Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d58525a5f8 | ||
|
|
4bbc090073 | ||
|
|
3edd6bd2d2 | ||
|
|
ed07ffdcf9 | ||
|
|
e6a1c65f22 | ||
|
|
badcf9cc3e | ||
|
|
b61f0dfc01 | ||
|
|
12d82f157b | ||
|
|
3ddc292dfc | ||
|
|
0a0e8299ac | ||
|
|
9402494f09 | ||
|
|
5cdfb4738a | ||
|
|
6c09419a07 |
No files matched your search
@@ -0,0 +1,101 @@
|
||||
# SLYNG
|
||||
|
||||
Sling sats and notes on nostr: https://slyng.yolopreneur.lol/
|
||||
|
||||
Aim at someone you follow, pull back for more, let go to send. Sats go as a zap (NIP-57) from your own wallet; the
|
||||
note goes private and gift-wrapped (NIP-17). A yolo venture on [yolopreneur.lol](https://yolopreneur.lol/),
|
||||
October 2026. The Y in the name is the slingshot.
|
||||
|
||||
## What it is
|
||||
|
||||
One static page, no backend, no dependencies. This folder is the site; `tests/` and `tools/` come along.
|
||||
|
||||
- `index.html`, `style.css`, `app.js`: the page. The slingshot fills the first screen; below it the story, how it
|
||||
works, the agent link, plans, the fine print.
|
||||
- `sling.js`: the slingshot. The pull is a polar coordinate around the fork: its angle picks who on the fence, its
|
||||
length picks the amount on the ladder (a note, then 21, 100, 210, 1,000, 2,100, 5,000, 10,000, 21,000 sats). Mouse,
|
||||
touch and keyboard (arrows aim and stretch, Enter lets go). The geometry is exported for the tests.
|
||||
- `lib/crypto.js`: SHA-256, HMAC, HKDF, secp256k1 (BIP-340 Schnorr and ECDH), ChaCha20, NIP-44 v2, and NIP-04 for
|
||||
wallets that still need it. Checked against the BIP-340 and NIP-44 test vectors.
|
||||
- `lib/nostr.js`: bech32 and NIP-19, events, signers (a key in the page, or a NIP-07 extension), a relay pool that
|
||||
answers NIP-42 AUTH, NIP-05 lookups.
|
||||
- `lib/zap.js`: lightning addresses, LNURL-pay, zap requests and receipts, and the check that an invoice is for exactly
|
||||
the amount pulled before anything is paid.
|
||||
- `lib/wallet.js`: the sender's wallet, WebLN (a browser extension) or Nostr Wallet Connect (NIP-47).
|
||||
- `lib/dm.js`: private notes, sealed and gift-wrapped (NIP-17, NIP-59).
|
||||
- `skill.md`, `llms.txt`: for agents. An agent can aim (a link with `#to=…&sats=…¬e=…`); only a person lets go.
|
||||
- `fonts/`: Slyng Condensed and Slyng Text, cut from TeX Gyre Heros Condensed and TeX Gyre Adventor (GUST Font
|
||||
License; `fonts/MANIFEST-SLYNG.txt` says what changed).
|
||||
- `tools/devnet.mjs`: a nostr network on localhost for the tests: a relay with optional AUTH, a lightning address
|
||||
server that publishes zap receipts, and a Nostr Wallet Connect wallet.
|
||||
- `branding/`: the brand page, https://slyng.yolopreneur.lol/branding/ (see Brand, below).
|
||||
|
||||
## Brand
|
||||
|
||||
The logo is the name in Unbounded ExtraBold with the Y as the slingshot: a volt stone on the vertex of the Y, and the
|
||||
band drawn up the inside of its arms to the tips, cut flush. Ink `#111014`, bone `#F1F0EC`, white, and volt
|
||||
`#D9FF3D`. The app icon is the slingshot alone, white on ink with the band and stone in volt. `branding/` has the
|
||||
files, the rules, the motion, and every draft from the six rounds that found it.
|
||||
|
||||
- `branding/index.html`, `branding/brand.css`: the page. No script, and its own Content-Security-Policy.
|
||||
- `branding/img/`: every image on it, drawn from the fonts' own outlines by `tools/brand/make.py` (Python 3.10+,
|
||||
fontTools, Pillow, and Playwright's Chromium for the animations; the fonts come from google/fonts at a fixed commit,
|
||||
checked by sha256). `branding/img/MANIFEST` has each file's sha256.
|
||||
- `tools/brand/`: one module per round of the work, kept as they were, so every draft comes out exactly as it was
|
||||
first drawn. `python3 tools/brand/make.py --stills` makes just the SVGs, in seconds.
|
||||
|
||||
The images are made files, like `shots/`, but the runner can't make them, so the branch carries only their
|
||||
`MANIFEST`: `tools/build.sh` runs `tools/brand/restore.sh`, which fetches each one back from the live site and checks
|
||||
it against `MANIFEST`. A refresh keeps the page whole, and refuses to land if anything doesn't match. To change the
|
||||
images: run `python3 tools/brand/make.py`, commit the new `MANIFEST`, and land the folder with `tools/bundle.sh` and
|
||||
the seed workflow.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Static, no server, no Cloudflare costs.** The page talks to nostr relays and to recipients' lightning address
|
||||
servers from the browser. The Content-Security-Policy allows scripts, styles and fonts from the page only, and
|
||||
connections to `https:` and `wss:` (plus localhost, for the devnet).
|
||||
- **Non-custodial.** Sats move from the sender's wallet to the recipient's; SLYNG never holds them. The invoice amount
|
||||
is read from the BOLT-11 prefix and must match the pull, or nothing is paid.
|
||||
- **A sling lands whole or not at all.** With sats and a note, the wallet pays first and the note goes only if the
|
||||
payment did. The stone is in the air for 2.6 seconds, and catching it back cancels everything.
|
||||
- **Zaps are public, notes are private.** The zap request carries no comment; the note travels separately, gift
|
||||
wrapped, to the recipient's NIP-17 inbox relays.
|
||||
- **Demo first.** Until someone signs in, four made-up people stand on the fence and the sats are pretend; nothing
|
||||
leaves the page. Signing in is a nostr extension (preferred), a key made here, or a pasted nsec.
|
||||
- **Verify everything.** Every event from a relay is checked (id and signature) before it's used, so a relay can't
|
||||
swap in someone else's lightning address.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
node --test tests/*.test.js # vectors, notes, zaps, the slingshot, and the devnet flow
|
||||
node tools/serve.mjs # in this folder, then open http://127.0.0.1:8000/
|
||||
node tools/devnet.mjs --auth # a local relay, lightning address server and wallet; prints test keys
|
||||
python3 tests/smoke.py # Chromium (Playwright), desktop and phone: the demo, and a live sling via the devnet
|
||||
tools/build.sh # the tests, branding/img from the live site, then shots/desktop.png and shots/og.png
|
||||
python3 tools/brand/make.py # every image on the brand page, from scratch, into branding/img/
|
||||
```
|
||||
|
||||
To sling by hand against the devnet, open `http://127.0.0.1:8000/?relays=ws://127.0.0.1:<port>` and sign in with one
|
||||
of the keys it printed (paste the `sk` as a secret key).
|
||||
|
||||
## Ship it
|
||||
|
||||
SLYNG lives where the yolo ventures live: `ventures/slyng/` on the gh-pages branch of teahubxyz/time, served at
|
||||
https://slyng.yolopreneur.lol/. To ship a change, commit this folder as `ventures/slyng/` to a branch of
|
||||
teahubxyz/time (say `venture/slyng`) and run Time's refresh workflow (Actions -> refresh) with slug `slyng` and from
|
||||
`venture/slyng`. It runs `tools/build.sh` on the runner, lands the folder on gh-pages in one commit, and asks Teahub
|
||||
pages to pull.
|
||||
|
||||
Or as a bundle: `tools/bundle.sh` makes `slyng.tgz` and its sha256 for the seed workflow, when the bundle can reach
|
||||
the runner (a Boring blob download link).
|
||||
|
||||
## Next
|
||||
|
||||
- Three front doors: slyng.app (notes), slyng.money (sats), slyng.ing (the market), maybe slyng.my for listings
|
||||
(`slyng.my/oldbike`, with `?via=<npub>` on a repost so the sale splits to whoever brought the buyer).
|
||||
- The market: stalls for local and digital goods, offers slung at a stall and countered, the deal desk as stall-keeper.
|
||||
- Files: sling a file and the key to open it.
|
||||
- Reach: for notes, a longer pull goes further (one person, a group, everyone).
|
||||
- Remote signers (NIP-46). A Whop app.
|
||||
@@ -0,0 +1,888 @@
|
||||
// SLYNG: aim at someone on the fence, pull back for more, let go to send.
|
||||
// Demo mode until you sign in; then notes go over nostr (NIP-17) and sats as zaps (NIP-57) from your own wallet.
|
||||
|
||||
import { Slingshot, LADDER, fmt, detentForAmount } from './sling.js';
|
||||
import { generatePrivateKey, hex, unhex, isHex64 } from './lib/crypto.js';
|
||||
import {
|
||||
LocalSigner, ExtensionSigner, Pool, parsePerson, parseSecret, nsecEncode, shortNpub, resolveNip05,
|
||||
parseProfile, latestBy, relayListFrom, dmRelaysFrom, normalizeRelay, now,
|
||||
} from './lib/nostr.js';
|
||||
import { payUrlFor, fetchPayEndpoint, fetchInvoice, zapRequestTemplate, readZapReceipt, lnurlEncode } from './lib/zap.js';
|
||||
import { WebLNWallet, NwcWallet } from './lib/wallet.js';
|
||||
import { sealNote, openWrap } from './lib/dm.js';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const area = (get) => ({
|
||||
get(k, d = null) { try { const v = get().getItem('slyng.' + k); return v == null ? d : JSON.parse(v); } catch { return d; } },
|
||||
set(k, v) { try { get().setItem('slyng.' + k, JSON.stringify(v)); } catch {} },
|
||||
del(k) { try { get().removeItem('slyng.' + k); } catch {} },
|
||||
});
|
||||
const local = area(() => localStorage);
|
||||
const session = area(() => sessionStorage);
|
||||
const uniq = (list) => [...new Set(list.filter(Boolean))];
|
||||
|
||||
const qs = new URLSearchParams(location.search);
|
||||
const override = (qs.get('relays') || '').split(',').map(normalizeRelay).filter(Boolean);
|
||||
const RELAYS = override.length ? override : ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
|
||||
const DM_RELAYS = override.length ? override : ['wss://relay.damus.io', 'wss://nos.lol', 'wss://auth.nostr1.com'];
|
||||
const FLIGHT_LIVE = Math.min(6000, Math.max(300, Number(qs.get('flight')) || 2600));
|
||||
const FLIGHT_DEMO = 1000;
|
||||
|
||||
const DEMO = [
|
||||
{ label: 'ana', replies: ['caught it. you didn\'t have to', 'ha, right back at you', 'that\'s my coffee sorted'], back: 21 },
|
||||
{ label: 'ben', replies: ['the records are yours, see you saturday', 'lunch is on me next time'] },
|
||||
{ label: 'cass', replies: ['got it, thank you', 'see you at the market'] },
|
||||
{ label: 'dev', replies: ['no lightning address yet, but the note made it'], notesOnly: true },
|
||||
].map((p) => ({ ...p, id: 'demo:' + p.label, initial: p.label[0].toUpperCase(), canTakeSats: !p.notesOnly, demo: true, n: 0 }));
|
||||
|
||||
const S = {
|
||||
mode: 'demo',
|
||||
signer: null,
|
||||
me: null,
|
||||
wallet: null,
|
||||
pool: new Pool(),
|
||||
myRead: [],
|
||||
myDm: [],
|
||||
people: new Map(),
|
||||
follows: [],
|
||||
fence: [],
|
||||
extra: new Map(),
|
||||
caught: [],
|
||||
slung: [],
|
||||
tab: 'caught',
|
||||
demoBalance: 21000,
|
||||
flight: null,
|
||||
subs: [],
|
||||
seenRumors: new Set(),
|
||||
seenZaps: new Set(),
|
||||
booted: now(),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- the readout
|
||||
|
||||
function say(amt, sub = '') {
|
||||
$('amt').textContent = amt;
|
||||
$('sub').textContent = sub;
|
||||
}
|
||||
|
||||
const noteText = () => $('note').value.trim();
|
||||
|
||||
function describe(sats, note) {
|
||||
if (!sats) return 'A note';
|
||||
return `${fmt(sats)} sats` + (note ? ' and a note' : '');
|
||||
}
|
||||
|
||||
function idleReadout() {
|
||||
if (S.flight) return;
|
||||
if (!S.fence.length) {
|
||||
say('Nobody on the fence', S.mode === 'live' ? 'Change who\'s on the fence to pick someone.' : '');
|
||||
return;
|
||||
}
|
||||
const hint = S.mode === 'demo'
|
||||
? 'Demo: pretend sats. Nothing leaves the page.'
|
||||
: !S.wallet ? 'Notes go now. Connect a wallet to sling sats too.' : 'Drag the stone down, away from who you aim at.';
|
||||
say('Pull back to sling', hint);
|
||||
}
|
||||
|
||||
function aimReadout({ target, detent, sats }) {
|
||||
if (S.flight) return;
|
||||
if (detent < 0 || !target) { idleReadout(); return; }
|
||||
const note = noteText();
|
||||
const to = `to ${target.label}`;
|
||||
if (detent === 0) { if (note) say('A note', to); else say('Write a note', 'or pull further for sats'); return; }
|
||||
if (!target.canTakeSats) { say('Notes only', `${target.label} has no lightning address yet`); return; }
|
||||
say(`${fmt(sats)} sats`, note ? `${to}, with a note` : to);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- the slingshot
|
||||
|
||||
let lastDetent = -1;
|
||||
const sling = new Slingshot($('sling'), {
|
||||
onAim(a) {
|
||||
if (a.detent !== lastDetent) {
|
||||
lastDetent = a.detent;
|
||||
if (a.detent >= 0) try { navigator.vibrate?.(6); } catch {}
|
||||
}
|
||||
aimReadout(a);
|
||||
},
|
||||
onSlack() { lastDetent = -1; idleReadout(); },
|
||||
onRelease(a) { lastDetent = -1; return release(a); },
|
||||
});
|
||||
|
||||
function release({ target, index, detent, sats }) {
|
||||
const note = noteText();
|
||||
if (!target) return false;
|
||||
if (detent === 0 && !note) {
|
||||
say('Write a note', 'or pull further for sats');
|
||||
$('note').focus({ preventScroll: true });
|
||||
return false;
|
||||
}
|
||||
if (sats > 0 && !target.canTakeSats) {
|
||||
say('Notes only', `${target.label} has no lightning address yet, so only notes reach them.`);
|
||||
return false;
|
||||
}
|
||||
if (S.mode === 'demo') {
|
||||
if (sats > S.demoBalance) { say('Not enough demo sats', `You have ${fmt(S.demoBalance)} left.`); return false; }
|
||||
} else {
|
||||
if (sats > 0 && !S.wallet) {
|
||||
say('Connect a wallet', 'Sats go from your own wallet. Notes work without one.');
|
||||
openWallet();
|
||||
return false;
|
||||
}
|
||||
if (note && !S.signer?.canEncrypt) {
|
||||
say('Can\'t seal notes', 'Your nostr extension can\'t encrypt (NIP-44). Sats still work.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
launch({ target, index, sats, note });
|
||||
return true;
|
||||
}
|
||||
|
||||
function launch({ target, index, sats, note }) {
|
||||
try { navigator.vibrate?.(14); } catch {}
|
||||
const f = { target, index, sats, note };
|
||||
if (S.mode === 'live' && sats > 0) {
|
||||
f.invoice = prepareInvoice(target, sats);
|
||||
f.invoice.catch(() => {});
|
||||
}
|
||||
S.flight = f;
|
||||
say(describe(sats, note), `In the air to ${target.label}. Catch it back if you change your mind.`);
|
||||
$('flight').hidden = false;
|
||||
f.ctl = sling.launch(index, {
|
||||
duration: S.mode === 'live' ? FLIGHT_LIVE : FLIGHT_DEMO,
|
||||
onLand: () => { $('flight').hidden = true; S.flight = null; landed(f); },
|
||||
onBack: () => { $('flight').hidden = true; S.flight = null; say('Caught it back', 'Nothing was sent.'); },
|
||||
});
|
||||
}
|
||||
|
||||
function clearNote() {
|
||||
$('note').value = '';
|
||||
autosize();
|
||||
sling.setNoted(false);
|
||||
sling.setSuggested(0);
|
||||
$('aimed').hidden = true;
|
||||
}
|
||||
|
||||
function landed(f) {
|
||||
if (S.mode === 'demo') demoLand(f);
|
||||
else liveLand(f);
|
||||
}
|
||||
|
||||
function demoLand(f) {
|
||||
clearNote();
|
||||
S.demoBalance -= f.sats;
|
||||
addEntry('slung', { who: f.target, sats: f.sats, note: f.note, at: now(), status: 'Caught', good: true });
|
||||
if (f.sats) sling.float(f.index, '+' + fmt(f.sats));
|
||||
say('Slung', `${describe(f.sats, f.note)} to ${f.target.label}. They caught it.`);
|
||||
updateChips();
|
||||
const p = f.target;
|
||||
const reply = p.replies?.[(p.n = (p.n || 0) + 1) % p.replies.length] || 'caught it';
|
||||
setTimeout(() => {
|
||||
if (S.mode !== 'demo') return;
|
||||
const back = p.back && f.sats >= 100 ? p.back : 0;
|
||||
if (back) { S.demoBalance += back; updateChips(); }
|
||||
addEntry('caught', { who: p, sats: back, note: reply, at: now() });
|
||||
toast(`${p.label} slung back ${back ? fmt(back) + ' sats and ' : ''}a note`);
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
async function liveLand(f) {
|
||||
const entry = addEntry('slung', { who: f.target, sats: f.sats, note: f.note, at: now(), status: f.sats ? 'Paying' : 'Sending' });
|
||||
let paid = false;
|
||||
try {
|
||||
if (f.sats > 0) {
|
||||
const { pr, zapRequestId } = await f.invoice;
|
||||
entry.zapRequestId = zapRequestId;
|
||||
await S.wallet.pay(pr);
|
||||
paid = true;
|
||||
if (f.note) updateEntry(entry, { status: 'Paid. Sending the note' });
|
||||
sling.float(f.index, '+' + fmt(f.sats));
|
||||
}
|
||||
let inbox = true;
|
||||
if (f.note) inbox = (await sendNote(f.target.pubkey, f.note)).hasInbox;
|
||||
entry.done = true;
|
||||
updateEntry(entry, {
|
||||
status: entry.caught ? 'Caught' : inbox ? 'Landed' : 'Landed. They haven\'t set up private notes, so their app may not show it.',
|
||||
good: true,
|
||||
});
|
||||
clearNote();
|
||||
say('Slung', `${describe(f.sats, f.note)} to ${f.target.label}.`);
|
||||
} catch (err) {
|
||||
const reason = String(err?.message || err);
|
||||
if (paid) {
|
||||
updateEntry(entry, { status: `The sats landed; the note didn't. ${reason}`, bad: true });
|
||||
say('Half landed', `The sats reached ${f.target.label}; the note didn't. ${reason}`);
|
||||
} else {
|
||||
updateEntry(entry, { status: `Didn't land. ${reason}`, bad: true });
|
||||
say('Nothing was sent', reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$('catchback').addEventListener('click', () => S.flight?.ctl?.cancel());
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- nostr, live
|
||||
|
||||
const profileTimes = new Map();
|
||||
|
||||
async function loadProfiles(pubkeys) {
|
||||
const want = uniq(pubkeys).filter(isHex64);
|
||||
for (let i = 0; i < want.length; i += 100) {
|
||||
const chunk = want.slice(i, i + 100);
|
||||
const evs = await S.pool.query(uniq([...RELAYS, ...S.myRead]), [{ kinds: [0], authors: chunk }], { timeout: 6000 });
|
||||
for (const [pk, e] of latestBy(evs)) {
|
||||
const cur = S.people.get(pk);
|
||||
if (!cur || (cur.created_at || 0) <= e.created_at) S.people.set(pk, parseProfile(e));
|
||||
profileTimes.set(pk, now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function freshProfile(pubkey) {
|
||||
if (!profileTimes.has(pubkey) || now() - profileTimes.get(pubkey) > 120) await loadProfiles([pubkey]);
|
||||
return S.people.get(pubkey) || { pubkey };
|
||||
}
|
||||
|
||||
const relayInfo = new Map();
|
||||
async function relaysOf(pubkey) {
|
||||
const c = relayInfo.get(pubkey);
|
||||
if (c && now() - c.t < 600) return c;
|
||||
const evs = await S.pool.query(RELAYS, [{ kinds: [10002, 10050], authors: [pubkey] }], { timeout: 5000 });
|
||||
const m = latestBy(evs, (e) => e.kind);
|
||||
const { read } = relayListFrom(m.get(10002));
|
||||
const info = { read, dm: dmRelaysFrom(m.get(10050)), t: now() };
|
||||
relayInfo.set(pubkey, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
const myDmRelays = () => (S.myDm.length ? S.myDm : DM_RELAYS);
|
||||
|
||||
async function prepareInvoice(person, sats) {
|
||||
const profile = await freshProfile(person.pubkey);
|
||||
const url = payUrlFor(profile);
|
||||
if (!url) throw new Error(`${person.label} has no lightning address.`);
|
||||
const ep = await fetchPayEndpoint(url);
|
||||
const msats = sats * 1000;
|
||||
let zr = null;
|
||||
if (ep.allowsNostr) {
|
||||
const theirs = (await relaysOf(person.pubkey)).read;
|
||||
const relays = uniq([...S.myRead, ...RELAYS, ...theirs]).slice(0, 6);
|
||||
zr = await S.signer.signEvent(zapRequestTemplate({ recipient: person.pubkey, msats, relays, lnurl: lnurlEncode(url) }));
|
||||
}
|
||||
const pr = await fetchInvoice(ep, msats, zr);
|
||||
return { pr, zapRequestId: zr?.id || null };
|
||||
}
|
||||
|
||||
async function sendNote(pubkey, text) {
|
||||
const { rumor, toThem, toMe } = await sealNote(S.signer, pubkey, text);
|
||||
S.seenRumors.add(rumor.id);
|
||||
const info = await relaysOf(pubkey);
|
||||
const targets = info.dm.length ? info.dm : uniq([...info.read, ...DM_RELAYS]);
|
||||
const r = await S.pool.publish(targets, toThem);
|
||||
if (!r.ok) throw new Error('No relay took the note.');
|
||||
if (toMe) S.pool.publish(myDmRelays(), toMe);
|
||||
return { hasInbox: info.dm.length > 0 };
|
||||
}
|
||||
|
||||
function personFor(pk) {
|
||||
const p = S.people.get(pk) || { pubkey: pk };
|
||||
const label = p.name || (p.nip05 ? p.nip05.replace(/^_@/, '') : '') || shortNpub(pk);
|
||||
return {
|
||||
id: pk, pubkey: pk, label,
|
||||
initial: (label.replace(/^[^a-z0-9]+/i, '')[0] || '?').toUpperCase(),
|
||||
picture: p.picture || '',
|
||||
canTakeSats: Boolean(payUrlFor(p)),
|
||||
};
|
||||
}
|
||||
|
||||
async function goLive(signer, { remember = false, how = 'local', quiet = false } = {}) {
|
||||
stopLive();
|
||||
S.signer = signer;
|
||||
S.pool.signer = signer;
|
||||
S.mode = 'live';
|
||||
S.me = signer.pubkey;
|
||||
S.caught = [];
|
||||
S.slung = [];
|
||||
renderLog();
|
||||
const saved = how === 'extension' ? { type: 'extension' } : { type: 'local', sk: hex(signer.priv) };
|
||||
local.del('account'); session.del('account');
|
||||
(remember ? local : session).set('account', saved);
|
||||
updateChips();
|
||||
setFence([]);
|
||||
say('Finding who you follow', 'Asking the relays.');
|
||||
const me = S.me;
|
||||
try {
|
||||
const evs = await S.pool.query(RELAYS, [{ kinds: [0, 3, 10002, 10050], authors: [me] }], { timeout: 7000 });
|
||||
if (S.me !== me) return;
|
||||
const m = latestBy(evs, (e) => e.kind);
|
||||
if (m.get(0)) S.people.set(me, parseProfile(m.get(0)));
|
||||
S.myRead = relayListFrom(m.get(10002)).read;
|
||||
S.myDm = dmRelaysFrom(m.get(10050));
|
||||
const contacts = m.get(3);
|
||||
S.follows = contacts ? uniq(contacts.tags.filter((t) => t[0] === 'p' && isHex64(t[1])).map((t) => t[1])).reverse() : [];
|
||||
const pins = (local.get('fence.' + me, []) || []).filter(isHex64);
|
||||
await loadProfiles(uniq([...pins, ...S.follows.slice(0, 120)]));
|
||||
if (S.me !== me) return;
|
||||
let fence = pins;
|
||||
if (!fence.length) {
|
||||
fence = S.follows.filter((pk) => payUrlFor(S.people.get(pk))).slice(0, 4);
|
||||
if (fence.length < 3) fence = uniq([...fence, ...S.follows]).slice(0, 4);
|
||||
}
|
||||
setFence(fence.map(personFor));
|
||||
updateChips();
|
||||
if (!S.follows.length && !fence.length) say('Nobody on the fence', 'You don\'t follow anyone yet. Add someone by npub or name@domain.');
|
||||
else if (!quiet) toast(`Signed in. ${S.fence.length} on the fence.`);
|
||||
startInbox();
|
||||
await applyHash();
|
||||
} catch (err) {
|
||||
say('Couldn\'t reach the relays', String(err?.message || err));
|
||||
}
|
||||
}
|
||||
|
||||
function stopLive() {
|
||||
for (const s of S.subs) s.close();
|
||||
S.subs = [];
|
||||
S.signer = null;
|
||||
S.pool.signer = null;
|
||||
S.me = null;
|
||||
S.follows = [];
|
||||
S.myRead = [];
|
||||
S.myDm = [];
|
||||
S.seenRumors.clear();
|
||||
S.seenZaps.clear();
|
||||
}
|
||||
|
||||
function goDemo() {
|
||||
stopLive();
|
||||
local.del('account');
|
||||
session.del('account');
|
||||
S.mode = 'demo';
|
||||
S.caught = [];
|
||||
S.slung = [];
|
||||
S.demoBalance = 21000;
|
||||
setFence(DEMO);
|
||||
updateChips();
|
||||
renderLog();
|
||||
idleReadout();
|
||||
}
|
||||
|
||||
function startInbox() {
|
||||
const me = S.me;
|
||||
const since = now() - 30 * 86400;
|
||||
const fresh = (t) => t >= S.booted - 5;
|
||||
S.subs.push(S.pool.subscribe(uniq([...myDmRelays(), ...RELAYS]), [{ kinds: [1059], '#p': [me], since: since - 2 * 86400, limit: 200 }], {
|
||||
onevent: async (w) => {
|
||||
const r = await openWrap(S.signer, w);
|
||||
if (!r || S.me !== me || S.seenRumors.has(r.id)) return;
|
||||
S.seenRumors.add(r.id);
|
||||
const fromMe = r.pubkey === me;
|
||||
const other = fromMe ? r.to : r.pubkey;
|
||||
if (!isHex64(other || '')) return;
|
||||
await ensureProfile(other);
|
||||
addEntry(fromMe ? 'slung' : 'caught', {
|
||||
who: personFor(other), sats: 0, note: r.content, at: r.created_at,
|
||||
status: fromMe ? 'Landed' : undefined, good: fromMe,
|
||||
});
|
||||
if (!fromMe && fresh(r.created_at)) toast(`${personFor(other).label} slung you a note`);
|
||||
},
|
||||
}));
|
||||
const zapRelays = uniq([...S.myRead, ...RELAYS]);
|
||||
S.subs.push(S.pool.subscribe(zapRelays, [{ kinds: [9735], '#p': [me], since, limit: 100 }], {
|
||||
onevent: async (e) => {
|
||||
const z = readZapReceipt(e);
|
||||
if (!z || z.to !== me || S.me !== me || S.seenZaps.has(z.id)) return;
|
||||
S.seenZaps.add(z.id);
|
||||
await ensureProfile(z.from);
|
||||
addEntry('caught', { who: personFor(z.from), sats: z.sats, note: z.comment, at: z.at });
|
||||
if (fresh(z.at)) toast(`${personFor(z.from).label} slung you ${fmt(z.sats)} sats`);
|
||||
},
|
||||
}));
|
||||
S.subs.push(S.pool.subscribe(zapRelays, [{ kinds: [9735], '#P': [me], since, limit: 100 }], {
|
||||
onevent: async (e) => {
|
||||
const z = readZapReceipt(e);
|
||||
if (!z || z.from !== me || S.me !== me || S.seenZaps.has(z.id)) return;
|
||||
S.seenZaps.add(z.id);
|
||||
const mine = S.slung.find((x) => x.zapRequestId && x.zapRequestId === z.requestId);
|
||||
if (mine) {
|
||||
mine.caught = true;
|
||||
if (mine.done) updateEntry(mine, { status: 'Caught', good: true });
|
||||
toast(`${mine.who.label} caught ${fmt(z.sats)} sats`);
|
||||
return;
|
||||
}
|
||||
if (!isHex64(z.to || '')) return;
|
||||
await ensureProfile(z.to);
|
||||
addEntry('slung', { who: personFor(z.to), sats: z.sats, note: '', at: z.at, status: 'Caught', good: true });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const asking = new Map();
|
||||
function ensureProfile(pk) {
|
||||
if (S.people.has(pk)) return Promise.resolve();
|
||||
if (!asking.has(pk)) asking.set(pk, loadProfiles([pk]).catch(() => {}));
|
||||
return asking.get(pk);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- the fence
|
||||
|
||||
function setFence(people) {
|
||||
S.fence = people.slice(0, 5);
|
||||
sling.setTargets(S.fence);
|
||||
if (S.mode === 'live' && S.me) local.set('fence.' + S.me, S.fence.map((p) => p.pubkey));
|
||||
idleReadout();
|
||||
}
|
||||
|
||||
function putOnFence(person) {
|
||||
const rest = S.fence.filter((p) => p.id !== person.id);
|
||||
setFence([person, ...rest].slice(0, 5));
|
||||
}
|
||||
|
||||
function fencePeople() {
|
||||
if (S.mode === 'demo') return DEMO;
|
||||
const seen = new Set();
|
||||
const list = [];
|
||||
for (const pk of [...S.fence.map((p) => p.pubkey), ...S.extra.keys(), ...S.follows]) {
|
||||
if (seen.has(pk)) continue;
|
||||
seen.add(pk);
|
||||
list.push(personFor(pk));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function openFence() {
|
||||
$('fence-q').value = '';
|
||||
$('fence-status').textContent = '';
|
||||
$('fence-lede').textContent = S.mode === 'demo'
|
||||
? 'In the demo, these four stand on the fence. Sign in to sling to the people you follow.'
|
||||
: 'Up to five people stand on the fence at a time. Pick from who you follow, or add anyone by npub or name@domain.';
|
||||
$('fence-q').closest('label').hidden = S.mode === 'demo';
|
||||
renderFenceList();
|
||||
$('fencedlg').showModal();
|
||||
if (S.mode === 'live') {
|
||||
const missing = S.follows.filter((pk) => !S.people.has(pk)).slice(0, 400);
|
||||
if (missing.length) {
|
||||
$('fence-status').textContent = 'Loading names';
|
||||
loadProfiles(missing).then(() => { $('fence-status').textContent = ''; renderFenceList(); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let searchTimer = 0;
|
||||
$('fence-q').addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(searchFence, 250);
|
||||
});
|
||||
|
||||
async function searchFence() {
|
||||
const q = $('fence-q').value.trim();
|
||||
$('fence-status').classList.remove('bad');
|
||||
let direct = null;
|
||||
try { direct = parsePerson(q); } catch {}
|
||||
if (!direct && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q)) {
|
||||
$('fence-status').textContent = `Looking up ${q}`;
|
||||
try { direct = await resolveNip05(q); } catch (err) {
|
||||
$('fence-status').textContent = String(err.message || err);
|
||||
$('fence-status').classList.add('bad');
|
||||
}
|
||||
}
|
||||
if (direct) {
|
||||
S.extra.set(direct.pubkey, true);
|
||||
await loadProfiles([direct.pubkey]);
|
||||
$('fence-status').textContent = '';
|
||||
} else if ($('fence-status').textContent.startsWith('Looking up')) {
|
||||
$('fence-status').textContent = '';
|
||||
}
|
||||
renderFenceList(direct?.pubkey);
|
||||
}
|
||||
|
||||
function face(person) {
|
||||
const f = document.createElement('span');
|
||||
f.className = 'face';
|
||||
f.setAttribute('aria-hidden', 'true');
|
||||
f.textContent = person.initial || '?';
|
||||
if (person.picture) {
|
||||
const img = document.createElement('img');
|
||||
img.src = person.picture;
|
||||
img.alt = '';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
img.loading = 'lazy';
|
||||
img.onerror = () => img.remove();
|
||||
f.textContent = '';
|
||||
f.appendChild(img);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
function renderFenceList(first = null) {
|
||||
const ul = $('fence-list');
|
||||
ul.innerHTML = '';
|
||||
const q = $('fence-q').value.trim().toLowerCase();
|
||||
let list = fencePeople();
|
||||
if (q && !first) {
|
||||
list = list.filter((p) => {
|
||||
const prof = S.people.get(p.pubkey) || {};
|
||||
return [p.label, prof.handle, prof.nip05].some((s) => String(s || '').toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
if (first) list.sort((a, b) => (a.pubkey === first ? -1 : b.pubkey === first ? 1 : 0));
|
||||
for (const p of list.slice(0, 200)) {
|
||||
const li = document.createElement('li');
|
||||
li.appendChild(face(p));
|
||||
const txt = document.createElement('div');
|
||||
const n = document.createElement('div');
|
||||
n.className = 'who-name';
|
||||
n.textContent = p.label;
|
||||
const s = document.createElement('div');
|
||||
s.className = 'who-sub';
|
||||
const prof = S.people.get(p.pubkey);
|
||||
s.textContent = p.demo ? (p.canTakeSats ? 'takes sats and notes' : 'notes only') : (prof?.nip05 || shortNpub(p.pubkey)) + (p.canTakeSats ? '' : ', notes only');
|
||||
txt.append(n, s);
|
||||
li.appendChild(txt);
|
||||
const on = S.fence.some((x) => x.id === p.id);
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'pill small ghost';
|
||||
b.setAttribute('aria-pressed', String(on));
|
||||
b.textContent = on ? 'On the fence' : 'Put on the fence';
|
||||
b.disabled = S.mode === 'demo';
|
||||
b.addEventListener('click', () => {
|
||||
const isOn = S.fence.some((x) => x.id === p.id);
|
||||
if (isOn) setFence(S.fence.filter((x) => x.id !== p.id));
|
||||
else if (S.fence.length >= 5) {
|
||||
$('fence-status').textContent = 'The fence holds five. Take someone off first.';
|
||||
return;
|
||||
} else setFence([...S.fence, p]);
|
||||
$('fence-status').textContent = '';
|
||||
renderFenceList(first);
|
||||
});
|
||||
li.appendChild(b);
|
||||
ul.appendChild(li);
|
||||
}
|
||||
if (!list.length) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = q ? 'Nobody by that name among who you follow. Paste their npub or name@domain.' : 'You don\'t follow anyone yet.';
|
||||
ul.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
$('fence').addEventListener('click', openFence);
|
||||
$('fence-done').addEventListener('click', () => $('fencedlg').close());
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- the log
|
||||
|
||||
function ago(t) {
|
||||
const d = now() - t;
|
||||
if (d < 45) return 'just now';
|
||||
if (d < 3600) return Math.round(d / 60) + ' min';
|
||||
if (d < 86400) return Math.round(d / 3600) + ' h';
|
||||
return new Date(t * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function addEntry(list, e) {
|
||||
S[list].push(e);
|
||||
renderLog();
|
||||
return e;
|
||||
}
|
||||
|
||||
function updateEntry(e, patch) {
|
||||
Object.assign(e, patch);
|
||||
if (patch.good) e.bad = false;
|
||||
renderLog();
|
||||
}
|
||||
|
||||
function renderLog() {
|
||||
for (const list of ['caught', 'slung']) {
|
||||
const ol = $(list);
|
||||
ol.innerHTML = '';
|
||||
const items = [...S[list]].sort((a, b) => b.at - a.at).slice(0, 100);
|
||||
for (const e of items) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'entry';
|
||||
li.appendChild(face(e.who));
|
||||
const body = document.createElement('div');
|
||||
const top = document.createElement('div');
|
||||
top.className = 'entry-top';
|
||||
const what = document.createElement('span');
|
||||
what.className = 'entry-what';
|
||||
const name = document.createElement('b');
|
||||
name.textContent = e.who.label;
|
||||
const amount = e.sats ? `${fmt(e.sats)} sats` : 'a note';
|
||||
if (list === 'caught') what.append(name, ` slung you ${amount}`);
|
||||
else what.append('You slung ', name, ` ${e.sats ? amount + (e.note ? ' and a note' : '') : 'a note'}`);
|
||||
const when = document.createElement('span');
|
||||
when.className = 'entry-when';
|
||||
when.textContent = ago(e.at);
|
||||
top.append(what, when);
|
||||
body.appendChild(top);
|
||||
if (e.note) {
|
||||
const n = document.createElement('p');
|
||||
n.className = 'entry-note';
|
||||
n.textContent = e.note;
|
||||
body.appendChild(n);
|
||||
}
|
||||
if (e.status) {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'entry-status' + (e.bad ? ' bad' : e.good ? ' good' : '');
|
||||
s.textContent = e.status;
|
||||
body.appendChild(s);
|
||||
}
|
||||
if (list === 'caught' && !S.fence.some((p) => p.id === e.who.id)) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'linkish aim-back';
|
||||
b.textContent = 'Sling one back';
|
||||
b.addEventListener('click', () => {
|
||||
putOnFence(e.who);
|
||||
renderLog();
|
||||
$('sling').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
$('sling').focus({ preventScroll: true });
|
||||
});
|
||||
body.appendChild(b);
|
||||
}
|
||||
li.appendChild(body);
|
||||
ol.appendChild(li);
|
||||
}
|
||||
}
|
||||
$('n-caught').textContent = S.caught.length ? String(S.caught.length) : '';
|
||||
$('n-slung').textContent = S.slung.length ? String(S.slung.length) : '';
|
||||
const current = S[S.tab];
|
||||
$('log-empty').textContent = current.length ? '' : S.tab === 'caught'
|
||||
? (S.mode === 'demo' ? 'Nothing caught yet. Sling something; in the demo, people sling back.' : 'Nothing caught yet. Notes and sats people sling you show up here.')
|
||||
: 'Nothing slung yet.';
|
||||
}
|
||||
|
||||
for (const t of ['caught', 'slung']) {
|
||||
$('t-' + t).addEventListener('click', () => {
|
||||
S.tab = t;
|
||||
for (const u of ['caught', 'slung']) {
|
||||
$('t-' + u).setAttribute('aria-selected', String(u === t));
|
||||
$(u).hidden = u !== t;
|
||||
}
|
||||
renderLog();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- account and wallet
|
||||
|
||||
function updateChips() {
|
||||
const a = $('account'), w = $('wallet');
|
||||
if (S.mode === 'live') {
|
||||
const p = S.people.get(S.me);
|
||||
a.textContent = p?.name || shortNpub(S.me);
|
||||
a.classList.add('live');
|
||||
w.textContent = S.wallet ? S.wallet.label : 'Connect a wallet';
|
||||
w.classList.toggle('live', Boolean(S.wallet));
|
||||
} else {
|
||||
a.textContent = 'Sign in';
|
||||
a.classList.remove('live');
|
||||
w.textContent = `${fmt(S.demoBalance)} demo sats`;
|
||||
w.classList.remove('live');
|
||||
}
|
||||
}
|
||||
|
||||
function accStatus(msg, bad = false) {
|
||||
$('acc-status').textContent = msg;
|
||||
$('acc-status').classList.toggle('bad', bad);
|
||||
}
|
||||
|
||||
function openAccount() {
|
||||
accStatus('');
|
||||
const live = S.mode === 'live';
|
||||
$('acc-out').hidden = live;
|
||||
$('acc-in').hidden = !live;
|
||||
$('acc-h').textContent = live ? 'Signed in' : 'Sling for real';
|
||||
if (live) {
|
||||
const p = S.people.get(S.me);
|
||||
$('acc-who').textContent = `You're ${p?.name ? p.name + ', ' : ''}${shortNpub(S.me)}.`;
|
||||
$('acc-how').textContent = S.signer.kind === 'extension'
|
||||
? 'Your nostr extension signs for you.'
|
||||
: (local.get('account') ? 'Your key is remembered in this browser.' : 'Your key is kept for this tab only.');
|
||||
$('acc-show').hidden = S.signer.kind !== 'local';
|
||||
} else {
|
||||
const has = Boolean(globalThis.nostr);
|
||||
$('acc-ext').disabled = !has;
|
||||
$('acc-ext-note').textContent = has
|
||||
? 'Alby, nos2x and others. Your key stays in the extension.'
|
||||
: 'No nostr extension in this browser. Alby or nos2x adds one.';
|
||||
}
|
||||
$('accountdlg').showModal();
|
||||
}
|
||||
|
||||
async function signInWith(make, how) {
|
||||
accStatus('Signing in');
|
||||
try {
|
||||
const signer = await make();
|
||||
$('accountdlg').close();
|
||||
await goLive(signer, { remember: $('acc-remember').checked, how });
|
||||
return signer;
|
||||
} catch (err) {
|
||||
accStatus(String(err?.message || err), true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$('account').addEventListener('click', openAccount);
|
||||
$('acc-close').addEventListener('click', () => $('accountdlg').close());
|
||||
$('acc-ext').addEventListener('click', () => signInWith(() => ExtensionSigner.connect(), 'extension'));
|
||||
$('acc-new').addEventListener('click', async () => {
|
||||
const priv = generatePrivateKey();
|
||||
const signer = await signInWith(async () => new LocalSigner(priv), 'local');
|
||||
if (signer) showKey(priv);
|
||||
});
|
||||
$('acc-paste').addEventListener('click', () => signInWith(async () => new LocalSigner(parseSecret($('acc-nsec').value)), 'local'));
|
||||
$('acc-out-btn').addEventListener('click', () => { $('accountdlg').close(); goDemo(); toast('Signed out. Back to the demo.'); });
|
||||
$('acc-show').addEventListener('click', () => { if (S.signer?.priv) { $('accountdlg').close(); showKey(S.signer.priv); } });
|
||||
|
||||
function showKey(priv) {
|
||||
$('key-nsec').textContent = nsecEncode(priv);
|
||||
$('keydlg').showModal();
|
||||
}
|
||||
$('key-copy').addEventListener('click', () => copy($('key-nsec').textContent, 'Key copied. Keep it somewhere private.'));
|
||||
|
||||
function walStatus(msg, bad = false) {
|
||||
$('wal-status').textContent = msg;
|
||||
$('wal-status').classList.toggle('bad', bad);
|
||||
}
|
||||
|
||||
function openWallet() {
|
||||
walStatus(S.mode === 'demo' ? 'You\'re in the demo, so the sats are pretend until you sign in.' : '');
|
||||
$('wal-out').hidden = Boolean(S.wallet);
|
||||
$('wal-in').hidden = !S.wallet;
|
||||
if (S.wallet) $('wal-which').textContent = `Connected: ${S.wallet.label} (${S.wallet.kind === 'nwc' ? 'Nostr Wallet Connect' : 'browser wallet'}).`;
|
||||
const has = Boolean(globalThis.webln);
|
||||
$('wal-webln').disabled = !has;
|
||||
$('wal-webln-note').textContent = has ? 'A lightning extension, such as Alby.' : 'No lightning extension in this browser. Alby adds one.';
|
||||
$('walletdlg').showModal();
|
||||
}
|
||||
|
||||
function useWallet(w, saved, remember) {
|
||||
S.wallet = w;
|
||||
local.del('wallet'); session.del('wallet');
|
||||
(remember ? local : session).set('wallet', saved);
|
||||
updateChips();
|
||||
idleReadout();
|
||||
}
|
||||
|
||||
$('wallet').addEventListener('click', openWallet);
|
||||
$('wal-close').addEventListener('click', () => $('walletdlg').close());
|
||||
$('wal-webln').addEventListener('click', async () => {
|
||||
walStatus('Connecting');
|
||||
try {
|
||||
useWallet(await WebLNWallet.connect(), { type: 'webln' }, $('wal-remember').checked);
|
||||
$('walletdlg').close();
|
||||
toast('Wallet connected');
|
||||
} catch (err) { walStatus(String(err?.message || err), true); }
|
||||
});
|
||||
$('wal-connect').addEventListener('click', async () => {
|
||||
const uri = $('wal-nwc').value.trim();
|
||||
walStatus('Asking your wallet');
|
||||
try {
|
||||
useWallet(await NwcWallet.connect(uri, S.pool), { type: 'nwc', uri }, $('wal-remember').checked);
|
||||
$('wal-nwc').value = '';
|
||||
$('walletdlg').close();
|
||||
toast('Wallet connected');
|
||||
} catch (err) { walStatus(String(err?.message || err), true); }
|
||||
});
|
||||
$('wal-forget').addEventListener('click', () => {
|
||||
S.wallet = null;
|
||||
local.del('wallet'); session.del('wallet');
|
||||
updateChips();
|
||||
idleReadout();
|
||||
$('walletdlg').close();
|
||||
toast('Wallet disconnected');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- links an agent made
|
||||
|
||||
async function applyHash() {
|
||||
const h = new URLSearchParams(location.hash.replace(/^#/, ''));
|
||||
const to = (h.get('to') || '').trim();
|
||||
if (!to) return;
|
||||
const asked = Math.max(0, Math.min(21000, Math.floor(Number(h.get('sats')) || 0)));
|
||||
const note = (h.get('note') || '').slice(0, 1000);
|
||||
let parsed = null;
|
||||
try { parsed = parsePerson(to); } catch {}
|
||||
let target = null;
|
||||
if (S.mode === 'live') {
|
||||
try {
|
||||
if (!parsed && to.includes('@')) parsed = await resolveNip05(to);
|
||||
if (parsed) { await loadProfiles([parsed.pubkey]); S.extra.set(parsed.pubkey, true); target = personFor(parsed.pubkey); }
|
||||
} catch {}
|
||||
} else if (parsed || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) {
|
||||
const label = parsed ? shortNpub(parsed.pubkey) : to.replace(/^_@/, '');
|
||||
target = { id: 'link:' + (parsed?.pubkey || to), pubkey: parsed?.pubkey, label, initial: (label.replace(/^[^a-z0-9]+/i, '')[0] || '?').toUpperCase(), canTakeSats: true, demo: true, replies: ['caught it, thanks'], n: 0 };
|
||||
}
|
||||
const banner = $('aimed');
|
||||
banner.hidden = false;
|
||||
if (!target) {
|
||||
banner.textContent = `This link aims at ${to}, but that isn't a nostr name SLYNG can find.`;
|
||||
return;
|
||||
}
|
||||
putOnFence(target);
|
||||
if (note) { $('note').value = note; autosize(); sling.setNoted(true); }
|
||||
const d = asked ? detentForAmount(asked) : 0;
|
||||
sling.setSuggested(d);
|
||||
const amount = asked ? `: ${fmt(LADDER[d])} sats${LADDER[d] !== asked ? ` (the ring closest to the ${fmt(asked)} it asked for)` : ''}` : '';
|
||||
banner.textContent = `A link aimed this at ${target.label}${amount}${note ? ', with a note' : ''}. `
|
||||
+ (asked ? 'Pull to the marked ring and let go' : 'Pull back and let go')
|
||||
+ (S.mode === 'demo' ? '. In the demo nothing is sent; sign in to sling it for real.' : '.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- small things
|
||||
|
||||
let toastTimer = 0;
|
||||
function toast(msg, ms = 3800) {
|
||||
const t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { t.hidden = true; }, ms);
|
||||
}
|
||||
|
||||
async function copy(text, done = 'Copied') {
|
||||
try { await navigator.clipboard.writeText(text); toast(done); } catch { toast('Couldn\'t copy. Select the text and copy it yourself.'); }
|
||||
}
|
||||
|
||||
for (const b of document.querySelectorAll('[data-copy]')) {
|
||||
b.addEventListener('click', () => copy($(b.dataset.copy).textContent));
|
||||
}
|
||||
|
||||
function autosize() {
|
||||
const t = $('note');
|
||||
t.style.height = 'auto';
|
||||
t.style.height = Math.min(t.scrollHeight + 3, 140) + 'px';
|
||||
}
|
||||
$('note').addEventListener('input', () => { autosize(); sling.setNoted(Boolean(noteText())); });
|
||||
$('note').addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('sling').focus(); } });
|
||||
|
||||
const waitFor = (get, ms) => new Promise((resolve) => {
|
||||
const t0 = Date.now();
|
||||
const tick = () => { const v = get(); if (v || Date.now() - t0 > ms) resolve(v); else setTimeout(tick, 100); };
|
||||
tick();
|
||||
});
|
||||
|
||||
async function boot() {
|
||||
setFence(DEMO);
|
||||
updateChips();
|
||||
renderLog();
|
||||
idleReadout();
|
||||
const acc = session.get('account') || local.get('account');
|
||||
const remembered = Boolean(local.get('account'));
|
||||
if (acc?.type === 'local' && isHex64(acc.sk || '')) {
|
||||
try { await goLive(new LocalSigner(unhex(acc.sk)), { remember: remembered, how: 'local', quiet: true }); } catch {}
|
||||
} else if (acc?.type === 'extension') {
|
||||
const ext = await waitFor(() => globalThis.nostr, 1500);
|
||||
if (ext) try { await goLive(await ExtensionSigner.connect(ext), { remember: remembered, how: 'extension', quiet: true }); } catch {}
|
||||
}
|
||||
if (S.mode === 'demo') await applyHash();
|
||||
const w = session.get('wallet') || local.get('wallet');
|
||||
const wRemember = Boolean(local.get('wallet'));
|
||||
if (w?.type === 'nwc') NwcWallet.connect(w.uri, S.pool).then((x) => useWallet(x, w, wRemember)).catch(() => {});
|
||||
if (w?.type === 'webln') {
|
||||
waitFor(() => globalThis.webln, 1500).then((p) => p && WebLNWallet.connect(p).then((x) => useWallet(x, w, wRemember)).catch(() => {}));
|
||||
}
|
||||
addEventListener('hashchange', () => applyHash());
|
||||
document.documentElement.dataset.ready = 'true';
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,203 @@
|
||||
@font-face { font-family: "Slyng Condensed"; src: url("../fonts/slyng-condensed-bold.woff") format("woff"); font-weight: 700; font-style: normal; font-display: swap; }
|
||||
@font-face { font-family: "Slyng Text"; src: url("../fonts/slyng-text-regular.woff") format("woff"); font-weight: 400; font-style: normal; font-display: swap; }
|
||||
@font-face { font-family: "Slyng Text"; src: url("../fonts/slyng-text-bold.woff") format("woff"); font-weight: 700; font-style: normal; font-display: swap; }
|
||||
|
||||
/* The brand's four colours, and the grounds the round-one drafts were made on. */
|
||||
:root {
|
||||
--ink: #111014;
|
||||
--bone: #F1F0EC;
|
||||
--white: #FFFFFF;
|
||||
--volt: #D9FF3D;
|
||||
--ink-2: #57554F;
|
||||
--line: rgba(17, 16, 20, 0.14);
|
||||
--red: #FF3D2E;
|
||||
--peri: #C9C6FF;
|
||||
--cobalt: #2340FF;
|
||||
--concrete: #D7D5CF;
|
||||
--cond: "Slyng Condensed", "Arial Narrow", "Helvetica Neue", sans-serif;
|
||||
--text: "Slyng Text", "Century Gothic", "Avenir", system-ui, sans-serif;
|
||||
--gutter: clamp(16px, 4vw, 48px);
|
||||
--wide: 1200px;
|
||||
--radius: 16px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--white);
|
||||
color: var(--ink);
|
||||
font: 400 17px/1.55 var(--text);
|
||||
font-kerning: normal;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
img { display: block; max-width: 100%; height: auto; }
|
||||
picture { display: contents; }
|
||||
a { color: var(--ink); text-decoration-thickness: 1.5px; text-underline-offset: 3px; }
|
||||
a:hover { text-decoration-thickness: 3px; }
|
||||
:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; border-radius: 4px; }
|
||||
h1, h2, h3, h4 { font-family: var(--cond); font-weight: 700; line-height: 1.02; letter-spacing: 0.005em; margin: 0 0 0.45em; }
|
||||
h2 { font-size: clamp(2.2rem, 5vw, 3.2rem); }
|
||||
h3 { font-size: 1.5rem; }
|
||||
p { margin: 0 0 0.9em; }
|
||||
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
.wrap { max-width: calc(var(--wide) + 2 * var(--gutter)); margin: 0 auto; padding-left: var(--gutter); padding-right: var(--gutter); }
|
||||
|
||||
/* ------------------------------------------------------------------ the logo, firing */
|
||||
.hero { background: var(--ink); color: var(--white); }
|
||||
.hero a { color: var(--white); text-decoration-color: var(--volt); }
|
||||
.hero :focus-visible { outline-color: var(--volt); }
|
||||
.crumbs { display: flex; align-items: baseline; gap: 10px; padding-top: 18px; padding-bottom: 4px; font: 700 1.35rem/1 var(--cond); letter-spacing: 0.04em; }
|
||||
.crumbs a { text-decoration: none; }
|
||||
.crumbs a:hover { text-decoration: underline; text-decoration-thickness: 2px; }
|
||||
.crumbs span { color: rgba(255, 255, 255, 0.5); }
|
||||
.crumbs [aria-current] { color: rgba(255, 255, 255, 0.78); }
|
||||
.reel { max-width: var(--wide); margin: 0 auto; }
|
||||
.reel img { width: 100%; }
|
||||
.intro { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 18px clamp(24px, 5vw, 64px); align-items: start; padding-bottom: clamp(48px, 8vw, 96px); }
|
||||
.intro h1 { font-size: clamp(2.7rem, 6.2vw, 4.6rem); line-height: 0.95; margin: 0; }
|
||||
.intro p { color: rgba(255, 255, 255, 0.86); font-size: 1.12rem; max-width: 33em; }
|
||||
.jump { display: flex; flex-wrap: wrap; gap: 10px 26px; margin: 18px 0 0; }
|
||||
.jump a { font-weight: 700; text-decoration-thickness: 2px; }
|
||||
|
||||
/* ------------------------------------------------------------------ sections */
|
||||
.part { padding-top: clamp(64px, 10vw, 120px); }
|
||||
.lede { font-size: 1.1rem; color: var(--ink-2); max-width: 40em; margin-bottom: 28px; }
|
||||
.note { color: var(--ink-2); max-width: 46em; margin-top: 20px; }
|
||||
|
||||
/* tiles: each piece on the ground it was drawn for */
|
||||
.tiles { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
||||
.tile { margin: 0; min-width: 0; }
|
||||
.ground {
|
||||
display: grid; place-items: center; min-height: 200px; overflow: hidden;
|
||||
padding: clamp(18px, 6%, 40px); border-radius: var(--radius);
|
||||
}
|
||||
.ground img { width: auto; max-height: 170px; }
|
||||
.ground.icon { min-height: 270px; }
|
||||
.ground.icon img { max-height: 210px; }
|
||||
.ground.sticker { min-height: 300px; }
|
||||
.ground.sticker img { max-height: 250px; }
|
||||
.tiles + .tiles, .tiles + .story { margin-top: 16px; }
|
||||
.ground.big { min-height: clamp(210px, 32vw, 400px); }
|
||||
.ground.big img { width: min(84%, 860px); max-height: none; }
|
||||
.ground.bleed { padding: 0; min-height: 0; }
|
||||
.ground.bleed img { width: 100%; max-height: none; }
|
||||
.g-ink { background: var(--ink); }
|
||||
.g-bone { background: var(--bone); }
|
||||
.g-white { background: var(--white); box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.g-volt { background: var(--volt); }
|
||||
.g-red { background: var(--red); }
|
||||
.g-peri { background: var(--peri); }
|
||||
.g-cobalt { background: var(--cobalt); }
|
||||
.g-concrete { background: var(--concrete); }
|
||||
figcaption { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: 0.9rem; color: var(--ink-2); }
|
||||
|
||||
/* the volt stone marks what was picked */
|
||||
.stone { display: inline-block; flex: none; width: 0.78em; height: 0.78em; border-radius: 50%; background: var(--volt); box-shadow: 0 0 0 2px var(--ink); vertical-align: -0.04em; margin: 0 0.5em 0 2px; }
|
||||
figcaption .stone, .picked .stone { margin: 0 0 0 2px; }
|
||||
|
||||
.pair { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; }
|
||||
|
||||
/* the logo */
|
||||
.anatomy { display: grid; grid-template-columns: minmax(0, 1fr); gap: 28px; margin-top: 40px; align-items: start; }
|
||||
.closeup { padding: clamp(12px, 4%, 32px); }
|
||||
.closeup img { width: 100%; max-width: 460px; max-height: none; }
|
||||
.anatomy dl { display: grid; grid-template-columns: 6.5em minmax(0, 1fr); gap: 14px 20px; margin: 0; }
|
||||
.anatomy dt { font-weight: 700; }
|
||||
.anatomy dd { margin: 0; color: var(--ink-2); }
|
||||
.donts { margin: 36px 0 0; padding-left: 1.15em; max-width: 46em; }
|
||||
.donts li { margin-bottom: 0.45em; }
|
||||
|
||||
/* the icon */
|
||||
.ladder { list-style: none; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 18px clamp(16px, 3vw, 36px); margin: 32px 0 0; padding: 0; }
|
||||
.ladder li { display: flex; flex-direction: column; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--ink-2); font-variant-numeric: tabular-nums; }
|
||||
.ladder .px180 { width: 180px; }
|
||||
.ladder .px120 { width: 120px; }
|
||||
.ladder .px64 { width: 64px; }
|
||||
.ladder .px44 { width: 44px; }
|
||||
.ladder .px32 { width: 32px; }
|
||||
.ladder .px20 { width: 20px; }
|
||||
|
||||
/* files */
|
||||
.files { list-style: none; margin: 0; padding: 0; max-width: 780px; border-top: 2px solid var(--ink); }
|
||||
.files li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 2px 18px; padding: 13px 0; border-bottom: 1px solid var(--line); }
|
||||
.files a { font-weight: 700; }
|
||||
.files span { color: var(--ink-2); font-size: 0.95rem; }
|
||||
|
||||
/* colour: widths follow how much of each the brand uses */
|
||||
.swatches { list-style: none; margin: 0; padding: 0; display: flex; min-height: clamp(200px, 26vw, 300px); border-radius: var(--radius); overflow: hidden; box-shadow: inset 0 0 0 1px var(--line); }
|
||||
.sw { display: flex; flex-direction: column; justify-content: flex-end; gap: 1px; min-width: 0; padding: 16px; font-size: 0.92rem; font-variant-numeric: tabular-nums; }
|
||||
.sw b { font: 700 1.7rem/1 var(--cond); margin-bottom: 8px; }
|
||||
.sw-ink { flex: 5; background: var(--ink); color: var(--white); }
|
||||
.sw-bone { flex: 3; background: var(--bone); }
|
||||
.sw-white { flex: 2; background: var(--white); border-left: 1px solid var(--line); }
|
||||
.sw-volt { flex: 1.35; background: var(--volt); }
|
||||
|
||||
/* type */
|
||||
.type-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 28px; align-items: start; }
|
||||
.spec { padding: clamp(18px, 5%, 40px); }
|
||||
.spec img { width: 100%; max-height: none; }
|
||||
.type-grid h3 + p { color: var(--ink-2); max-width: 34em; }
|
||||
.type-grid h3:not(:first-child) { margin-top: 1.3em; }
|
||||
.type-grid .sample-cond { font: 700 clamp(2.4rem, 5vw, 3.4rem)/1 var(--cond); margin: 0.2em 0 0.35em; color: var(--ink); }
|
||||
.sample-text { font-size: 1.15rem; max-width: 30em; }
|
||||
|
||||
/* motion */
|
||||
.beats { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: 22px 28px; margin: 32px 0 0; padding: 0; }
|
||||
.beats li { border-top: 2px solid var(--ink); padding-top: 10px; color: var(--ink-2); }
|
||||
.beats b { display: block; font: 700 1.35rem/1.1 var(--cond); color: var(--ink); }
|
||||
.beats span { display: block; font-size: 0.85rem; margin: 2px 0 6px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* how it got here */
|
||||
.drafts .lede .stone { margin-right: 0.35em; }
|
||||
.round { margin-top: clamp(56px, 9vw, 104px); }
|
||||
.round-head { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0 clamp(16px, 3vw, 36px); align-items: start; margin-bottom: 26px; }
|
||||
.round-head .num { font: 700 clamp(4.2rem, 10vw, 7.5rem)/0.78 var(--cond); margin: 0; min-width: 0.55em; color: var(--ink); }
|
||||
.round-head h3 { font-size: clamp(1.8rem, 3.6vw, 2.5rem); margin-bottom: 0.3em; }
|
||||
.round-head p { color: var(--ink-2); max-width: 44em; }
|
||||
.after { margin: 24px 0 0; padding-top: 14px; border-top: 2px solid var(--ink); font-weight: 700; }
|
||||
.after span { display: block; max-width: 48em; }
|
||||
.concept { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; padding: 28px 0; border-top: 1px solid var(--line); }
|
||||
.concept-meta h4 { display: flex; align-items: center; gap: 10px; font-size: 1.75rem; margin-bottom: 6px; }
|
||||
.cn { display: inline-grid; place-items: center; flex: none; width: 1.45em; height: 1.45em; border-radius: 50%; background: var(--bone); font-size: 0.72em; line-height: 1; }
|
||||
.cn.chosen { background: var(--volt); box-shadow: 0 0 0 2px var(--ink); }
|
||||
.face { color: var(--ink-2); font-size: 0.92rem; margin-bottom: 10px; }
|
||||
.picked { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
.story { list-style: none; display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 140px), 1fr)); gap: 12px; margin: 16px 0 0; padding: 0; }
|
||||
.story .ground { min-height: 0; padding: 10px; }
|
||||
.story .ground img { width: 100%; max-height: none; }
|
||||
.story span { display: block; margin-top: 6px; font-size: 0.88rem; color: var(--ink-2); }
|
||||
|
||||
.foot { margin-top: clamp(72px, 11vw, 132px); padding: 28px 0 48px; border-top: 1px solid var(--line); color: var(--ink-2); font-size: 0.9rem; }
|
||||
.foot p { max-width: 62em; }
|
||||
|
||||
/* ------------------------------------------------------------------ wider screens */
|
||||
@media (min-width: 720px) {
|
||||
.tiles.two, .tiles.three, .pair.side { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.tiles.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.anatomy { grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 48px; }
|
||||
.type-grid { grid-template-columns: minmax(0, 6fr) minmax(0, 5fr); gap: 48px; }
|
||||
.concept { grid-template-columns: minmax(0, 3fr) minmax(0, 8fr); gap: 16px 40px; }
|
||||
.concept-meta { position: sticky; top: 20px; align-self: start; }
|
||||
}
|
||||
@media (max-width: 859px) {
|
||||
.intro { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
body { font-size: 16px; }
|
||||
.swatches { flex-direction: column; min-height: 460px; }
|
||||
.sw-white { border-left: 0; border-top: 1px solid var(--line); }
|
||||
.anatomy dl { grid-template-columns: minmax(0, 1fr); gap: 2px; }
|
||||
.anatomy dd { margin-bottom: 12px; }
|
||||
.ground { min-height: 150px; }
|
||||
.ground.icon { min-height: 220px; }
|
||||
.ground img { max-height: 140px; }
|
||||
.ground.sticker { min-height: 240px; }
|
||||
.ground.sticker img { max-height: 200px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
375b9ff97f67cfd0f730a9ff6cd45db73de1a8533f06e226d4e75d6c6b81018f c1-inv.svg
|
||||
533cb0e38a0d00d8b95dfef68c91116e4b86bd62f044e28c7c11f5cf47b7ea0b c1-main.svg
|
||||
2357ea08591eece2db824316fea90fe2c3a3ac87950a7b5e25ee4afbdb69bac6 c2-inv.svg
|
||||
375dc7a3b225123e1ddd552e0174e1a61077d1d8c0c0cee2a4e10c9f97e35c49 c2-main.svg
|
||||
a2ba90addf36dcd16ee72af21db6f41a0a75dc79df8f65d36688938a30bb686a c3-inv.svg
|
||||
32fd4570301e82675c9447446f8ec6c48373dbf7987f5144cb5769393d27b898 c3-main.svg
|
||||
a775df7b4a67437c2f5d593b2849445bd3f4faa54c7379b643a64a1d4e871406 c4-app.svg
|
||||
a20d7ff572251408492b4c851f06bd53c9fe86ee090ca9da6819d9eaa375b9ce c4-ing.svg
|
||||
14ca886f12e751bd6996b1af23f58f9f022370ef21d7fb979a02a53f38bf84aa c4-inv.svg
|
||||
5a7e4f1d8156a92ba9a123a5e057a96c3d9d280166291a6d30871f0f1f35fe5f c4-money.svg
|
||||
d46b7cf0ff6cf5ff221bd6efcb37d2c2571ea1cecfc6035732ad3005ae245804 c5-ink.svg
|
||||
f48d6561d8b005d8461b9e3bacc3f1894f6d34567f0b51d1d056c45be991e5d1 c5-lockup.svg
|
||||
6dbec40d319d733f8c139fc7a4059a225957c735c4f7b75e0619f730792f8fc6 c5-red.svg
|
||||
2c651002550b4191ea8d3317b4e1447f91dea6ba27541cf76c58bfb58c2de5b0 c6-green.svg
|
||||
295a70d1ccdf86d01864d328bdf9afa8b52717d7960414e9dec3b4b9f74b9d57 c6-orange.svg
|
||||
ce3b80a51efd141dcb38ac0168679badbc324775274782f518e734b1273f5496 c6-yellow.svg
|
||||
abce18eb0eb22edc9bb6191cda7426de195445b67b278c11d13fb30876a3671c c7-clean.svg
|
||||
fed0581f8b0612fec7370f5b1493cab5ee84ce39aef46bee5375a74069b8c0f9 c7-construct.svg
|
||||
ba5a051515e88d6d014d5ba4902bad95ea72f8181216358151511cbaf61bd425 c8-inv.svg
|
||||
2a787c1afe0e8f87ec9b57a077786221abe9a2d8dbb81ab9f3b437079ef20174 c8-main.svg
|
||||
65d1567b9929d2d5fb25c4a7a7543510cc2f88f09d7bcdcb7e1ce7a230919052 dir-icon-volt.svg
|
||||
6464fff3cb94d371ffbda8e3ada9ed5407345d1ce9d487dd8740ec7e1902909e dir-icon-zap.svg
|
||||
d71f612c9e8d23414992221d6d79f8ac8cb71538134a364e56dfe4bbfa2b516a dir-lockup-lexend-dark.svg
|
||||
ce61bf5bf614b4da3adbd97d6e57803c689742b807e9edf1893fa0683150fe8b dir-lockup-lexend-light.svg
|
||||
eb1dbe4cf184b16c61891e82b46be9b72e905d6817909e0b3928963f9f26785b dir-lockup-unbounded-dark.svg
|
||||
a5b78d2d1523818fcaa88bb285b951e9c62adb123eb73edb44070c78f3b69b52 dir-lockup-unbounded-light.svg
|
||||
056fd3864b82d4b91d15a1b92062fea3fedd28342d78d1721ae36593120fef3b icon-pull-release.webp
|
||||
224d9a5b1e7f244d882d3871d4b6f133905ff2952021cb6bfe3facb84fbd74dd og.png
|
||||
ef1f60bd1d2f374a7fd17469dda2de0a917936271753bcdde1f9e639b6267a5f story-1-loaded.svg
|
||||
47be3d895db2300baef7d4e73b5b40aaa117c6895dea7281f12f2dcfec33d267 story-2-drawn.svg
|
||||
2855c26191e6289dfe631db3aead3d33f0eee8b4988db93259b6d54daf387b8d story-3-release.svg
|
||||
41e8c12db4cb0178d114f9d108595768f267ebbcb44f61a060bf34406a90f091 story-4-empty.svg
|
||||
24d3f55ee46aa76595200bfd9117fa0be180bec137a4eb4f681f89976170f399 story-5-reload.svg
|
||||
520549aed1196b72fbea90fc74e30cca798c7186863734b64478080fc0b66192 type-unbounded.svg
|
||||
ac35badc695f01f0c1b268962016c72522acb6ee78f67d54139c38bbd6004118 wm-anim-unbounded-dark.webp
|
||||
3712d2b4b5cda0ef7fa5ef4fd8306f4edf1debb93bc3747a2e3d70b99b66e1d7 wm-anim-unbounded-light.webp
|
||||
bec247efaae1329850ab07f2850838154f13c0cc32f67d417a74a5ddeae2af77 wm-final-anim-dark.webp
|
||||
eaf5a2c687d7e555eb9c24201e2f479f8bb8865d8c7574283b696a2a3179d7cf wm-final-anim-light.webp
|
||||
9f9474567461cd8c7fd4c212bab343dc2ebae09239bbf23d474b521a5a8c1201 wm-final-font-dark.svg
|
||||
68546d62e2df754b4b72665a23155633956d97fbb135c0a61af6c4714d242eb4 wm-final-font-light.svg
|
||||
85e60e27b6f3c908d108449cbf2b20174d990987da4edb93c3a6dc62326f79db wm-final-round-dark.svg
|
||||
52ea09a69b83a75827bc836fb79406171a25b2bf33906d12390cc619752cda15 wm-final-round-light.svg
|
||||
1529a2c5c8f65c0d91b0ffe477fc5d42781cbc3136f101cf111ebda358d61480 wm-final-still-dark.svg
|
||||
2ceb12976ced487716e1df7e940451289f1c37821d299e58eca2137df0f87704 wm-final-still-light.svg
|
||||
6fa84e1052ac282a3f275454556555134aca22156a5122258034b3902398f307 wm-font-anim-font-dark.webp
|
||||
21c6b2a444a9efd90c022c0c15fbd3ae52a1ce4525c50c0c13970a3ec837bede wm-font-anim-font-light.webp
|
||||
489a72cb02ecb1ae7e421dde1ad6580e99a40ddfbf7b63d3aaddd8f0133cf23c wm-icony-lexend-dark.svg
|
||||
8626e95a823acdb844cb13dcafa0f82b3d0d0e2648fd8c77519a9c8b7c4b682d wm-icony-lexend-light.svg
|
||||
8095a55a3be774d05f6706ad7dd2f00f544ef73f5b7e99ab1686bd72709bc7e3 wm-icony-unbounded-dark.svg
|
||||
b5f1258c5ab884b4d71469294c34c7a27153c9e9437d9814664cfbee36e3e51b wm-icony-unbounded-light.svg
|
||||
6eacf221320c8b56634f40c8780c46f8300cf382e60edaeef49e8638f14f9f5c wm-logo-dark.svg
|
||||
88251ff9a4982926df41035bcba167014e030b78be8ed9e2f4f0061627445fd6 wm-logo-light.svg
|
||||
16b3b7a7a7e92e07453beb769a0fece36829fc2bf1147319e591e7ed688b8f76 wm-logo-y.svg
|
||||
962872f7140763d80a45f602f0e97b984ef964e5a1450dee8b0266b2e149ee2a wm-sling-anim-unbounded-dark.webp
|
||||
dc6575e476aa6785a8e6d501b7b7b7322ad5974492b7a4897d64e58c98455e03 wm-sling-anim-unbounded-light-volt.webp
|
||||
8bb26316d0d2378995617baa79f69ae15b5fe3841976a129578e0dc8ac7bf17c wm-sling-lexend-dark.svg
|
||||
c1b54354122d3ddd06bfbc3b6117b080e3f7b626407c96890f4178d1f21d1446 wm-sling-lexend-light-volt.svg
|
||||
3b8537dde3533cd26b80b3961bffa7791087da776ea20efcbe4a11a918188008 wm-sling-unbounded-dark.svg
|
||||
95ffa0b2165881d2ebb23ac8d6f152c445585700fa2cbed8bccc39173e6e851a wm-sling-unbounded-light-volt.svg
|
||||
@@ -0,0 +1,367 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; img-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'">
|
||||
<meta name="referrer" content="strict-origin">
|
||||
<title>SLYNG brand</title>
|
||||
<meta name="description" content="The SLYNG logo, where the Y is a slingshot: the files, how to use them, its colours, type and motion, and every draft it took to get there.">
|
||||
<link rel="canonical" href="https://slyng.yolopreneur.lol/branding/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="SLYNG brand: the Y is a slingshot">
|
||||
<meta property="og:description" content="The logo, its files, colours, type and motion, and every draft it took to get there.">
|
||||
<meta property="og:url" content="https://slyng.yolopreneur.lol/branding/">
|
||||
<meta property="og:image" content="https://slyng.yolopreneur.lol/branding/img/og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="theme-color" content="#111014">
|
||||
<link rel="icon" href="img/dir-icon-zap.svg" type="image/svg+xml">
|
||||
<link rel="preload" href="../fonts/slyng-condensed-bold.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="preload" href="../fonts/slyng-text-regular.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="stylesheet" href="brand.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="hero">
|
||||
<nav class="crumbs wrap" aria-label="Breadcrumb"><a href="../">SLYNG</a><span aria-hidden="true">/</span><span aria-current="page">Brand</span></nav>
|
||||
<div class="reel"><picture><source srcset="img/wm-final-still-dark.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-final-anim-dark.webp" alt="The SLYNG logo in motion: the band snaps straight and fires the stone out of the top, rings out, catches a new stone and draws it back down onto the vertex of the Y" width="1200" height="400"></picture></div>
|
||||
<div class="intro wrap">
|
||||
<h1>The Y is a slingshot.</h1>
|
||||
<div>
|
||||
<p>The SLYNG logo is the name in Unbounded ExtraBold with the Y doing the slinging: a volt stone on the vertex, and the band drawn up the inside of the arms to the tips. Here are the files, how to use them, and every draft it took to get here.</p>
|
||||
<p class="jump"><a href="#files">Get the files</a><a href="#drafts">See every draft</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="part" id="logo" aria-labelledby="logo-h">
|
||||
<div class="wrap">
|
||||
<h2 id="logo-h">The logo</h2>
|
||||
<p class="lede">Two versions, one drawing. White letters on ink, or ink letters on bone or white. On a light ground the bands and the stone get an ink outline, because volt alone disappears against it.</p>
|
||||
<div class="pair">
|
||||
<figure class="tile"><div class="ground g-ink big"><img src="img/wm-logo-dark.svg" alt="The SLYNG logo in white on ink" width="1200" height="225" loading="lazy" decoding="async"></div>
|
||||
<figcaption>For ink and other dark grounds</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone big"><img src="img/wm-logo-light.svg" alt="The SLYNG logo in ink on bone" width="1200" height="225" loading="lazy" decoding="async"></div>
|
||||
<figcaption>For bone, white and other light grounds</figcaption></figure>
|
||||
</div>
|
||||
<div class="anatomy">
|
||||
<figure class="tile"><div class="ground g-ink closeup"><img src="img/wm-logo-y.svg" alt="Close-up of the Y: the stone on the vertex, the bands inside the arms, cut flush with the tips" width="1200" height="989" loading="lazy" decoding="async"></div>
|
||||
</figure>
|
||||
<dl>
|
||||
<dt>The Y</dt><dd>Unbounded's own Y, untouched. It is the slingshot.</dd>
|
||||
<dt>The stone</dt><dd>A volt circle centred on the vertex of the fork, 16.4% of the letter height across.</dd>
|
||||
<dt>The bands</dt><dd>5.6% of the letter height wide, parallel to the inside edges of the arms, from the stone to the tips, cut flush with the tops of the letters.</dd>
|
||||
<dt>The gap</dt><dd>A hairline, 2.2% of the letter height, between each band and its arm and around the stone: the ground itself on ink, an ink outline on light grounds.</dd>
|
||||
<dt>Spacing</dt><dd>The letters keep Unbounded's own widths, with L and Y set 40 units (of 1,000) closer. Keep clear space of at least half the letter height on every side.</dd>
|
||||
<dt>Size</dt><dd>At least 200 pixels wide on screens and 35 mm in print. Smaller, and the hairline gap fills in: use the icon.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<ul class="donts">
|
||||
<li>Use the files. Don't retype the name, even in Unbounded: the bands and the stone aren't in the font.</li>
|
||||
<li>Keep the band and the stone volt. Don't change the colours, add effects, or stretch it.</li>
|
||||
<li>White letters go on ink; ink letters go on bone or white. Not on photos or busy grounds.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="part" id="icon" aria-labelledby="icon-h">
|
||||
<div class="wrap">
|
||||
<h2 id="icon-h">The icon</h2>
|
||||
<p class="lede">Where the name won't fit: an app icon, a profile picture, a favicon. It is concept 5's slingshot in the logo's colours, white on ink with the band and stone in volt, or reversed, ink on volt.</p>
|
||||
<div class="tiles three">
|
||||
<figure class="tile"><div class="ground g-bone icon"><img src="img/dir-icon-zap.svg" alt="The SLYNG app icon: a white slingshot with a volt band and stone on ink" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<figcaption>The icon</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink icon"><img src="img/dir-icon-volt.svg" alt="The SLYNG app icon reversed: an ink slingshot on volt" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Reversed</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone icon"><picture><source srcset="img/dir-icon-zap.svg" media="(prefers-reduced-motion: reduce)"><img src="img/icon-pull-release.webp" alt="The SLYNG icon in motion: the band pulls back, lets the stone fly, rings out, and a new stone drops in" width="1200" height="1200" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>In motion</figcaption></figure>
|
||||
</div>
|
||||
<ul class="ladder" aria-label="The icon from 180 pixels down to 20"><li><img src="img/dir-icon-zap.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async" class="px180"><span>180 px</span></li>
|
||||
<li><img src="img/dir-icon-zap.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async" class="px120"><span>120 px</span></li>
|
||||
<li><img src="img/dir-icon-zap.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async" class="px64"><span>64 px</span></li>
|
||||
<li><img src="img/dir-icon-zap.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async" class="px44"><span>44 px</span></li>
|
||||
<li><img src="img/dir-icon-zap.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async" class="px32"><span>32 px</span></li>
|
||||
<li><img src="img/dir-icon-zap.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async" class="px20"><span>20 px</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="part" id="files" aria-labelledby="files-h">
|
||||
<div class="wrap">
|
||||
<h2 id="files-h">Files</h2>
|
||||
<ul class="files">
|
||||
<li><a href="img/wm-logo-dark.svg" download="slyng-logo-on-dark.svg">Logo for dark grounds</a><span>SVG, white letters, transparent ground</span></li>
|
||||
<li><a href="img/wm-logo-light.svg" download="slyng-logo-on-light.svg">Logo for light grounds</a><span>SVG, ink letters, transparent ground</span></li>
|
||||
<li><a href="img/dir-icon-zap.svg" download="slyng-icon.svg">App icon</a><span>SVG, 1024 square</span></li>
|
||||
<li><a href="img/dir-icon-volt.svg" download="slyng-icon-reversed.svg">App icon, reversed</a><span>SVG, 1024 square</span></li>
|
||||
<li><a href="img/wm-final-anim-dark.webp" download="slyng-logo-motion-on-ink.webp">Logo in motion, on ink</a><span>Animated WebP, 1800 by 600, 3-second loop</span></li>
|
||||
<li><a href="img/wm-final-anim-light.webp" download="slyng-logo-motion-on-bone.webp">Logo in motion, on bone</a><span>Animated WebP, 1800 by 600, 3-second loop</span></li>
|
||||
<li><a href="img/icon-pull-release.webp" download="slyng-icon-motion.webp">Icon in motion</a><span>Animated WebP, 512 square, transparent corners</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="part" id="colour" aria-labelledby="colour-h">
|
||||
<div class="wrap">
|
||||
<h2 id="colour-h">Colour</h2>
|
||||
<p class="lede">Four colours, used about in these proportions. Volt is only ever the sling: the band, the stone, and the ground of the reversed icon.</p>
|
||||
<ul class="swatches">
|
||||
<li class="sw sw-ink"><b>Ink</b><span>#111014</span><span>RGB 17 16 20</span></li>
|
||||
<li class="sw sw-bone"><b>Bone</b><span>#F1F0EC</span><span>RGB 241 240 236</span></li>
|
||||
<li class="sw sw-white"><b>White</b><span>#FFFFFF</span><span>RGB 255 255 255</span></li>
|
||||
<li class="sw sw-volt"><b>Volt</b><span>#D9FF3D</span><span>RGB 217 255 61</span></li>
|
||||
</ul>
|
||||
<p class="note">White on ink reads at 19:1, and volt on ink at 16.5:1. Volt on white or bone is barely there (1.2:1 and 1.0:1), which is why the light logo outlines it in ink.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="part" id="type" aria-labelledby="type-h">
|
||||
<div class="wrap">
|
||||
<h2 id="type-h">Type</h2>
|
||||
<div class="type-grid">
|
||||
<figure class="tile"><div class="ground g-bone spec"><img src="img/type-unbounded.svg" alt="Unbounded ExtraBold, A to Z and 0 to 9" width="1200" height="292" loading="lazy" decoding="async"></div>
|
||||
</figure>
|
||||
<div>
|
||||
<h3>Unbounded ExtraBold</h3>
|
||||
<p>The logo is drawn from Unbounded at weight 800, a wide, rounded sans by NaN, free from Google Fonts under the SIL Open Font License. Its Y has straight arms that meet at a sharp vertex, which is what lets the bands run along them and the stone sit on the point.</p>
|
||||
<h3>Slyng Condensed and Slyng Text</h3>
|
||||
<p class="sample-cond">Pull back for more</p>
|
||||
<p class="sample-text">The site sets its headings in Slyng Condensed and everything you read in Slyng Text, cut from TeX Gyre Heros Condensed and TeX Gyre Adventor.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="part" id="motion" aria-labelledby="motion-h">
|
||||
<div class="wrap">
|
||||
<h2 id="motion-h">Motion</h2>
|
||||
<p class="lede">When the logo moves, it fires. A three-second loop that starts and ends in the logo pose, so it can stop after any loop and leave the logo standing.</p>
|
||||
<div class="pair side">
|
||||
<figure class="tile"><div class="ground g-ink bleed"><picture><source srcset="img/wm-final-still-dark.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-final-anim-dark.webp" alt="The SLYNG logo in motion, on ink" width="1200" height="400" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>On ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone bleed"><picture><source srcset="img/wm-final-still-light.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-final-anim-light.webp" alt="The SLYNG logo in motion, on bone" width="1200" height="400" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>On bone</figcaption></figure>
|
||||
</div>
|
||||
<ol class="beats">
|
||||
<li><b>Hold</b><span>0 to 0.5 s</span>The logo, still. In the last moments the stone trembles.</li>
|
||||
<li><b>Release</b><span>0.5 to 0.6 s</span>The band snaps up, straight across the tips.</li>
|
||||
<li><b>Flight</b><span>0.6 to 0.7 s</span>The stone flies out of the top, getting smaller.</li>
|
||||
<li><b>Ring out</b><span>0.6 to 1.8 s</span>The empty band rings around straight and settles.</li>
|
||||
<li><b>Reload</b><span>1.8 to 2.3 s</span>A new stone drops in, and the band catches it in a shallow V.</li>
|
||||
<li><b>Draw back</b><span>2.3 to 2.8 s</span>The pouch slides down the arms until the stone sits on the vertex.</li>
|
||||
<li><b>Hold</b><span>2.8 to 3 s</span>And it loops.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="part drafts" id="drafts" aria-labelledby="drafts-h">
|
||||
<div class="wrap">
|
||||
<h2 id="drafts-h">How it got here</h2>
|
||||
<p class="lede">Every draft, in the order it was made, over six rounds. Each round ends with what changed next; the <span class="stone" aria-hidden="true"></span>stone marks what was picked.</p>
|
||||
<section class="round" id="round-1" aria-labelledby="round-1-h"><header class="round-head"><p class="num" aria-hidden="true">1</p>
|
||||
<div><h3 id="round-1-h"><span class="sr">Round 1: </span>Eight ways in</h3><p>The brief was the name with its Y as a slingshot. Eight concepts, each with its own typeface and colours.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="concept" id="concept-1"><div class="concept-meta"><h4><span class="cn"><span class="sr">Concept </span>1</span>Loaded</h4><p class="face">Lexend ExtraBold</p>
|
||||
<p>The Y is a slingshot with a red band, and the stone sits in the pouch, ready.</p>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-bone"><img src="img/c1-main.svg" alt="SLYNG wordmark: the Y is a slingshot loaded with a stone" width="1200" height="260" loading="lazy" decoding="async"></div>
|
||||
<figcaption>On bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/c1-inv.svg" alt="SLYNG wordmark, Loaded, reversed on ink" width="1200" height="260" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Reversed</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
<div class="concept" id="concept-2"><div class="concept-meta"><h4><span class="cn"><span class="sr">Concept </span>2</span>Full draw</h4><p class="face">Archivo, black and extra wide</p>
|
||||
<p>The band is pulled all the way back, below the word, about to let go.</p>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-red"><img src="img/c2-main.svg" alt="SLYNG wordmark: the slingshot Y with its band pulled all the way back below the word" width="1200" height="314" loading="lazy" decoding="async"></div>
|
||||
<figcaption>On vermilion</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/c2-inv.svg" alt="SLYNG wordmark reversed on ink with a vermilion band" width="1200" height="314" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Reversed</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
<div class="concept" id="concept-3"><div class="concept-meta"><h4><span class="cn"><span class="sr">Concept </span>3</span>Pocket</h4><p class="face">Fredoka SemiBold, lowercase</p>
|
||||
<p>A pocket slingshot: the y's descender becomes the handle.</p>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-peri"><img src="img/c3-main.svg" alt="Lowercase slyng wordmark: the y is a pocket slingshot whose descender is the handle" width="1200" height="490" loading="lazy" decoding="async"></div>
|
||||
<figcaption>On periwinkle</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/c3-inv.svg" alt="slyng wordmark in periwinkle on ink" width="1200" height="490" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Reversed</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
<div class="concept" id="concept-4"><div class="concept-meta"><h4><span class="cn"><span class="sr">Concept </span>4</span>Landing dot</h4><p class="face">Sora ExtraBold</p>
|
||||
<p>The stone has flown: it lands as the dot in the three front doors, slyng.app, slyng.money and slyng.ing.</p>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-white"><img src="img/c4-app.svg" alt="SLYNG.app lockup: the stone flies from the slingshot Y and lands as the domain dot" width="1200" height="309" loading="lazy" decoding="async"></div>
|
||||
<figcaption>.app</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-white"><img src="img/c4-money.svg" alt="SLYNG.money lockup" width="1200" height="248" loading="lazy" decoding="async"></div>
|
||||
<figcaption>.money</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-white"><img src="img/c4-ing.svg" alt="SLYNG.ing lockup" width="1200" height="331" loading="lazy" decoding="async"></div>
|
||||
<figcaption>.ing</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/c4-inv.svg" alt="SLYNG.app lockup reversed on ink" width="1200" height="309" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Reversed</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
<div class="concept" id="concept-5"><div class="concept-meta"><h4><span class="cn chosen"><span class="sr">Concept </span>5</span>App icon</h4><p class="face">The slingshot on its own</p>
|
||||
<p>The slingshot alone in a rounded square, made to be an app icon, and locked up with Loaded.</p>
|
||||
<p class="picked"><span class="stone" aria-hidden="true"></span>Picked: this icon</p>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-bone icon"><img src="img/c5-ink.svg" alt="SLYNG app icon: a white slingshot with a vermilion band and stone on ink" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone icon"><img src="img/c5-red.svg" alt="SLYNG app icon: an ink slingshot with a white band on vermilion" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Vermilion</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-white"><img src="img/c5-lockup.svg" alt="SLYNG app icon locked up with the Loaded wordmark" width="1200" height="244" loading="lazy" decoding="async"></div>
|
||||
<figcaption>With the wordmark</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
<div class="concept" id="concept-6"><div class="concept-meta"><h4><span class="cn"><span class="sr">Concept </span>6</span>Price sticker</h4><p class="face">Big Shoulders Display Black</p>
|
||||
<p>A flea-market price sticker for the market, with a price tag loaded in the Y and Local goods and Digital goods around the edge.</p>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-concrete sticker"><img src="img/c6-yellow.svg" alt="Yellow flea-market price sticker: SLYNG with a price tag loaded in the slingshot Y, ringed by Local goods and Digital goods" width="1200" height="1191" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Yellow</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-concrete sticker"><img src="img/c6-orange.svg" alt="Orange version of the price sticker" width="1200" height="1191" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Orange</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-concrete sticker"><img src="img/c6-green.svg" alt="Green version of the price sticker" width="1200" height="1191" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Green</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
<div class="concept" id="concept-7"><div class="concept-meta"><h4><span class="cn"><span class="sr">Concept </span>7</span>Monoline</h4><p class="face">Drawn from circles and lines</p>
|
||||
<p>Every letter built from circles and straight lines of one weight, shown with its construction.</p>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-cobalt"><img src="img/c7-construct.svg" alt="Monoline SLYNG wordmark drawn from circles and straight lines, with its construction guides showing" width="1200" height="372" loading="lazy" decoding="async"></div>
|
||||
<figcaption>With its construction</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/c7-clean.svg" alt="Monoline SLYNG wordmark on ink, without guides" width="1200" height="338" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Clean</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
<div class="concept" id="concept-8"><div class="concept-meta"><h4><span class="cn chosen"><span class="sr">Concept </span>8</span>Zap</h4><p class="face">Unbounded ExtraBold</p>
|
||||
<p>A lightning bolt is loaded in the Y: sats travel as zaps. Ink and volt.</p>
|
||||
<p class="picked"><span class="stone" aria-hidden="true"></span>Picked: these colours</p>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-ink"><img src="img/c8-main.svg" alt="SLYNG wordmark: the slingshot Y is loaded with a lightning bolt" width="1200" height="229" loading="lazy" decoding="async"></div>
|
||||
<figcaption>On ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-volt"><img src="img/c8-inv.svg" alt="SLYNG wordmark in ink on volt" width="1200" height="229" loading="lazy" decoding="async"></div>
|
||||
<figcaption>On volt</figcaption></figure>
|
||||
</div>
|
||||
</div>
|
||||
<p class="after"><span>Next: concept 5, the app icon, in the colours of concept 8.</span></p>
|
||||
</section>
|
||||
<section class="round" id="round-2" aria-labelledby="round-2-h"><header class="round-head"><p class="num" aria-hidden="true">2</p>
|
||||
<div><h3 id="round-2-h"><span class="sr">Round 2: </span>The icon in Zap's colours</h3><p>Concept 5's slingshot in white on ink with the band and stone in volt, and reversed in ink on volt. Lockups with Lexend and Unbounded; on ink, the bare slingshot stands in front of the word. And the first motion: pull back, let go, ring out, reload.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tiles three"><figure class="tile"><div class="ground g-bone icon"><img src="img/dir-icon-zap.svg" alt="SLYNG app icon: a white slingshot with a volt band and stone on ink" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<figcaption><span class="stone" aria-hidden="true"></span>The icon</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink icon"><img src="img/dir-icon-volt.svg" alt="SLYNG app icon reversed: an ink slingshot on volt" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Reversed</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone icon"><picture><source srcset="img/dir-icon-zap.svg" media="(prefers-reduced-motion: reduce)"><img src="img/icon-pull-release.webp" alt="Animated SLYNG icon: the band pulls back, lets the stone fly, rings out, and a new stone drops in" width="1200" height="1200" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption><span class="stone" aria-hidden="true"></span>Pull and release</figcaption></figure>
|
||||
</div>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-bone"><img src="img/dir-lockup-lexend-light.svg" alt="SLYNG icon and wordmark lockup in Lexend, on bone" width="1200" height="240" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Lexend, on bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/dir-lockup-lexend-dark.svg" alt="SLYNG slingshot and wordmark lockup in Lexend, on ink" width="1200" height="235" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Lexend, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><img src="img/dir-lockup-unbounded-light.svg" alt="SLYNG icon and wordmark lockup in Unbounded, on bone" width="1200" height="217" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Unbounded, on bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/dir-lockup-unbounded-dark.svg" alt="SLYNG slingshot and wordmark lockup in Unbounded, on ink" width="1200" height="212" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Unbounded, on ink</figcaption></figure>
|
||||
</div>
|
||||
<ol class="story" aria-label="The pull and release, frame by frame"><li><div class="ground g-white"><img src="img/story-1-loaded.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<span>Loaded</span></li>
|
||||
<li><div class="ground g-white"><img src="img/story-2-drawn.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<span>Drawn back</span></li>
|
||||
<li><div class="ground g-white"><img src="img/story-3-release.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<span>Released</span></li>
|
||||
<li><div class="ground g-white"><img src="img/story-4-empty.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<span>Empty</span></li>
|
||||
<li><div class="ground g-white"><img src="img/story-5-reload.svg" alt="" width="1200" height="1200" loading="lazy" decoding="async"></div>
|
||||
<span>Reloading</span></li>
|
||||
</ol><p class="after"><span>Next: make the icon the Y of the wordmark.</span></p>
|
||||
</section>
|
||||
<section class="round" id="round-3" aria-labelledby="round-3-h"><header class="round-head"><p class="num" aria-hidden="true">3</p>
|
||||
<div><h3 id="round-3-h"><span class="sr">Round 3: </span>The icon as the Y</h3><p>The whole app icon set into the word as its Y, in Unbounded and in Lexend, with the pull and release playing inside it.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-bone"><img src="img/wm-icony-unbounded-light.svg" alt="SLYNG in Unbounded where the Y is the app icon, on bone" width="1200" height="233" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Unbounded, on bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/wm-icony-unbounded-dark.svg" alt="SLYNG in Unbounded where the Y is the app icon, on ink" width="1200" height="233" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Unbounded, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><img src="img/wm-icony-lexend-light.svg" alt="SLYNG in Lexend where the Y is the app icon, on bone" width="1200" height="262" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Lexend, on bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/wm-icony-lexend-dark.svg" alt="SLYNG in Lexend where the Y is the app icon, on ink" width="1200" height="262" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Lexend, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><picture><source srcset="img/wm-icony-unbounded-dark.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-anim-unbounded-dark.webp" alt="Animated: the icon in the Y pulls back and lets go, on ink" width="1200" height="234" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption><span class="stone" aria-hidden="true"></span>In motion, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><picture><source srcset="img/wm-icony-unbounded-light.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-anim-unbounded-light.webp" alt="Animated: the icon in the Y pulls back and lets go, on bone" width="1200" height="234" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption><span class="stone" aria-hidden="true"></span>In motion, on bone</figcaption></figure>
|
||||
</div>
|
||||
<p class="after"><span>Next: not the whole icon, only the bare slingshot, the way it stood in front of the word on ink. The motion stays.</span></p>
|
||||
</section>
|
||||
<section class="round" id="round-4" aria-labelledby="round-4-h"><header class="round-head"><p class="num" aria-hidden="true">4</p>
|
||||
<div><h3 id="round-4-h"><span class="sr">Round 4: </span>The bare slingshot as the Y</h3><p>The clean slingshot stands in for the Y. Without the icon's square around it, the stone leaves through the top of the frame and the next one drops back in the same way.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-ink"><img src="img/wm-sling-unbounded-dark.svg" alt="SLYNG in Unbounded with the bare slingshot as the Y, on ink" width="1200" height="245" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Unbounded, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><img src="img/wm-sling-unbounded-light-volt.svg" alt="SLYNG in Unbounded with the bare slingshot as the Y, on bone" width="1200" height="245" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Unbounded, on bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/wm-sling-lexend-dark.svg" alt="SLYNG in Lexend with the bare slingshot as the Y, on ink" width="1200" height="278" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Lexend, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><img src="img/wm-sling-lexend-light-volt.svg" alt="SLYNG in Lexend with the bare slingshot as the Y, on bone" width="1200" height="278" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Lexend, on bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><picture><source srcset="img/wm-sling-unbounded-dark.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-sling-anim-unbounded-dark.webp" alt="Animated: the bare slingshot Y fires its stone out of the top of the frame, on ink" width="1200" height="440" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>In motion, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><picture><source srcset="img/wm-sling-unbounded-light-volt.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-sling-anim-unbounded-light-volt.webp" alt="Animated: the bare slingshot Y fires its stone out of the top of the frame, on bone" width="1200" height="440" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>In motion, on bone</figcaption></figure>
|
||||
</div>
|
||||
<p class="after"><span>Next: a Y drawn in the same style as the other letters, the band lined up with its arms, and a dark animation without the shimmer.</span></p>
|
||||
</section>
|
||||
<section class="round" id="round-5" aria-labelledby="round-5-h"><header class="round-head"><p class="num" aria-hidden="true">5</p>
|
||||
<div><h3 id="round-5-h"><span class="sr">Round 5: </span>Unbounded's own Y</h3><p>Two takes. Unbounded's own Y, untouched, with the band along the inside of its arms and the stone in the fork; and the rounded slingshot, drawn to the weight of the letters. The motion is now rendered as whole, opaque, lossless frames, which is what stops the shimmer on ink.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-ink"><img src="img/wm-final-font-dark.svg" alt="SLYNG in white Unbounded on ink; the Y is Unbounded's own Y with a volt band along its arms and a volt stone in the fork" width="1200" height="225" loading="lazy" decoding="async"></div>
|
||||
<figcaption><span class="stone" aria-hidden="true"></span>Own Y, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink"><img src="img/wm-final-round-dark.svg" alt="SLYNG in white Unbounded on ink; the Y is the rounded slingshot with the band along its arms" width="1200" height="245" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Rounded Y, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><img src="img/wm-final-font-light.svg" alt="SLYNG in ink Unbounded on bone; the Y is Unbounded's own Y with an ink-outlined volt band along its arms" width="1200" height="225" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Own Y, on bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><img src="img/wm-final-round-light.svg" alt="SLYNG in ink Unbounded on bone; the Y is the rounded slingshot with the band along its arms" width="1200" height="245" loading="lazy" decoding="async"></div>
|
||||
<figcaption>Rounded Y, on bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink bleed"><picture><source srcset="img/wm-final-font-dark.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-font-anim-font-dark.webp" alt="Animated: Unbounded's own Y pulls back and fires, on ink" width="1200" height="400" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>In motion, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone bleed"><picture><source srcset="img/wm-final-font-light.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-font-anim-font-light.webp" alt="Animated: Unbounded's own Y pulls back and fires, on bone" width="1200" height="400" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>In motion, on bone</figcaption></figure>
|
||||
</div>
|
||||
<p class="after"><span>Next: the own Y on ink was the closest. The stone moves down onto the vertex, the bands run along the edges of the Y all the way to the tips, and the motion goes back to the earlier sequence.</span></p>
|
||||
</section>
|
||||
<section class="round" id="round-6" aria-labelledby="round-6-h"><header class="round-head"><p class="num" aria-hidden="true">6</p>
|
||||
<div><h3 id="round-6-h"><span class="sr">Round 6: </span>The logo</h3><p>The stone sits on the vertex. The bands run up the inside of the arms, a hairline from the edge, and stop flush with the tops of the letters. The motion is the earlier pull and release again, and every loop ends in the logo pose.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tiles two"><figure class="tile"><div class="ground g-ink"><img src="img/wm-logo-dark.svg" alt="The SLYNG logo in white on ink" width="1200" height="225" loading="lazy" decoding="async"></div>
|
||||
<figcaption>On ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone"><img src="img/wm-logo-light.svg" alt="The SLYNG logo in ink on bone" width="1200" height="225" loading="lazy" decoding="async"></div>
|
||||
<figcaption>On bone</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-ink bleed"><picture><source srcset="img/wm-final-still-dark.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-final-anim-dark.webp" alt="The SLYNG logo in motion, on ink" width="1200" height="400" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>In motion, on ink</figcaption></figure>
|
||||
<figure class="tile"><div class="ground g-bone bleed"><picture><source srcset="img/wm-final-still-light.svg" media="(prefers-reduced-motion: reduce)"><img src="img/wm-final-anim-light.webp" alt="The SLYNG logo in motion, on bone" width="1200" height="400" loading="lazy" decoding="async"></picture></div>
|
||||
<figcaption>In motion, on bone</figcaption></figure>
|
||||
</div>
|
||||
<p class="after"><span>Picked: this is the logo, the one at the top of this page.</span></p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<div class="wrap">
|
||||
<p>SLYNG is a venture on <a href="https://yolopreneur.lol/">yolopreneur.lol</a>, made by Claude in October 2026. The logo and every draft on this page were drawn in code, and <a href="../tools/brand/make.py">tools/brand/make.py</a> makes them all again, byte for byte.</p>
|
||||
<p>Unbounded, Lexend, Archivo, Fredoka, Sora and Big Shoulders Display come from Google Fonts under the SIL Open Font License. This page is set in Slyng Condensed and Slyng Text, under the <a href="../fonts/GUST-FONT-LICENSE.txt">GUST Font License</a>.</p>
|
||||
<p><a href="../">Back to SLYNG</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16 29V18.5" fill="none" stroke="#8A5A35" stroke-width="4.4" stroke-linecap="round"/><path d="M8.6 5.4C8.8 12 12.4 16.7 16 18.5C19.6 16.7 23.2 12 23.4 5.4" fill="none" stroke="#8A5A35" stroke-width="4.4" stroke-linecap="round"/><path d="M8.6 5.4L16 12.6L23.4 5.4" fill="none" stroke="#E2412E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="16" cy="12.6" r="3.3" fill="#F0A532" stroke="#B9761A" stroke-width="1"/></svg>
|
||||
|
After Width: | Height: | Size: 518 B |
@@ -0,0 +1,30 @@
|
||||
% This is a preliminary version (2006-09-30), barring acceptance from
|
||||
% the LaTeX Project Team and other feedback, of the GUST Font License.
|
||||
% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
|
||||
%
|
||||
% For the most recent version of this license see
|
||||
% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||
% or
|
||||
% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
|
||||
%
|
||||
% This work may be distributed and/or modified under the conditions
|
||||
% of the LaTeX Project Public License, either version 1.3c of this
|
||||
% license or (at your option) any later version.
|
||||
%
|
||||
% Please also observe the following clause:
|
||||
% 1) it is requested, but not legally required, that derived works be
|
||||
% distributed only after changing the names of the fonts comprising this
|
||||
% work and given in an accompanying "manifest", and that the
|
||||
% files comprising the Work, as listed in the manifest, also be given
|
||||
% new names. Any exceptions to this request are also given in the
|
||||
% manifest.
|
||||
%
|
||||
% We recommend the manifest be given in a separate file named
|
||||
% MANIFEST-<fontid>.txt, where <fontid> is some unique identification
|
||||
% of the font family. If a separate "readme" file accompanies the Work,
|
||||
% we recommend a name of the form README-<fontid>.txt.
|
||||
%
|
||||
% The latest version of the LaTeX Project Public License is in
|
||||
% http://www.latex-project.org/lppl.txt and version 1.3c or later
|
||||
% is part of all distributions of LaTeX version 2006/05/20 or later.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
The fonts in this folder are derived works of the TeX Gyre fonts by the GUST e-foundry, distributed under the
|
||||
GUST Font License (GUST-FONT-LICENSE.txt in this folder; it refers to the LaTeX Project Public License 1.3c,
|
||||
https://www.latex-project.org/lppl.txt).
|
||||
|
||||
As the license requests, the derived fonts and their files have new names:
|
||||
|
||||
slyng-condensed-bold.woff "Slyng Condensed Bold" from TeX Gyre Heros Cn Bold (texgyreheroscn-bold.otf)
|
||||
slyng-text-regular.woff "Slyng Text Regular" from TeX Gyre Adventor Regular (texgyreadventor-regular.otf)
|
||||
slyng-text-bold.woff "Slyng Text Bold" from TeX Gyre Adventor Bold (texgyreadventor-bold.otf)
|
||||
|
||||
What changed: the glyph set was cut down to Latin-1 plus common punctuation, the OpenType features to kern, liga,
|
||||
tnum, lnum and pnum, the name table to the names above, and the files were converted to WOFF. The outlines are
|
||||
unchanged. Made for SLYNG (https://slyng.yolopreneur.lol/) in October 2026.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,252 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https:; font-src 'self'; connect-src 'self' https: wss: ws://127.0.0.1:* ws://localhost:* http://127.0.0.1:* http://localhost:*; media-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<meta name="referrer" content="strict-origin">
|
||||
<title>SLYNG</title>
|
||||
<meta name="description" content="Sling sats and notes on nostr. Aim at someone you follow, pull back for more, let go to send. Your key, your wallet, no account.">
|
||||
<link rel="canonical" href="https://slyng.yolopreneur.lol/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="SLYNG: sling sats and notes on nostr">
|
||||
<meta property="og:description" content="Aim at someone you follow, pull back for more, let go to send. Your key, your wallet, no account.">
|
||||
<meta property="og:url" content="https://slyng.yolopreneur.lol/">
|
||||
<meta property="og:image" content="https://slyng.yolopreneur.lol/shots/og.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="theme-color" content="#D8E8F0">
|
||||
<link rel="icon" href="favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate" type="text/plain" href="llms.txt" title="For agents">
|
||||
<link rel="preload" href="fonts/slyng-condensed-bold.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="preload" href="fonts/slyng-text-regular.woff" as="font" type="font/woff" crossorigin>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script type="module" src="app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="brand" href="#top" aria-label="SLYNG, to the top">
|
||||
<svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><path class="m-wood" d="M16 29V18.5M8.6 5.4C8.8 12 12.4 16.7 16 18.5C19.6 16.7 23.2 12 23.4 5.4"/><path class="m-band" d="M8.6 5.4L16 12.6L23.4 5.4"/><circle class="m-stone" cx="16" cy="12.6" r="3.3"/></svg>
|
||||
<span class="word">SLYNG</span>
|
||||
</a>
|
||||
<p class="motto">Sling sats and notes on nostr</p>
|
||||
<nav class="links" aria-label="On this page"><a href="#how">How it works</a><a href="#agents">For agents</a><a href="#plans">Plans</a></nav>
|
||||
<a class="join" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+SLYNG&venture=slyng&plan=Stall+%28founding%29">Get early access</a>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="studio" aria-label="The slingshot">
|
||||
<div class="range">
|
||||
<div class="chips">
|
||||
<button type="button" class="chip" id="account" aria-haspopup="dialog">Demo</button>
|
||||
<button type="button" class="chip" id="wallet" aria-haspopup="dialog">21,000 demo sats</button>
|
||||
</div>
|
||||
<p id="aimed" class="aimed" hidden></p>
|
||||
<svg id="sling" class="sling" tabindex="0" role="application" aria-roledescription="slingshot"
|
||||
aria-label="Slingshot. Drag the stone back, away from the person you aim at. The further back, the more sats. With a keyboard: left and right arrows aim, down pulls back, up eases off, Enter lets go."
|
||||
aria-describedby="readout"></svg>
|
||||
<div class="readout" id="readout" aria-live="polite">
|
||||
<p class="amt" id="amt">Pull back to sling</p>
|
||||
<p class="sub" id="sub">Drag the stone down, away from who you aim at.</p>
|
||||
</div>
|
||||
<div class="note">
|
||||
<label for="note" class="sr">Note</label>
|
||||
<textarea id="note" rows="1" maxlength="1000" placeholder="Tie a note to it" autocomplete="off" enterkeyhint="done"></textarea>
|
||||
</div>
|
||||
<div class="flight" id="flight" hidden>
|
||||
<button type="button" class="pill catch" id="catchback">Catch it back</button>
|
||||
</div>
|
||||
<p class="fence-row"><button type="button" class="linkish" id="fence">Change who's on the fence</button></p>
|
||||
</div>
|
||||
|
||||
<aside class="log" aria-label="What you caught and slung">
|
||||
<div class="log-tabs" role="tablist" aria-label="Activity">
|
||||
<button type="button" role="tab" id="t-caught" aria-selected="true" aria-controls="caught">Caught <span class="count" id="n-caught"></span></button>
|
||||
<button type="button" role="tab" id="t-slung" aria-selected="false" aria-controls="slung">Slung <span class="count" id="n-slung"></span></button>
|
||||
</div>
|
||||
<ol id="caught" class="entries" role="tabpanel" aria-labelledby="t-caught"></ol>
|
||||
<ol id="slung" class="entries" role="tabpanel" aria-labelledby="t-slung" hidden></ol>
|
||||
<p id="log-empty" class="empty"></p>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section id="story" class="story" aria-labelledby="story-h">
|
||||
<p class="bigword" aria-hidden="true">SL<svg class="bigy" viewBox="0 0 64 72"><path class="y-wood" d="M32 69V40M10 6C10.5 22 20 35 32 40C44 35 53.5 22 54 6"/><path class="y-band" d="M10 6L32 24L54 6"/><circle class="y-stone" cx="32" cy="24" r="7"/></svg>NG</p>
|
||||
<div class="story-text">
|
||||
<h2 id="story-h">The Y is the fork</h2>
|
||||
<p>SLYNG started as a verb. I'll sling you some sats. I'll sling you a note later. It needed a spelling of its own, and the odd one turned out to be the point: the Y in the middle of the word is a slingshot.</p>
|
||||
<p>So the app is one. The people you follow stand on the fence. You aim by pulling the other way, the band tells you how much, and when you let go it lands with them: sats over lightning, and a note sealed so only they can read it.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="how" class="how" aria-labelledby="how-h">
|
||||
<h2 id="how-h">How it works</h2>
|
||||
<ul class="steps">
|
||||
<li>
|
||||
<svg class="diagram" viewBox="0 0 120 80" aria-hidden="true"><path class="d-fence" d="M14 34 A70 70 0 0 1 106 34"/><circle class="d-dot" cx="22" cy="26" r="6"/><circle class="d-dot on" cx="60" cy="12" r="6"/><circle class="d-dot" cx="98" cy="26" r="6"/><path class="d-arrow" d="M60 74 L60 24"/><path class="d-pull" d="M60 60 L60 76"/></svg>
|
||||
<h3>Aim</h3>
|
||||
<p>Everyone you can sling to stands on the fence: the people you follow on nostr. Pull the stone away from the person you mean, the way a real slingshot works, and the dotted arc shows where it will go.</p>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="diagram" viewBox="0 0 120 80" aria-hidden="true"><circle class="d-ring" cx="60" cy="18" r="10"/><circle class="d-ring" cx="60" cy="18" r="22"/><circle class="d-ring on" cx="60" cy="18" r="34"/><circle class="d-ring" cx="60" cy="18" r="46"/><circle class="d-stone" cx="60" cy="52" r="5"/></svg>
|
||||
<h3>Stretch</h3>
|
||||
<p>The further back you pull, the more you send. A short tug is a note on its own, then 21 sats, 100, 210, and on up to 21,000. Each ring is one step, so the amount is written out before you let go.</p>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="diagram" viewBox="0 0 120 80" aria-hidden="true"><path class="d-arc" d="M18 70 Q70 -14 104 34"/><circle class="d-stone" cx="18" cy="70" r="5"/><circle class="d-dot on" cx="104" cy="34" r="7"/></svg>
|
||||
<h3>Let go</h3>
|
||||
<p>The stone is in the air for a moment, and you can catch it back. When it lands, your wallet pays and your note goes, privately. On their side, it shows up as caught.</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="agents" class="agents" aria-labelledby="agents-h">
|
||||
<h2 id="agents-h">Let an agent aim</h2>
|
||||
<p class="lead">An agent can aim the slingshot. Only a person lets go.</p>
|
||||
<p>Any agent can make a link that opens SLYNG aimed at someone, with a note tied on and a suggested amount marked on the band. You read it, pull back, and let go, or you don't.</p>
|
||||
<div class="snippet"><pre id="agent-link">https://slyng.yolopreneur.lol/#to=name@example.com&sats=2100&note=for%20the%20records</pre>
|
||||
<button type="button" class="pill small copy" data-copy="agent-link">Copy link</button></div>
|
||||
<p>Or hand your agent this prompt:</p>
|
||||
<div class="snippet"><pre id="agent-prompt">Read https://slyng.yolopreneur.lol/skill.md, then make me a SLYNG link to send [who] [how many] sats with the note "[what to say]".</pre>
|
||||
<button type="button" class="pill small copy" data-copy="agent-prompt">Copy prompt</button></div>
|
||||
<p>An agent with a nostr key and a wallet of its own can sling by itself, the way SLYNG does: a zap for the sats and a gift-wrapped note for the words. The files: <a href="skill.md">skill.md</a> (the steps, for agents) and <a href="llms.txt">llms.txt</a> (this list).</p>
|
||||
</section>
|
||||
|
||||
<section id="plans" class="plans" aria-labelledby="plans-h">
|
||||
<h2 id="plans-h">Plans</h2>
|
||||
<div class="plan-grid">
|
||||
<div class="plan">
|
||||
<h3>Sling</h3>
|
||||
<p class="price">Free, today</p>
|
||||
<p>Everything on this page: sats and private notes to anyone on nostr, with your own key and your own wallet. No account, and no cut taken.</p>
|
||||
</div>
|
||||
<div class="plan founding">
|
||||
<h3>Stall</h3>
|
||||
<p class="price">Planned table fee of 2,100 sats a month for founding sellers. Nothing is charged now.</p>
|
||||
<p>A stall at slyng.ing, the flea market: local goods to meet up for and digital ones to sling. A stall-keeper that haggles for you and turns away lowballers, and a cut for whoever reposts your listing into a sale.</p>
|
||||
<a class="pill join-plan" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+SLYNG&venture=slyng&plan=Stall+%28founding%29">Join early access</a>
|
||||
<p class="tiny">Early access is a public issue on Teahub: no email, nothing charged.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="fine" class="fine" aria-labelledby="fine-h">
|
||||
<h2 id="fine-h">The fine print</h2>
|
||||
<dl>
|
||||
<dt>No account, no server of ours</dt>
|
||||
<dd>This page talks to nostr relays and to the lightning address servers of the people you sling to, straight from your browser. Nothing goes through SLYNG.</dd>
|
||||
<dt>Your key</dt>
|
||||
<dd>With a nostr extension, your key never touches this page. A key made or pasted here stays in this browser, for this tab unless you ask to be remembered. It signs from a page, so keep it for everyday amounts and keep your main key in an extension.</dd>
|
||||
<dt>Your sats</dt>
|
||||
<dd>Sats go from your own wallet, through a browser wallet or Nostr Wallet Connect, straight to theirs. SLYNG never holds them. Before your wallet pays, the page checks that the invoice is for exactly the amount you pulled. Lightning payments are final.</dd>
|
||||
<dt>What's public</dt>
|
||||
<dd>Sats go as a zap, and zaps are public on nostr: anyone can see who sent how much to whom. The note travels separately, encrypted and gift-wrapped (NIP-17), so relays can see neither the words nor who sent them.</dd>
|
||||
<dt>Catch it back</dt>
|
||||
<dd>While the stone is in the air, you can catch it back and nothing is sent.</dd>
|
||||
<dt>Demo</dt>
|
||||
<dd>Until you sign in, the sats are pretend and the people on the fence are made up. Nothing leaves the page.</dd>
|
||||
<dt>Not affiliated</dt>
|
||||
<dd>SLYNG is independent, and isn't affiliated with any wallet, relay or app named here.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section id="next" class="next" aria-labelledby="next-h">
|
||||
<h2 id="next-h">Coming next</h2>
|
||||
<ul>
|
||||
<li>Three front doors: slyng.app for notes, slyng.money for sats, slyng.ing for the market.</li>
|
||||
<li>The market: stalls for local and digital goods, offers slung at a stall, counters slung back.</li>
|
||||
<li>Files: sling a file, and the key to open it, for sale or for free.</li>
|
||||
<li>Reach: for notes, a longer pull goes further, from one person to a group to everyone.</li>
|
||||
<li>Remote signers, so your key can stay on your phone.</li>
|
||||
<li>A Whop app, so sellers there can take sats and set up a stall.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="foot">
|
||||
<p>SLYNG is a venture on <a href="https://yolopreneur.lol/">yolopreneur.lol</a>, made by Claude in October 2026. Type is set in Slyng Condensed and Slyng Text, cut from TeX Gyre Heros Condensed and TeX Gyre Adventor under the <a href="fonts/GUST-FONT-LICENSE.txt">GUST Font License</a>. The logo, its files and every draft that led to it are on the <a href="branding/">brand page</a>.</p>
|
||||
</footer>
|
||||
|
||||
<dialog id="accountdlg" class="dlg" aria-labelledby="acc-h">
|
||||
<form method="dialog" class="form">
|
||||
<h2 id="acc-h">Sling for real</h2>
|
||||
<div id="acc-out">
|
||||
<p>Sign in with nostr to sling to the people you follow. Your key is who you are; there's nothing else to sign up for.</p>
|
||||
<div class="choices">
|
||||
<button type="button" class="choice" id="acc-ext"><b>Use my nostr extension</b><span id="acc-ext-note">Alby, nos2x and others. Your key stays in the extension.</span></button>
|
||||
<button type="button" class="choice" id="acc-new"><b>Make a new key here</b><span>New to nostr. The key lives in this browser, and you get a copy to keep.</span></button>
|
||||
</div>
|
||||
<details class="group">
|
||||
<summary>Paste a secret key</summary>
|
||||
<label>Secret key <input type="password" id="acc-nsec" autocomplete="off" spellcheck="false" placeholder="nsec1…"></label>
|
||||
<button type="button" class="pill small" id="acc-paste">Use this key</button>
|
||||
</details>
|
||||
<label class="check"><input type="checkbox" id="acc-remember"> Remember me on this device</label>
|
||||
</div>
|
||||
<div id="acc-in" hidden>
|
||||
<p id="acc-who"></p>
|
||||
<p class="tiny" id="acc-how"></p>
|
||||
<div class="row">
|
||||
<button type="button" class="pill small ghost" id="acc-show" hidden>Show my key</button>
|
||||
<button type="button" class="pill small ghost" id="acc-out-btn">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="acc-status" class="status" role="status"></p>
|
||||
<div class="row end">
|
||||
<button type="button" class="pill ghost small" id="acc-close">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="keydlg" class="dlg" aria-labelledby="key-h">
|
||||
<form method="dialog" class="form">
|
||||
<h2 id="key-h">Keep a copy of your key</h2>
|
||||
<p>This is your nostr secret key. Anyone who has it can sling as you, so keep it somewhere private, like a password manager. Once it's gone from this browser, it's gone.</p>
|
||||
<pre id="key-nsec" class="secret"></pre>
|
||||
<div class="row end">
|
||||
<button type="button" class="pill small ghost" id="key-copy">Copy key</button>
|
||||
<button type="submit" class="pill small" value="close">I saved it</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="walletdlg" class="dlg" aria-labelledby="wal-h">
|
||||
<form method="dialog" class="form">
|
||||
<h2 id="wal-h">Your wallet</h2>
|
||||
<p>Sats go from your own wallet straight to theirs. SLYNG never holds them.</p>
|
||||
<div id="wal-out">
|
||||
<div class="choices">
|
||||
<button type="button" class="choice" id="wal-webln"><b>Use my browser wallet</b><span id="wal-webln-note">A lightning extension, such as Alby.</span></button>
|
||||
</div>
|
||||
<label>Or paste a Nostr Wallet Connect string <input type="password" id="wal-nwc" autocomplete="off" spellcheck="false" placeholder="nostr+walletconnect://…"></label>
|
||||
<button type="button" class="pill small" id="wal-connect">Connect</button>
|
||||
<label class="check"><input type="checkbox" id="wal-remember"> Remember this wallet on this device</label>
|
||||
</div>
|
||||
<div id="wal-in" hidden>
|
||||
<p id="wal-which"></p>
|
||||
<button type="button" class="pill small ghost" id="wal-forget">Disconnect</button>
|
||||
</div>
|
||||
<p id="wal-status" class="status" role="status"></p>
|
||||
<div class="row end">
|
||||
<button type="button" class="pill ghost small" id="wal-close">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="fencedlg" class="dlg wide" aria-labelledby="fence-h">
|
||||
<form method="dialog" class="form" id="fence-form">
|
||||
<h2 id="fence-h">Who's on the fence</h2>
|
||||
<p id="fence-lede">Up to five people stand on the fence at a time. Pick from who you follow, or add anyone by npub or name@domain.</p>
|
||||
<label><span class="sr">Find someone</span><input type="search" id="fence-q" placeholder="Search, or paste an npub or name@domain" autocomplete="off" spellcheck="false"></label>
|
||||
<p id="fence-status" class="status" role="status"></p>
|
||||
<ul id="fence-list" class="people"></ul>
|
||||
<div class="row end">
|
||||
<button type="button" class="pill small" id="fence-done">Done</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,497 @@
|
||||
// The cryptography SLYNG needs, with no dependencies: SHA-256, HMAC, HKDF, secp256k1 (BIP-340 Schnorr signatures
|
||||
// and ECDH), ChaCha20, and NIP-44 v2 encryption. Pure functions over Uint8Array, usable in the browser and in Node.
|
||||
//
|
||||
// The curve arithmetic uses BigInt and is not constant-time. It signs the occasional event in a browser tab; for a
|
||||
// key that holds real weight, sign with a nostr extension instead (SLYNG prefers one when it finds one).
|
||||
|
||||
const te = new TextEncoder();
|
||||
const td = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
export const utf8 = (s) => te.encode(s);
|
||||
export const fromUtf8 = (b) => td.decode(b);
|
||||
|
||||
export function concat(...parts) {
|
||||
let n = 0;
|
||||
for (const p of parts) n += p.length;
|
||||
const out = new Uint8Array(n);
|
||||
let o = 0;
|
||||
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hex(bytes) {
|
||||
let s = '';
|
||||
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
||||
return s;
|
||||
}
|
||||
|
||||
export function unhex(s) {
|
||||
if (typeof s !== 'string' || s.length % 2 || /[^0-9a-f]/i.test(s)) throw new Error('not hex');
|
||||
const out = new Uint8Array(s.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
export const isHex64 = (s) => typeof s === 'string' && /^[0-9a-f]{64}$/.test(s);
|
||||
|
||||
export function randomBytes(n) {
|
||||
const out = new Uint8Array(n);
|
||||
globalThis.crypto.getRandomValues(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function equalBytes(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let d = 0;
|
||||
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
|
||||
return d === 0;
|
||||
}
|
||||
|
||||
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
export function base64(bytes) {
|
||||
let s = '';
|
||||
let i = 0;
|
||||
for (; i + 2 < bytes.length; i += 3) {
|
||||
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
||||
s += B64[n >> 18] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
|
||||
}
|
||||
const rest = bytes.length - i;
|
||||
if (rest === 1) {
|
||||
const n = bytes[i] << 16;
|
||||
s += B64[n >> 18] + B64[(n >> 12) & 63] + '==';
|
||||
} else if (rest === 2) {
|
||||
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
|
||||
s += B64[n >> 18] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + '=';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function unbase64(s) {
|
||||
if (typeof s !== 'string' || !/^[A-Za-z0-9+/]*={0,2}$/.test(s) || s.length % 4) throw new Error('not base64');
|
||||
const pad = s.endsWith('==') ? 2 : s.endsWith('=') ? 1 : 0;
|
||||
const out = new Uint8Array((s.length / 4) * 3 - pad);
|
||||
let o = 0;
|
||||
for (let i = 0; i < s.length; i += 4) {
|
||||
const n = (B64.indexOf(s[i]) << 18) | (B64.indexOf(s[i + 1]) << 12) |
|
||||
((s[i + 2] === '=' ? 0 : B64.indexOf(s[i + 2])) << 6) | (s[i + 3] === '=' ? 0 : B64.indexOf(s[i + 3]));
|
||||
if (o < out.length) out[o++] = n >> 16;
|
||||
if (o < out.length) out[o++] = (n >> 8) & 255;
|
||||
if (o < out.length) out[o++] = n & 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- SHA-256
|
||||
|
||||
const K256 = new Uint32Array([
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]);
|
||||
|
||||
export function sha256(msg) {
|
||||
const len = msg.length;
|
||||
const total = ((len + 9 + 63) >> 6) << 6;
|
||||
const buf = new Uint8Array(total);
|
||||
buf.set(msg);
|
||||
buf[len] = 0x80;
|
||||
const dv = new DataView(buf.buffer);
|
||||
dv.setUint32(total - 8, Math.floor(len / 0x20000000));
|
||||
dv.setUint32(total - 4, (len << 3) >>> 0);
|
||||
const H = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
||||
const W = new Uint32Array(64);
|
||||
for (let off = 0; off < total; off += 64) {
|
||||
for (let i = 0; i < 16; i++) W[i] = dv.getUint32(off + i * 4);
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const a = W[i - 15], b = W[i - 2];
|
||||
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3);
|
||||
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10);
|
||||
W[i] = (W[i - 16] + s0 + W[i - 7] + s1) | 0;
|
||||
}
|
||||
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (h + S1 + ch + K256[i] + W[i]) | 0;
|
||||
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
|
||||
const maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + maj) | 0;
|
||||
h = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
|
||||
}
|
||||
H[0] += a; H[1] += b; H[2] += c; H[3] += d; H[4] += e; H[5] += f; H[6] += g; H[7] += h;
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
const odv = new DataView(out.buffer);
|
||||
for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hmacSha256(key, msg) {
|
||||
const k = new Uint8Array(64);
|
||||
k.set(key.length > 64 ? sha256(key) : key);
|
||||
const inner = new Uint8Array(64 + msg.length);
|
||||
const outer = new Uint8Array(96);
|
||||
for (let i = 0; i < 64; i++) { inner[i] = k[i] ^ 0x36; outer[i] = k[i] ^ 0x5c; }
|
||||
inner.set(msg, 64);
|
||||
outer.set(sha256(inner), 64);
|
||||
return sha256(outer);
|
||||
}
|
||||
|
||||
export const hkdfExtract = (salt, ikm) => hmacSha256(salt, ikm);
|
||||
|
||||
export function hkdfExpand(prk, info, length) {
|
||||
if (length > 255 * 32) throw new Error('hkdf length');
|
||||
const out = new Uint8Array(length);
|
||||
let prev = new Uint8Array(0);
|
||||
for (let i = 1, pos = 0; pos < length; i++, pos += 32) {
|
||||
prev = hmacSha256(prk, concat(prev, info, Uint8Array.of(i)));
|
||||
out.set(prev.subarray(0, Math.min(32, length - pos)), pos);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- secp256k1
|
||||
|
||||
const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
|
||||
export const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
|
||||
const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
|
||||
const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
|
||||
|
||||
const mod = (a, m = P) => { const r = a % m; return r >= 0n ? r : r + m; };
|
||||
|
||||
function inv(a, m = P) {
|
||||
let r0 = mod(a, m), r1 = m, s0 = 1n, s1 = 0n;
|
||||
while (r1 !== 0n) {
|
||||
const q = r0 / r1;
|
||||
[r0, r1] = [r1, r0 - q * r1];
|
||||
[s0, s1] = [s1, s0 - q * s1];
|
||||
}
|
||||
if (r0 !== 1n) throw new Error('not invertible');
|
||||
return mod(s0, m);
|
||||
}
|
||||
|
||||
function powMod(b, e, m = P) {
|
||||
let r = 1n;
|
||||
b = mod(b, m);
|
||||
while (e > 0n) {
|
||||
if (e & 1n) r = (r * b) % m;
|
||||
b = (b * b) % m;
|
||||
e >>= 1n;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
const INF = { X: 0n, Y: 1n, Z: 0n };
|
||||
|
||||
function dbl(p) {
|
||||
if (p.Z === 0n || p.Y === 0n) return INF;
|
||||
const A = (p.X * p.X) % P;
|
||||
const B = (p.Y * p.Y) % P;
|
||||
const C = (B * B) % P;
|
||||
const xb = p.X + B;
|
||||
const D = mod(2n * (xb * xb - A - C));
|
||||
const E = (3n * A) % P;
|
||||
const F = (E * E) % P;
|
||||
const X3 = mod(F - 2n * D);
|
||||
const Y3 = mod(E * (D - X3) - 8n * C);
|
||||
const Z3 = (2n * p.Y * p.Z) % P;
|
||||
return { X: X3, Y: Y3, Z: Z3 };
|
||||
}
|
||||
|
||||
function add(p, q) {
|
||||
if (p.Z === 0n) return q;
|
||||
if (q.Z === 0n) return p;
|
||||
const Z1Z1 = (p.Z * p.Z) % P;
|
||||
const Z2Z2 = (q.Z * q.Z) % P;
|
||||
const U1 = (p.X * Z2Z2) % P;
|
||||
const U2 = (q.X * Z1Z1) % P;
|
||||
const S1 = (((p.Y * q.Z) % P) * Z2Z2) % P;
|
||||
const S2 = (((q.Y * p.Z) % P) * Z1Z1) % P;
|
||||
if (U1 === U2) return S1 === S2 ? dbl(p) : INF;
|
||||
const H = mod(U2 - U1);
|
||||
const R = mod(S2 - S1);
|
||||
const H2 = (H * H) % P;
|
||||
const H3 = (H * H2) % P;
|
||||
const U1H2 = (U1 * H2) % P;
|
||||
const X3 = mod(R * R - H3 - 2n * U1H2);
|
||||
const Y3 = mod(R * (U1H2 - X3) - S1 * H3);
|
||||
const Z3 = (((H * p.Z) % P) * q.Z) % P;
|
||||
return { X: X3, Y: Y3, Z: Z3 };
|
||||
}
|
||||
|
||||
const neg = (p) => (p.Z === 0n ? p : { X: p.X, Y: mod(-p.Y), Z: p.Z });
|
||||
|
||||
function affine(p) {
|
||||
if (p.Z === 0n) return null;
|
||||
const zi = inv(p.Z);
|
||||
const zi2 = (zi * zi) % P;
|
||||
return { x: (p.X * zi2) % P, y: (((p.Y * zi2) % P) * zi) % P };
|
||||
}
|
||||
|
||||
// k·p with a 4-bit window: 64 windows of 4 doublings and one addition
|
||||
function mul(p, k) {
|
||||
k = mod(k, N);
|
||||
if (k === 0n || p.Z === 0n) return INF;
|
||||
const table = [INF, p];
|
||||
for (let i = 2; i < 16; i++) table.push(add(table[i - 1], p));
|
||||
let r = INF;
|
||||
for (let shift = 252n; shift >= 0n; shift -= 4n) {
|
||||
r = dbl(dbl(dbl(dbl(r))));
|
||||
const nib = Number((k >> shift) & 15n);
|
||||
if (nib) r = add(r, table[nib]);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
// Many points to Z = 1 with one inversion (Montgomery's trick)
|
||||
function normalizeAll(points) {
|
||||
const zs = points.map((p) => (p.Z === 0n ? 1n : p.Z));
|
||||
const prefix = new Array(zs.length);
|
||||
let acc = 1n;
|
||||
for (let i = 0; i < zs.length; i++) { prefix[i] = acc; acc = (acc * zs[i]) % P; }
|
||||
let invAcc = inv(acc);
|
||||
const out = new Array(points.length);
|
||||
for (let i = zs.length - 1; i >= 0; i--) {
|
||||
const zi = (invAcc * prefix[i]) % P;
|
||||
invAcc = (invAcc * zs[i]) % P;
|
||||
const p = points[i];
|
||||
if (p.Z === 0n) { out[i] = INF; continue; }
|
||||
const zi2 = (zi * zi) % P;
|
||||
out[i] = { X: (p.X * zi2) % P, Y: (((p.Y * zi2) % P) * zi) % P, Z: 1n };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// G multiples, precomputed once: GT[w][j] = j·16^w·G, so k·G is 64 additions and no doublings
|
||||
let GT = null;
|
||||
function gTable() {
|
||||
if (GT) return GT;
|
||||
const flat = [];
|
||||
let base = { X: GX, Y: GY, Z: 1n };
|
||||
for (let w = 0; w < 64; w++) {
|
||||
const row = [INF, base];
|
||||
for (let j = 2; j < 16; j++) row.push(add(row[j - 1], base));
|
||||
flat.push(...row);
|
||||
base = dbl(dbl(dbl(dbl(base))));
|
||||
}
|
||||
const norm = normalizeAll(flat);
|
||||
GT = [];
|
||||
for (let w = 0; w < 64; w++) GT.push(norm.slice(w * 16, w * 16 + 16));
|
||||
return GT;
|
||||
}
|
||||
|
||||
function mulG(k) {
|
||||
k = mod(k, N);
|
||||
if (k === 0n) return INF;
|
||||
const T = gTable();
|
||||
let r = INF;
|
||||
for (let w = 0; w < 64; w++) {
|
||||
const nib = Number((k >> BigInt(w * 4)) & 15n);
|
||||
if (nib) r = add(r, T[w][nib]);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export const bytesToBig = (b) => (b.length ? BigInt('0x' + hex(b)) : 0n);
|
||||
export const bigToBytes = (n) => unhex(n.toString(16).padStart(64, '0'));
|
||||
|
||||
function liftX(x) {
|
||||
if (x >= P) return null;
|
||||
const c = mod(x * x * x + 7n);
|
||||
const y = powMod(c, (P + 1n) / 4n);
|
||||
if ((y * y) % P !== c) return null;
|
||||
return { X: x, Y: (y & 1n) === 0n ? y : P - y, Z: 1n };
|
||||
}
|
||||
|
||||
function privToBig(priv) {
|
||||
const d = bytesToBig(priv);
|
||||
if (priv.length !== 32 || d <= 0n || d >= N) throw new Error('bad private key');
|
||||
return d;
|
||||
}
|
||||
|
||||
export function isValidPrivateKey(priv) {
|
||||
try { privToBig(priv); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
export function generatePrivateKey() {
|
||||
for (;;) {
|
||||
const k = randomBytes(32);
|
||||
if (isValidPrivateKey(k)) return k;
|
||||
}
|
||||
}
|
||||
|
||||
// x-only public key (32 bytes), as nostr uses
|
||||
export function getPublicKey(priv) {
|
||||
return bigToBytes(affine(mulG(privToBig(priv))).x);
|
||||
}
|
||||
|
||||
export function isValidPublicKey(pub) {
|
||||
return pub.length === 32 && liftX(bytesToBig(pub)) !== null;
|
||||
}
|
||||
|
||||
function taggedHash(tag, ...msgs) {
|
||||
const t = sha256(utf8(tag));
|
||||
return sha256(concat(t, t, ...msgs));
|
||||
}
|
||||
|
||||
export function schnorrSign(msg, priv, aux = randomBytes(32)) {
|
||||
const d0 = privToBig(priv);
|
||||
const Pt = affine(mulG(d0));
|
||||
const d = (Pt.y & 1n) === 0n ? d0 : N - d0;
|
||||
const px = bigToBytes(Pt.x);
|
||||
const ta = taggedHash('BIP0340/aux', aux);
|
||||
const t = bigToBytes(d).map((b, i) => b ^ ta[i]);
|
||||
const k0 = mod(bytesToBig(taggedHash('BIP0340/nonce', t, px, msg)), N);
|
||||
if (k0 === 0n) throw new Error('nonce is zero');
|
||||
const R = affine(mulG(k0));
|
||||
const k = (R.y & 1n) === 0n ? k0 : N - k0;
|
||||
const rx = bigToBytes(R.x);
|
||||
const e = mod(bytesToBig(taggedHash('BIP0340/challenge', rx, px, msg)), N);
|
||||
const sig = concat(rx, bigToBytes(mod(k + e * d, N)));
|
||||
if (!schnorrVerify(sig, msg, px)) throw new Error('signature did not verify');
|
||||
return sig;
|
||||
}
|
||||
|
||||
export function schnorrVerify(sig, msg, pub) {
|
||||
try {
|
||||
if (sig.length !== 64 || pub.length !== 32) return false;
|
||||
const Pt = liftX(bytesToBig(pub));
|
||||
if (!Pt) return false;
|
||||
const r = bytesToBig(sig.subarray(0, 32));
|
||||
const s = bytesToBig(sig.subarray(32, 64));
|
||||
if (r >= P || s >= N) return false;
|
||||
const e = mod(bytesToBig(taggedHash('BIP0340/challenge', sig.subarray(0, 32), pub, msg)), N);
|
||||
const R = affine(add(mulG(s), neg(mul(Pt, e))));
|
||||
if (!R || (R.y & 1n) !== 0n) return false;
|
||||
return R.x === r;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ECDH as NIP-04 and NIP-44 use it: the unhashed x coordinate of priv·pub
|
||||
export function sharedX(priv, pubX) {
|
||||
const Pt = liftX(bytesToBig(pubX));
|
||||
if (!Pt) throw new Error('bad public key');
|
||||
return bigToBytes(affine(mul(Pt, privToBig(priv))).x);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- ChaCha20
|
||||
|
||||
function rotl(v, c) { return (v << c) | (v >>> (32 - c)); }
|
||||
|
||||
function quarter(s, a, b, c, d) {
|
||||
s[a] = (s[a] + s[b]) | 0; s[d] = rotl(s[d] ^ s[a], 16);
|
||||
s[c] = (s[c] + s[d]) | 0; s[b] = rotl(s[b] ^ s[c], 12);
|
||||
s[a] = (s[a] + s[b]) | 0; s[d] = rotl(s[d] ^ s[a], 8);
|
||||
s[c] = (s[c] + s[d]) | 0; s[b] = rotl(s[b] ^ s[c], 7);
|
||||
}
|
||||
|
||||
// RFC 8439 ChaCha20: 32-byte key, 12-byte nonce, 32-bit block counter
|
||||
export function chacha20(key, nonce, data, counter = 0) {
|
||||
if (key.length !== 32 || nonce.length !== 12) throw new Error('chacha20 key or nonce');
|
||||
const kv = new DataView(key.buffer, key.byteOffset, 32);
|
||||
const nv = new DataView(nonce.buffer, nonce.byteOffset, 12);
|
||||
const init = new Int32Array(16);
|
||||
init[0] = 0x61707865; init[1] = 0x3320646e; init[2] = 0x79622d32; init[3] = 0x6b206574;
|
||||
for (let i = 0; i < 8; i++) init[4 + i] = kv.getUint32(i * 4, true);
|
||||
for (let i = 0; i < 3; i++) init[13 + i] = nv.getUint32(i * 4, true);
|
||||
const out = new Uint8Array(data.length);
|
||||
const s = new Int32Array(16);
|
||||
const block = new Uint8Array(64);
|
||||
const bv = new DataView(block.buffer);
|
||||
for (let off = 0; off < data.length; off += 64) {
|
||||
init[12] = counter++;
|
||||
s.set(init);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
quarter(s, 0, 4, 8, 12); quarter(s, 1, 5, 9, 13); quarter(s, 2, 6, 10, 14); quarter(s, 3, 7, 11, 15);
|
||||
quarter(s, 0, 5, 10, 15); quarter(s, 1, 6, 11, 12); quarter(s, 2, 7, 8, 13); quarter(s, 3, 4, 9, 14);
|
||||
}
|
||||
for (let i = 0; i < 16; i++) bv.setUint32(i * 4, (s[i] + init[i]) | 0, true);
|
||||
const n = Math.min(64, data.length - off);
|
||||
for (let i = 0; i < n; i++) out[off + i] = data[off + i] ^ block[i];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- NIP-44 v2
|
||||
|
||||
export function nip44ConversationKey(priv, pubX) {
|
||||
return hkdfExtract(utf8('nip44-v2'), sharedX(priv, pubX));
|
||||
}
|
||||
|
||||
export function calcPaddedLen(len) {
|
||||
if (len < 1) throw new Error('empty message');
|
||||
if (len <= 32) return 32;
|
||||
const nextPower = 2 ** (Math.floor(Math.log2(len - 1)) + 1);
|
||||
const chunk = nextPower <= 256 ? 32 : nextPower / 8;
|
||||
return chunk * (Math.floor((len - 1) / chunk) + 1);
|
||||
}
|
||||
|
||||
function messageKeys(conversationKey, nonce) {
|
||||
const k = hkdfExpand(conversationKey, nonce, 76);
|
||||
return { chachaKey: k.subarray(0, 32), chachaNonce: k.subarray(32, 44), hmacKey: k.subarray(44, 76) };
|
||||
}
|
||||
|
||||
function pad(text) {
|
||||
const u = utf8(text);
|
||||
if (u.length < 1 || u.length > 65535) throw new Error('message must be 1 to 65535 bytes');
|
||||
const out = new Uint8Array(2 + calcPaddedLen(u.length));
|
||||
out[0] = u.length >> 8;
|
||||
out[1] = u.length & 255;
|
||||
out.set(u, 2);
|
||||
return out;
|
||||
}
|
||||
|
||||
function unpad(padded) {
|
||||
const len = (padded[0] << 8) | padded[1];
|
||||
if (len < 1 || padded.length !== 2 + calcPaddedLen(len)) throw new Error('bad padding');
|
||||
return fromUtf8(padded.subarray(2, 2 + len));
|
||||
}
|
||||
|
||||
export function nip44Encrypt(text, conversationKey, nonce = randomBytes(32)) {
|
||||
const { chachaKey, chachaNonce, hmacKey } = messageKeys(conversationKey, nonce);
|
||||
const ct = chacha20(chachaKey, chachaNonce, pad(text));
|
||||
const mac = hmacSha256(hmacKey, concat(nonce, ct));
|
||||
return base64(concat(Uint8Array.of(2), nonce, ct, mac));
|
||||
}
|
||||
|
||||
export function nip44Decrypt(payload, conversationKey) {
|
||||
if (typeof payload !== 'string' || payload.startsWith('#')) throw new Error('unsupported encryption version');
|
||||
if (payload.length < 132 || payload.length > 87472) throw new Error('bad payload size');
|
||||
const data = unbase64(payload);
|
||||
if (data.length < 99 || data.length > 65603) throw new Error('bad payload size');
|
||||
if (data[0] !== 2) throw new Error('unsupported encryption version');
|
||||
const nonce = data.subarray(1, 33);
|
||||
const ct = data.subarray(33, data.length - 32);
|
||||
const mac = data.subarray(data.length - 32);
|
||||
const { chachaKey, chachaNonce, hmacKey } = messageKeys(conversationKey, nonce);
|
||||
if (!equalBytes(hmacSha256(hmacKey, concat(nonce, ct)), mac)) throw new Error('bad mac');
|
||||
return unpad(chacha20(chachaKey, chachaNonce, ct));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- NIP-04 (for wallets)
|
||||
|
||||
// AES-256-CBC with the raw ECDH x as key. Only Nostr Wallet Connect still needs it, for wallets without NIP-44.
|
||||
async function aesKey(priv, pubX, usage) {
|
||||
return globalThis.crypto.subtle.importKey('raw', sharedX(priv, pubX), { name: 'AES-CBC' }, false, [usage]);
|
||||
}
|
||||
|
||||
export async function nip04Encrypt(text, priv, pubX, iv = randomBytes(16)) {
|
||||
const key = await aesKey(priv, pubX, 'encrypt');
|
||||
const ct = new Uint8Array(await globalThis.crypto.subtle.encrypt({ name: 'AES-CBC', iv }, key, utf8(text)));
|
||||
return base64(ct) + '?iv=' + base64(iv);
|
||||
}
|
||||
|
||||
export async function nip04Decrypt(payload, priv, pubX) {
|
||||
const [ct, ivPart] = String(payload).split('?iv=');
|
||||
if (!ivPart) throw new Error('not a NIP-04 payload');
|
||||
const key = await aesKey(priv, pubX, 'decrypt');
|
||||
const pt = await globalThis.crypto.subtle.decrypt({ name: 'AES-CBC', iv: unbase64(ivPart) }, key, unbase64(ct));
|
||||
return fromUtf8(new Uint8Array(pt));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Private notes (NIP-17): a kind-14 message, sealed by the sender (NIP-59 kind 13) and gift-wrapped (kind 1059) by a
|
||||
// throwaway key for each recipient, so relays see neither the words nor who sent them.
|
||||
|
||||
import { generatePrivateKey, nip44ConversationKey, nip44Encrypt, unhex } from './crypto.js';
|
||||
import { eventId, finalizeEvent, now, tagValue, verifyEvent } from './nostr.js';
|
||||
|
||||
const TWO_DAYS = 2 * 24 * 60 * 60;
|
||||
const jitter = () => now() - Math.floor(Math.random() * TWO_DAYS);
|
||||
|
||||
export function rumor(senderPubkey, recipients, text, extraTags = []) {
|
||||
const r = {
|
||||
kind: 14,
|
||||
pubkey: senderPubkey,
|
||||
created_at: now(),
|
||||
tags: [...recipients.map((p) => ['p', p]), ...extraTags],
|
||||
content: text,
|
||||
};
|
||||
r.id = eventId(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
// seal for one recipient (signed by the sender), then wrap it with a fresh key
|
||||
export async function wrapFor(signer, rumorEvent, recipient) {
|
||||
const seal = await signer.signEvent({
|
||||
kind: 13, created_at: jitter(), tags: [], content: await signer.nip44Encrypt(recipient, JSON.stringify(rumorEvent)),
|
||||
});
|
||||
const eph = generatePrivateKey();
|
||||
const content = nip44Encrypt(JSON.stringify(seal), nip44ConversationKey(eph, unhex(recipient)));
|
||||
return finalizeEvent({ kind: 1059, created_at: jitter(), tags: [['p', recipient]], content }, eph);
|
||||
}
|
||||
|
||||
// One wrap for the recipient and one for the sender's own inbox, so the note shows up on their other clients too
|
||||
export async function sealNote(signer, recipient, text) {
|
||||
const me = await signer.getPublicKey();
|
||||
const r = rumor(me, [recipient], text);
|
||||
const toThem = await wrapFor(signer, r, recipient);
|
||||
const toMe = recipient === me ? null : await wrapFor(signer, r, me);
|
||||
return { rumor: r, toThem, toMe };
|
||||
}
|
||||
|
||||
// Open a gift wrap addressed to us. Returns the rumor with its true sender, or null if anything doesn't check out.
|
||||
export async function openWrap(signer, wrap) {
|
||||
try {
|
||||
if (wrap.kind !== 1059 || !verifyEvent(wrap)) return null;
|
||||
const seal = JSON.parse(await signer.nip44Decrypt(wrap.pubkey, wrap.content));
|
||||
if (seal.kind !== 13 || !verifyEvent(seal)) return null;
|
||||
const r = JSON.parse(await signer.nip44Decrypt(seal.pubkey, seal.content));
|
||||
if (r.pubkey !== seal.pubkey) return null;
|
||||
if (r.kind !== 14 || typeof r.content !== 'string') return null;
|
||||
return { ...r, wrapId: wrap.id, to: tagValue(r, 'p') };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Nostr for SLYNG: bech32 and NIP-19 names, events (NIP-01), signers (a key in this page, or a NIP-07 extension),
|
||||
// a small relay pool with NIP-42 AUTH, and NIP-05 lookups. No dependencies.
|
||||
|
||||
import {
|
||||
utf8, hex, unhex, isHex64, sha256, schnorrSign, schnorrVerify, getPublicKey, isValidPublicKey,
|
||||
isValidPrivateKey, nip44ConversationKey, nip44Encrypt, nip44Decrypt,
|
||||
} from './crypto.js';
|
||||
|
||||
export const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- bech32
|
||||
|
||||
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
|
||||
|
||||
function polymod(values) {
|
||||
let chk = 1;
|
||||
for (const v of values) {
|
||||
const top = chk >>> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
||||
for (let i = 0; i < 5; i++) if ((top >>> i) & 1) chk ^= GEN[i];
|
||||
}
|
||||
return chk >>> 0;
|
||||
}
|
||||
|
||||
function hrpExpand(hrp) {
|
||||
const out = [];
|
||||
for (const ch of hrp) out.push(ch.charCodeAt(0) >>> 5);
|
||||
out.push(0);
|
||||
for (const ch of hrp) out.push(ch.charCodeAt(0) & 31);
|
||||
return out;
|
||||
}
|
||||
|
||||
function convertBits(data, from, to, pad) {
|
||||
let acc = 0, bits = 0;
|
||||
const out = [];
|
||||
const maxv = (1 << to) - 1;
|
||||
const maxAcc = (1 << (from + to - 1)) - 1;
|
||||
for (const value of data) {
|
||||
if (value < 0 || value >> from) throw new Error('bad bech32 data');
|
||||
acc = ((acc << from) | value) & maxAcc;
|
||||
bits += from;
|
||||
while (bits >= to) { bits -= to; out.push((acc >>> bits) & maxv); }
|
||||
}
|
||||
if (pad) {
|
||||
if (bits > 0) out.push((acc << (to - bits)) & maxv);
|
||||
} else if (bits >= from || ((acc << (to - bits)) & maxv)) {
|
||||
throw new Error('bad bech32 padding');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bech32Encode(hrp, bytes) {
|
||||
const data = convertBits(bytes, 8, 5, true);
|
||||
const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0];
|
||||
const pm = polymod(values) ^ 1;
|
||||
const check = [0, 1, 2, 3, 4, 5].map((i) => (pm >>> (5 * (5 - i))) & 31);
|
||||
return hrp + '1' + [...data, ...check].map((d) => CHARSET[d]).join('');
|
||||
}
|
||||
|
||||
export function bech32Decode(str) {
|
||||
if (typeof str !== 'string') throw new Error('not bech32');
|
||||
if (str !== str.toLowerCase() && str !== str.toUpperCase()) throw new Error('mixed case');
|
||||
str = str.toLowerCase();
|
||||
const pos = str.lastIndexOf('1');
|
||||
if (pos < 1 || pos + 7 > str.length) throw new Error('not bech32');
|
||||
const hrp = str.slice(0, pos);
|
||||
const data = [];
|
||||
for (const ch of str.slice(pos + 1)) {
|
||||
const d = CHARSET.indexOf(ch);
|
||||
if (d === -1) throw new Error('bad bech32 character');
|
||||
data.push(d);
|
||||
}
|
||||
if (polymod([...hrpExpand(hrp), ...data]) !== 1) throw new Error('bad bech32 checksum');
|
||||
return { hrp, bytes: Uint8Array.from(convertBits(data.slice(0, -6), 5, 8, false)) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- NIP-19
|
||||
|
||||
export const npubEncode = (pubHex) => bech32Encode('npub', unhex(pubHex));
|
||||
export const nsecEncode = (priv) => bech32Encode('nsec', priv);
|
||||
|
||||
function readTlv(bytes) {
|
||||
const out = {};
|
||||
for (let i = 0; i + 2 <= bytes.length;) {
|
||||
const t = bytes[i], l = bytes[i + 1];
|
||||
const v = bytes.subarray(i + 2, i + 2 + l);
|
||||
if (v.length !== l) throw new Error('bad tlv');
|
||||
(out[t] ||= []).push(v);
|
||||
i += 2 + l;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function nprofileEncode(pubHex, relays = []) {
|
||||
const parts = [Uint8Array.of(0, 32), unhex(pubHex)];
|
||||
for (const r of relays) { const u = utf8(r); parts.push(Uint8Array.of(1, u.length), u); }
|
||||
let n = 0; for (const p of parts) n += p.length;
|
||||
const all = new Uint8Array(n); let o = 0; for (const p of parts) { all.set(p, o); o += p.length; }
|
||||
return bech32Encode('nprofile', all);
|
||||
}
|
||||
|
||||
// Any way of naming a person: hex, npub, nprofile, or nostr: links to them. NIP-05 names need resolveNip05.
|
||||
export function parsePerson(input) {
|
||||
let s = String(input || '').trim();
|
||||
if (s.startsWith('nostr:')) s = s.slice(6);
|
||||
if (isHex64(s.toLowerCase())) return { pubkey: s.toLowerCase(), relays: [] };
|
||||
if (/^(npub|nprofile)1/i.test(s)) {
|
||||
const { hrp, bytes } = bech32Decode(s);
|
||||
if (hrp === 'npub' && bytes.length === 32) return { pubkey: hex(bytes), relays: [] };
|
||||
if (hrp === 'nprofile') {
|
||||
const tlv = readTlv(bytes);
|
||||
const pk = tlv[0]?.[0];
|
||||
if (!pk || pk.length !== 32) throw new Error('bad nprofile');
|
||||
const relays = (tlv[1] || []).map((r) => new TextDecoder().decode(r));
|
||||
return { pubkey: hex(pk), relays };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSecret(input) {
|
||||
const s = String(input || '').trim();
|
||||
let bytes = null;
|
||||
if (/^nsec1/i.test(s)) {
|
||||
const d = bech32Decode(s);
|
||||
if (d.hrp === 'nsec') bytes = d.bytes;
|
||||
} else if (isHex64(s.toLowerCase())) {
|
||||
bytes = unhex(s.toLowerCase());
|
||||
}
|
||||
if (!bytes || !isValidPrivateKey(bytes)) throw new Error('That is not a nostr secret key (nsec1… or 64 hex characters).');
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export const shortNpub = (pubHex) => { const n = npubEncode(pubHex); return n.slice(0, 10) + '…' + n.slice(-4); };
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- events
|
||||
|
||||
export function serializeEvent(e) {
|
||||
return JSON.stringify([0, e.pubkey, e.created_at, e.kind, e.tags, e.content]);
|
||||
}
|
||||
|
||||
export const eventId = (e) => hex(sha256(utf8(serializeEvent(e))));
|
||||
|
||||
export function finalizeEvent(template, priv) {
|
||||
const e = {
|
||||
kind: template.kind,
|
||||
created_at: template.created_at ?? now(),
|
||||
tags: template.tags || [],
|
||||
content: template.content ?? '',
|
||||
pubkey: hex(getPublicKey(priv)),
|
||||
};
|
||||
e.id = eventId(e);
|
||||
e.sig = hex(schnorrSign(unhex(e.id), priv));
|
||||
return e;
|
||||
}
|
||||
|
||||
export function verifyEvent(e) {
|
||||
try {
|
||||
if (!e || !isHex64(e.id) || !isHex64(e.pubkey) || typeof e.sig !== 'string' || e.sig.length !== 128) return false;
|
||||
if (!Number.isInteger(e.kind) || !Number.isInteger(e.created_at) || !Array.isArray(e.tags) || typeof e.content !== 'string') return false;
|
||||
if (eventId(e) !== e.id) return false;
|
||||
return schnorrVerify(unhex(e.sig), unhex(e.id), unhex(e.pubkey));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const tagValue = (e, name) => (e.tags || []).find((t) => t[0] === name)?.[1];
|
||||
export const tagValues = (e, name) => (e.tags || []).filter((t) => t[0] === name).map((t) => t[1]);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- signers
|
||||
|
||||
// A key that lives in this page
|
||||
export class LocalSigner {
|
||||
constructor(priv) {
|
||||
if (!isValidPrivateKey(priv)) throw new Error('bad private key');
|
||||
this.priv = priv;
|
||||
this.pubkey = hex(getPublicKey(priv));
|
||||
this.kind = 'local';
|
||||
this.keys = new Map();
|
||||
}
|
||||
async getPublicKey() { return this.pubkey; }
|
||||
async signEvent(template) { return finalizeEvent(template, this.priv); }
|
||||
conversationKey(pubHex) {
|
||||
let k = this.keys.get(pubHex);
|
||||
if (!k) { k = nip44ConversationKey(this.priv, unhex(pubHex)); this.keys.set(pubHex, k); }
|
||||
return k;
|
||||
}
|
||||
async nip44Encrypt(pubHex, text) { return nip44Encrypt(text, this.conversationKey(pubHex)); }
|
||||
async nip44Decrypt(pubHex, payload) { return nip44Decrypt(payload, this.conversationKey(pubHex)); }
|
||||
get canEncrypt() { return true; }
|
||||
}
|
||||
|
||||
// A NIP-07 browser extension (Alby, nos2x, Nostash…): the key never touches this page
|
||||
export class ExtensionSigner {
|
||||
constructor(ext, pubkey) {
|
||||
this.ext = ext;
|
||||
this.pubkey = pubkey;
|
||||
this.kind = 'extension';
|
||||
}
|
||||
static async connect(ext = globalThis.nostr) {
|
||||
if (!ext?.getPublicKey) throw new Error('No nostr extension found in this browser.');
|
||||
const pubkey = String(await ext.getPublicKey()).toLowerCase();
|
||||
if (!isHex64(pubkey) || !isValidPublicKey(unhex(pubkey))) throw new Error('The extension gave back a key that is not a nostr public key.');
|
||||
return new ExtensionSigner(ext, pubkey);
|
||||
}
|
||||
async getPublicKey() { return this.pubkey; }
|
||||
async signEvent(template) {
|
||||
const e = await this.ext.signEvent({
|
||||
kind: template.kind, created_at: template.created_at ?? now(), tags: template.tags || [],
|
||||
content: template.content ?? '',
|
||||
});
|
||||
if (!verifyEvent(e) || e.pubkey !== this.pubkey) throw new Error('The extension returned an event that does not verify.');
|
||||
return e;
|
||||
}
|
||||
get canEncrypt() { return Boolean(this.ext.nip44?.encrypt && this.ext.nip44?.decrypt); }
|
||||
async nip44Encrypt(pubHex, text) {
|
||||
if (!this.canEncrypt) throw new Error('This extension cannot encrypt private notes (it has no NIP-44).');
|
||||
return this.ext.nip44.encrypt(pubHex, text);
|
||||
}
|
||||
async nip44Decrypt(pubHex, payload) {
|
||||
if (!this.canEncrypt) throw new Error('This extension cannot open private notes (it has no NIP-44).');
|
||||
return this.ext.nip44.decrypt(pubHex, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- relays
|
||||
|
||||
let subCounter = 0;
|
||||
const nextSubId = () => 's' + (++subCounter).toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
|
||||
export function normalizeRelay(url) {
|
||||
try {
|
||||
const u = new URL(String(url).trim());
|
||||
if (u.protocol !== 'wss:' && u.protocol !== 'ws:') return null;
|
||||
u.hash = '';
|
||||
let s = u.toString();
|
||||
if (s.endsWith('/') && u.pathname === '/') s = s.slice(0, -1);
|
||||
return s.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// One relay connection. Reconnects on demand; answers AUTH challenges with the pool's signer.
|
||||
class Relay {
|
||||
constructor(url, pool) {
|
||||
this.url = url;
|
||||
this.pool = pool;
|
||||
this.ws = null;
|
||||
this.ready = null;
|
||||
this.subs = new Map();
|
||||
this.pending = new Map();
|
||||
this.authed = false;
|
||||
this.challenge = null;
|
||||
this.waitingAuth = [];
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1)) return this.ready;
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
let ws;
|
||||
try { ws = new WebSocket(this.url); } catch (err) { reject(err); return; }
|
||||
this.ws = ws;
|
||||
const timer = setTimeout(() => { reject(new Error('timed out connecting to ' + this.url)); try { ws.close(); } catch {} }, 8000);
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
for (const [id, s] of this.subs) this.send(['REQ', id, ...s.filters]);
|
||||
};
|
||||
ws.onerror = () => { clearTimeout(timer); reject(new Error('could not reach ' + this.url)); };
|
||||
ws.onclose = () => {
|
||||
clearTimeout(timer);
|
||||
this.authed = false;
|
||||
for (const [, p] of this.pending) p.resolve({ ok: false, message: 'connection closed' });
|
||||
this.pending.clear();
|
||||
};
|
||||
ws.onmessage = (m) => this.onMessage(m.data);
|
||||
});
|
||||
this.ready.catch(() => {});
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
send(msg) {
|
||||
if (this.ws?.readyState === 1) this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
async onMessage(raw) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
if (!Array.isArray(msg)) return;
|
||||
const [type, a, b, c] = msg;
|
||||
if (type === 'EVENT') {
|
||||
const s = this.subs.get(a);
|
||||
if (s && b && typeof b === 'object') s.onevent(b, this.url);
|
||||
} else if (type === 'EOSE') {
|
||||
this.subs.get(a)?.oneose?.(this.url);
|
||||
} else if (type === 'OK') {
|
||||
const p = this.pending.get(a);
|
||||
if (p) {
|
||||
this.pending.delete(a);
|
||||
if (!b && String(c || '').startsWith('auth-required') && p.event && !p.retried && this.pool.signer) {
|
||||
p.retried = true;
|
||||
this.waitingAuth.push(() => this.publish(p.event).then(p.resolve));
|
||||
await this.authenticate();
|
||||
} else {
|
||||
p.resolve({ ok: Boolean(b), message: String(c || '') });
|
||||
}
|
||||
}
|
||||
} else if (type === 'CLOSED') {
|
||||
const s = this.subs.get(a);
|
||||
if (s && String(b || '').startsWith('auth-required') && this.pool.signer && !s.retried) {
|
||||
s.retried = true;
|
||||
this.waitingAuth.push(() => this.send(['REQ', a, ...s.filters]));
|
||||
await this.authenticate();
|
||||
} else if (s) {
|
||||
s.oneose?.(this.url);
|
||||
}
|
||||
} else if (type === 'AUTH' && typeof a === 'string') {
|
||||
this.challenge = a;
|
||||
if (this.waitingAuth.length) await this.authenticate();
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate() {
|
||||
if (!this.challenge || !this.pool.signer || this.authing) return;
|
||||
this.authing = true;
|
||||
try {
|
||||
const ev = await this.pool.signer.signEvent({
|
||||
kind: 22242, tags: [['relay', this.url], ['challenge', this.challenge]], content: '',
|
||||
});
|
||||
const res = await new Promise((resolve) => {
|
||||
const t = setTimeout(() => { this.pending.delete(ev.id); resolve({ ok: false, message: 'auth timed out' }); }, 6000);
|
||||
this.pending.set(ev.id, { resolve: (r) => { clearTimeout(t); resolve(r); } });
|
||||
this.send(['AUTH', ev]);
|
||||
});
|
||||
this.authed = res.ok;
|
||||
const queued = this.waitingAuth.splice(0);
|
||||
if (res.ok) for (const fn of queued) fn();
|
||||
} catch {
|
||||
this.waitingAuth.length = 0;
|
||||
} finally {
|
||||
this.authing = false;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(filters, handlers) {
|
||||
const id = nextSubId();
|
||||
this.subs.set(id, { filters, ...handlers });
|
||||
this.connect().then(() => {}, () => handlers.oneose?.(this.url));
|
||||
if (this.ws?.readyState === 1) this.send(['REQ', id, ...filters]);
|
||||
return () => {
|
||||
this.subs.delete(id);
|
||||
this.send(['CLOSE', id]);
|
||||
};
|
||||
}
|
||||
|
||||
publish(event) {
|
||||
return new Promise((resolve) => {
|
||||
const done = (r) => { clearTimeout(t); resolve(r); };
|
||||
const t = setTimeout(() => { this.pending.delete(event.id); resolve({ ok: false, message: 'no answer' }); }, 8000);
|
||||
this.connect().then(() => {
|
||||
this.pending.set(event.id, { resolve: done, event });
|
||||
this.send(['EVENT', event]);
|
||||
}, (err) => done({ ok: false, message: err.message }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Pool {
|
||||
constructor({ signer = null, verify = true } = {}) {
|
||||
this.relays = new Map();
|
||||
this.signer = signer;
|
||||
this.verify = verify;
|
||||
}
|
||||
relay(url) {
|
||||
const u = normalizeRelay(url);
|
||||
if (!u) return null;
|
||||
let r = this.relays.get(u);
|
||||
if (!r) { r = new Relay(u, this); this.relays.set(u, r); }
|
||||
return r;
|
||||
}
|
||||
|
||||
// Subscribe on several relays at once: each event once, verified; oneose after every relay finished (or 6 s)
|
||||
subscribe(urls, filters, { onevent, oneose, verify = this.verify } = {}) {
|
||||
const seen = new Set();
|
||||
const relays = [...new Set(urls.map(normalizeRelay).filter(Boolean))].map((u) => this.relay(u));
|
||||
let left = relays.length;
|
||||
let eosed = false;
|
||||
const finish = () => { if (!eosed) { eosed = true; oneose?.(); } };
|
||||
const timer = setTimeout(finish, 6000);
|
||||
if (!left) { clearTimeout(timer); setTimeout(finish, 0); }
|
||||
const closers = relays.map((r) => r.subscribe(filters, {
|
||||
onevent: (e, from) => {
|
||||
if (!e?.id || seen.has(e.id)) return;
|
||||
seen.add(e.id);
|
||||
if (verify && !verifyEvent(e)) return;
|
||||
onevent?.(e, from);
|
||||
},
|
||||
oneose: () => { left -= 1; if (left <= 0) { clearTimeout(timer); finish(); } },
|
||||
}));
|
||||
return { close: () => { clearTimeout(timer); closers.forEach((c) => c()); } };
|
||||
}
|
||||
|
||||
// Everything matching, gathered until every relay says EOSE
|
||||
query(urls, filters, { timeout = 6000 } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const out = [];
|
||||
let sub = null;
|
||||
const t = setTimeout(() => { sub?.close(); resolve(out); }, timeout);
|
||||
sub = this.subscribe(urls, filters, {
|
||||
onevent: (e) => out.push(e),
|
||||
oneose: () => { clearTimeout(t); sub?.close(); resolve(out); },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async publish(urls, event) {
|
||||
const relays = [...new Set(urls.map(normalizeRelay).filter(Boolean))].map((u) => this.relay(u));
|
||||
const results = await Promise.all(relays.map((r) => r.publish(event).then((res) => ({ url: r.url, ...res }))));
|
||||
return { ok: results.some((r) => r.ok), results };
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const r of this.relays.values()) try { r.ws?.close(); } catch {}
|
||||
this.relays.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- profiles
|
||||
|
||||
export function parseProfile(e) {
|
||||
let p = {};
|
||||
try { p = JSON.parse(e.content) || {}; } catch {}
|
||||
const str = (v, n = 200) => (typeof v === 'string' ? v.slice(0, n) : '');
|
||||
return {
|
||||
pubkey: e.pubkey,
|
||||
name: str(p.display_name, 64) || str(p.displayName, 64) || str(p.name, 64),
|
||||
handle: str(p.name, 64),
|
||||
picture: /^https:\/\//.test(p.picture || '') ? str(p.picture, 500) : '',
|
||||
lud16: str(p.lud16, 200),
|
||||
lud06: str(p.lud06, 2000),
|
||||
nip05: str(p.nip05, 200),
|
||||
about: str(p.about, 280),
|
||||
created_at: e.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
// newest replaceable event per author
|
||||
export function latestBy(events, key = (e) => e.pubkey) {
|
||||
const m = new Map();
|
||||
for (const e of events) {
|
||||
const k = key(e);
|
||||
const cur = m.get(k);
|
||||
if (!cur || e.created_at > cur.created_at) m.set(k, e);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
export function relayListFrom(e) {
|
||||
const read = [], write = [];
|
||||
for (const t of e?.tags || []) {
|
||||
if (t[0] !== 'r' || !normalizeRelay(t[1])) continue;
|
||||
const u = normalizeRelay(t[1]);
|
||||
if (!t[2] || t[2] === 'read') read.push(u);
|
||||
if (!t[2] || t[2] === 'write') write.push(u);
|
||||
}
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
export const dmRelaysFrom = (e) => (e?.tags || []).filter((t) => t[0] === 'relay').map((t) => normalizeRelay(t[1])).filter(Boolean);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- NIP-05
|
||||
|
||||
export async function resolveNip05(name, fetcher = globalThis.fetch) {
|
||||
const m = /^([a-z0-9._-]+)@([a-z0-9.-]+\.[a-z]{2,})$/i.exec(String(name || '').trim());
|
||||
if (!m) return null;
|
||||
const [, local, domain] = m;
|
||||
const res = await fetcher(`https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(local.toLowerCase())}`);
|
||||
if (!res.ok) throw new Error(`${domain} did not answer for ${local}`);
|
||||
const json = await res.json();
|
||||
const pk = json?.names?.[local.toLowerCase()] || json?.names?.[local];
|
||||
if (!isHex64(String(pk || '').toLowerCase())) throw new Error(`${name} is not a nostr name`);
|
||||
const pubkey = pk.toLowerCase();
|
||||
const relays = (json.relays?.[pubkey] || []).map(normalizeRelay).filter(Boolean);
|
||||
return { pubkey, relays };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// The sender's own wallet: WebLN (a lightning browser extension such as Alby) or Nostr Wallet Connect (NIP-47).
|
||||
// Each wallet does one thing here: pay an invoice SLYNG has already checked.
|
||||
|
||||
import { unhex, hex, getPublicKey, isHex64, isValidPrivateKey, nip04Encrypt, nip04Decrypt, nip44ConversationKey, nip44Encrypt, nip44Decrypt } from './crypto.js';
|
||||
import { finalizeEvent, normalizeRelay, now } from './nostr.js';
|
||||
|
||||
export class WebLNWallet {
|
||||
constructor(provider) { this.provider = provider; this.kind = 'webln'; this.label = 'Browser wallet'; }
|
||||
static async connect(provider = globalThis.webln) {
|
||||
if (!provider) throw new Error('No lightning wallet extension found in this browser.');
|
||||
await provider.enable?.();
|
||||
const w = new WebLNWallet(provider);
|
||||
try { const info = await provider.getInfo?.(); if (info?.node?.alias) w.label = info.node.alias; } catch {}
|
||||
return w;
|
||||
}
|
||||
async pay(invoice) {
|
||||
const r = await this.provider.sendPayment(invoice);
|
||||
if (!r?.preimage) throw new Error('Your wallet did not confirm the payment.');
|
||||
return { preimage: r.preimage };
|
||||
}
|
||||
}
|
||||
|
||||
export function parseNwcUri(uri) {
|
||||
let u;
|
||||
try { u = new URL(String(uri).trim().replace(/^nostrwalletconnect:\/\//i, 'nostr+walletconnect://')); } catch { u = null; }
|
||||
if (!u || !/^nostr\+walletconnect:$/i.test(u.protocol)) {
|
||||
throw new Error('That is not a Nostr Wallet Connect string (it starts with nostr+walletconnect://).');
|
||||
}
|
||||
const wallet = (u.hostname || u.pathname.replace(/^\/+/, '')).toLowerCase();
|
||||
const relays = u.searchParams.getAll('relay').map(normalizeRelay).filter(Boolean);
|
||||
const secret = (u.searchParams.get('secret') || '').toLowerCase();
|
||||
if (!isHex64(wallet) || !relays.length || !isHex64(secret) || !isValidPrivateKey(unhex(secret))) {
|
||||
throw new Error('That wallet connection is missing its wallet key, relay or secret.');
|
||||
}
|
||||
return { wallet, relays, secret: unhex(secret), lud16: u.searchParams.get('lud16') || '' };
|
||||
}
|
||||
|
||||
export class NwcWallet {
|
||||
constructor(conn, pool) {
|
||||
this.conn = conn;
|
||||
this.pool = pool;
|
||||
this.kind = 'nwc';
|
||||
this.client = hex(getPublicKey(conn.secret));
|
||||
this.encryption = 'nip04';
|
||||
this.label = conn.lud16 || 'Wallet Connect';
|
||||
}
|
||||
|
||||
static async connect(uri, pool) {
|
||||
const w = new NwcWallet(parseNwcUri(uri), pool);
|
||||
const info = await pool.query(w.conn.relays, [{ kinds: [13194], authors: [w.conn.wallet], limit: 1 }], { timeout: 7000 });
|
||||
if (!info.length) throw new Error('The wallet did not answer on its relay. Check the connection string, or that the wallet is online.');
|
||||
const latest = info.sort((a, b) => b.created_at - a.created_at)[0];
|
||||
const methods = String(latest.content).split(/[\s,]+/);
|
||||
if (!methods.includes('pay_invoice')) throw new Error('This wallet connection is not allowed to pay invoices.');
|
||||
const enc = latest.tags.find((t) => t[0] === 'encryption')?.[1] || '';
|
||||
if (enc.split(/\s+/).includes('nip44_v2')) w.encryption = 'nip44';
|
||||
return w;
|
||||
}
|
||||
|
||||
async encrypt(text) {
|
||||
if (this.encryption === 'nip44') return nip44Encrypt(text, nip44ConversationKey(this.conn.secret, unhex(this.conn.wallet)));
|
||||
return nip04Encrypt(text, this.conn.secret, unhex(this.conn.wallet));
|
||||
}
|
||||
|
||||
async decrypt(payload) {
|
||||
if (this.encryption === 'nip44' && !payload.includes('?iv=')) {
|
||||
return nip44Decrypt(payload, nip44ConversationKey(this.conn.secret, unhex(this.conn.wallet)));
|
||||
}
|
||||
return nip04Decrypt(payload, this.conn.secret, unhex(this.conn.wallet));
|
||||
}
|
||||
|
||||
async request(method, params, timeout = 60000) {
|
||||
const tags = [['p', this.conn.wallet]];
|
||||
if (this.encryption === 'nip44') tags.push(['encryption', 'nip44_v2']);
|
||||
const ev = finalizeEvent({
|
||||
kind: 23194, created_at: now(), tags, content: await this.encrypt(JSON.stringify({ method, params })),
|
||||
}, this.conn.secret);
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const sub = this.pool.subscribe(this.conn.relays, [{ kinds: [23195], authors: [this.conn.wallet], '#e': [ev.id] }], {
|
||||
onevent: async (e) => {
|
||||
if (settled) return;
|
||||
try {
|
||||
const body = JSON.parse(await this.decrypt(e.content));
|
||||
settled = true;
|
||||
clearTimeout(t);
|
||||
sub.close();
|
||||
if (body.error) reject(new Error(body.error.message || body.error.code || 'Your wallet refused.'));
|
||||
else resolve(body.result);
|
||||
} catch {}
|
||||
},
|
||||
});
|
||||
const t = setTimeout(() => { if (!settled) { settled = true; sub.close(); reject(new Error('Your wallet did not answer in time.')); } }, timeout);
|
||||
this.pool.publish(this.conn.relays, ev).then((r) => {
|
||||
if (!r.ok && !settled) { settled = true; clearTimeout(t); sub.close(); reject(new Error('Could not reach your wallet\'s relay.')); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async pay(invoice) {
|
||||
const r = await this.request('pay_invoice', { invoice });
|
||||
if (!r?.preimage) throw new Error('Your wallet did not confirm the payment.');
|
||||
return { preimage: r.preimage };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// Sats over lightning: lightning addresses (LUD-16), LNURL-pay (LUD-06), and zaps (NIP-57).
|
||||
// The page asks the recipient's lightning address server for an invoice, checks that the invoice is for exactly the
|
||||
// amount asked, and hands it to the sender's own wallet. SLYNG never holds funds.
|
||||
|
||||
import { bech32Decode, bech32Encode, now, tagValue, verifyEvent } from './nostr.js';
|
||||
import { utf8 } from './crypto.js';
|
||||
|
||||
export function lightningAddressUrl(address) {
|
||||
const m = /^([a-z0-9._+-]+)@([a-z0-9.-]+\.[a-z]{2,}|localhost(:\d+)?|127\.0\.0\.1(:\d+)?)$/i.exec(String(address || '').trim());
|
||||
if (!m) return null;
|
||||
const [, name, domain] = m;
|
||||
const local = /^(localhost|127\.0\.0\.1)/.test(domain);
|
||||
return `${local ? 'http' : 'https'}://${domain}/.well-known/lnurlp/${name.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export const lnurlEncode = (url) => bech32Encode('lnurl', utf8(url)).toUpperCase();
|
||||
|
||||
export function lnurlDecode(lnurl) {
|
||||
const { hrp, bytes } = bech32Decode(String(lnurl).replace(/^lightning:/i, ''));
|
||||
if (hrp !== 'lnurl') throw new Error('not an lnurl');
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
// Where to ask for an invoice, from a profile's lud16 (preferred) or lud06
|
||||
export function payUrlFor(profile) {
|
||||
if (profile?.lud16) {
|
||||
const u = lightningAddressUrl(profile.lud16);
|
||||
if (u) return u;
|
||||
}
|
||||
if (profile?.lud06) {
|
||||
try { return lnurlDecode(profile.lud06); } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchPayEndpoint(url, fetcher = globalThis.fetch) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetcher(url, { headers: { accept: 'application/json' } });
|
||||
} catch {
|
||||
throw new Error('Their lightning address server did not answer.');
|
||||
}
|
||||
if (!res.ok) throw new Error(`Their lightning address server answered ${res.status}.`);
|
||||
const j = await res.json();
|
||||
if (j?.status === 'ERROR') throw new Error(j.reason || 'Their lightning address refused.');
|
||||
if (j?.tag !== 'payRequest' || typeof j.callback !== 'string') throw new Error('Their lightning address is not set up to receive payments.');
|
||||
return {
|
||||
url,
|
||||
callback: j.callback,
|
||||
min: Number(j.minSendable) || 1000,
|
||||
max: Number(j.maxSendable) || 0,
|
||||
allowsNostr: j.allowsNostr === true && typeof j.nostrPubkey === 'string' && /^[0-9a-f]{64}$/.test(j.nostrPubkey),
|
||||
nostrPubkey: j.nostrPubkey,
|
||||
commentAllowed: Number(j.commentAllowed) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function zapRequestTemplate({ recipient, msats, relays, lnurl, comment = '', eventId = null }) {
|
||||
const tags = [['relays', ...relays.slice(0, 8)], ['amount', String(msats)], ['lnurl', lnurl], ['p', recipient]];
|
||||
if (eventId) tags.push(['e', eventId]);
|
||||
tags.push(['client', 'slyng']);
|
||||
return { kind: 9734, created_at: now(), content: comment, tags };
|
||||
}
|
||||
|
||||
// The amount a BOLT-11 invoice asks for, in millisats (null if it names none)
|
||||
export function invoiceMsats(pr) {
|
||||
const s = String(pr || '').toLowerCase().replace(/^lightning:/, '');
|
||||
const sep = s.lastIndexOf('1');
|
||||
if (!s.startsWith('ln') || sep < 0) return null;
|
||||
const hrp = s.slice(0, sep);
|
||||
const m = /^ln(bcrt|bc|tbs|tb|sb)(\d+)([munp]?)$/.exec(hrp);
|
||||
if (!m) return null;
|
||||
const [, , digits, mult] = m;
|
||||
const n = BigInt(digits);
|
||||
const msats = { '': n * 100000000000n, m: n * 100000000n, u: n * 100000n, n: n * 100n, p: n / 10n }[mult];
|
||||
if (mult === 'p' && n % 10n) return null;
|
||||
return Number(msats);
|
||||
}
|
||||
|
||||
export async function fetchInvoice(endpoint, msats, zapRequest = null, fetcher = globalThis.fetch) {
|
||||
if (msats < endpoint.min || (endpoint.max && msats > endpoint.max)) {
|
||||
const lo = Math.ceil(endpoint.min / 1000), hi = Math.floor(endpoint.max / 1000);
|
||||
throw new Error(`Their lightning address takes ${lo.toLocaleString('en-US')} to ${hi.toLocaleString('en-US')} sats at a time.`);
|
||||
}
|
||||
const u = new URL(endpoint.callback);
|
||||
u.searchParams.set('amount', String(msats));
|
||||
if (zapRequest) {
|
||||
u.searchParams.set('nostr', JSON.stringify(zapRequest));
|
||||
u.searchParams.set('lnurl', lnurlEncode(endpoint.url));
|
||||
}
|
||||
let res;
|
||||
try { res = await fetcher(u.toString(), { headers: { accept: 'application/json' } }); } catch {
|
||||
throw new Error('Their lightning address server did not answer.');
|
||||
}
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (j?.status === 'ERROR' || !j?.pr) throw new Error(j?.reason || 'Their lightning address did not send an invoice.');
|
||||
const asked = invoiceMsats(j.pr);
|
||||
if (asked !== msats) throw new Error('Their server sent an invoice for a different amount, so nothing was paid.');
|
||||
return j.pr;
|
||||
}
|
||||
|
||||
// A zap receipt (kind 9735): who zapped whom, how much, and the note in the request
|
||||
export function readZapReceipt(e) {
|
||||
try {
|
||||
const desc = JSON.parse(tagValue(e, 'description') || 'null');
|
||||
if (!desc || desc.kind !== 9734 || !verifyEvent(desc)) return null;
|
||||
const bolt11 = tagValue(e, 'bolt11');
|
||||
const msats = invoiceMsats(bolt11);
|
||||
const requested = Number(tagValue(desc, 'amount') || 0);
|
||||
if (msats == null || (requested && requested !== msats)) return null;
|
||||
return {
|
||||
id: e.id,
|
||||
from: tagValue(e, 'P') || desc.pubkey,
|
||||
to: tagValue(e, 'p'),
|
||||
sats: Math.floor(msats / 1000),
|
||||
comment: desc.content || '',
|
||||
at: e.created_at,
|
||||
requestId: desc.id,
|
||||
server: e.pubkey,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# SLYNG
|
||||
|
||||
> Sling sats and notes on nostr: aim at someone you follow, pull back for more, let go to send. Sats go as zaps
|
||||
> (NIP-57) from the sender's own wallet; notes go private and gift-wrapped (NIP-17). No account, no server.
|
||||
|
||||
An agent can aim; only a person lets go. To aim, make a link:
|
||||
https://slyng.yolopreneur.lol/#to=<npub, nprofile, hex key or name@domain>&sats=<1 to 21000>¬e=<URL-encoded text>
|
||||
|
||||
- [SLYNG](https://slyng.yolopreneur.lol/): the slingshot, in the browser
|
||||
- [skill.md](https://slyng.yolopreneur.lol/skill.md): how to make a SLYNG link, or sling by yourself with your own key and wallet
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "slyng",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Sling sats and notes on nostr. A static venture page on yolopreneur.lol.",
|
||||
"scripts": { "test": "node --test tests/*.test.js" }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: slyng
|
||||
description: Sling sats and private notes to someone on nostr, the way SLYNG does. Either make a SLYNG link that a person opens, checks and lets go of, or, with your own nostr key and wallet, send the zap and the note yourself.
|
||||
---
|
||||
|
||||
# SLYNG, for agents
|
||||
|
||||
SLYNG (https://slyng.yolopreneur.lol/) is a slingshot for nostr. A person aims at someone they follow, pulls back
|
||||
for more sats, and lets go: the sats go as a zap (NIP-57) from their own wallet, and the note goes as a private,
|
||||
gift-wrapped message (NIP-17). An agent can aim. Only a person lets go.
|
||||
|
||||
## 1. Make a link (the usual way)
|
||||
|
||||
Give the person a link that opens SLYNG aimed at someone, with the note tied on and the amount marked on the band:
|
||||
|
||||
```
|
||||
https://slyng.yolopreneur.lol/#to=<who>&sats=<how many>¬e=<what to say>
|
||||
```
|
||||
|
||||
- `to`: an `npub1…`, an `nprofile1…`, a 64-character hex public key, or a NIP-05 name such as `ben@example.com`.
|
||||
- `sats`: a whole number from 1 to 21,000. The band has rings at 21, 100, 210, 1,000, 2,100, 5,000, 10,000 and
|
||||
21,000 sats; SLYNG marks the ring closest to what you ask and says so if it isn't exact. Leave it out for a note on
|
||||
its own.
|
||||
- `note`: the words, URL-encoded, up to 1,000 characters. Leave it out to send sats on their own.
|
||||
|
||||
Example, for 2,100 sats and a thank-you to ben@example.com:
|
||||
|
||||
```
|
||||
https://slyng.yolopreneur.lol/#to=ben%40example.com&sats=2100¬e=thanks%20for%20the%20records
|
||||
```
|
||||
|
||||
Nothing is sent when the link opens. The person reads it, pulls back to the marked ring and lets go, or doesn't.
|
||||
Tell them what the link does in one line when you hand it over.
|
||||
|
||||
## 2. Sling by yourself (when you have your own key and wallet)
|
||||
|
||||
Only when the person you work for asked you to send sats, and only the amount they named.
|
||||
|
||||
1. **Find them.** Fetch the recipient's kind 0 (profile: `lud16` or `lud06`), kind 10050 (relays for private
|
||||
messages) and kind 10002 (relay list) from a few relays: wss://relay.damus.io, wss://nos.lol,
|
||||
wss://relay.primal.net. Verify every event's id and signature before you use it; a forged profile with someone
|
||||
else's lightning address is how sats get stolen.
|
||||
2. **Get an invoice (NIP-57).** `lud16` `name@domain` means `GET https://domain/.well-known/lnurlp/name`. If the answer
|
||||
has `allowsNostr: true` and a `nostrPubkey`, sign a kind 9734 zap request with tags
|
||||
`["relays", …]`, `["amount", "<millisats>"]`, `["lnurl", "<bech32 lnurl of that URL>"]`, `["p", "<their hex key>"]`
|
||||
and `["client", "slyng"]`, then `GET <callback>?amount=<millisats>&nostr=<the zap request as JSON, URL-encoded>&lnurl=<lnurl>`.
|
||||
Without `allowsNostr`, call the callback with `amount` alone (a plain lightning payment, no receipt).
|
||||
3. **Check the invoice.** The amount in the invoice's prefix must equal what you asked for (`lnbc2100n1…` is
|
||||
210,000 millisats, 210 sats). If it doesn't, pay nothing.
|
||||
4. **Pay** from your own wallet, for example with Nostr Wallet Connect (NIP-47 `pay_invoice`).
|
||||
5. **Send the note (NIP-17).** Make a kind 14 message (not signed) with a `["p", "<their key>"]` tag. Seal it: a kind 13
|
||||
event, signed by you, whose content is the message encrypted to them with NIP-44. Wrap it: a kind 1059 event,
|
||||
signed by a one-time key, whose content is the seal encrypted to them with NIP-44, with a `["p", "<their key>"]` tag.
|
||||
Give both the seal and the wrap a `created_at` up to two days in the past. Publish the wrap to their kind 10050
|
||||
relays, and a second wrap addressed to yourself to your own.
|
||||
6. **Watch for the catch.** Their lightning address server publishes a kind 9735 receipt that tags them (`p`) and you
|
||||
(`P`); its `description` is your zap request.
|
||||
|
||||
House rules: a sling lands whole or not at all, so pay first and send the note only if the payment went through.
|
||||
Zaps are public (anyone can see who sent how much to whom); notes are not. Never put anything private in a zap
|
||||
request's content.
|
||||
|
||||
## Files
|
||||
|
||||
- https://slyng.yolopreneur.lol/ : the slingshot
|
||||
- https://slyng.yolopreneur.lol/skill.md : this file
|
||||
- https://slyng.yolopreneur.lol/llms.txt : the list, for agents
|
||||
@@ -0,0 +1,413 @@
|
||||
// The slingshot. The pull is a polar coordinate around the fork: its angle picks who, its length picks how much.
|
||||
// Pure geometry first (exported for the tests), then the SVG component that draws and animates it.
|
||||
|
||||
export const LADDER = [0, 21, 100, 210, 1000, 2100, 5000, 10000, 21000];
|
||||
export const GEOM = { w: 420, h: 470, top: 34, rx: 210, ry: 300, dead: 22, max: 118, fence: 210, grab: 70, tip: 46 };
|
||||
|
||||
export const fmt = (n) => Number(n).toLocaleString('en-US');
|
||||
|
||||
export function ringRadius(i, g = GEOM) {
|
||||
return g.dead + ((g.max - g.dead) * i) / (LADDER.length - 1);
|
||||
}
|
||||
|
||||
// which step of the ladder a stretch reaches; -1 is slack (nothing happens)
|
||||
export function detentFor(stretch, g = GEOM) {
|
||||
if (!(stretch >= g.dead)) return -1;
|
||||
const t = (Math.min(stretch, g.max) - g.dead) / (g.max - g.dead);
|
||||
return Math.min(LADDER.length - 1, Math.round(t * (LADDER.length - 1)));
|
||||
}
|
||||
|
||||
// the nearest ladder step for a suggested amount (from a link an agent made)
|
||||
export function detentForAmount(sats) {
|
||||
const n = Number(sats);
|
||||
if (!Number.isFinite(n) || n <= 0) return 0;
|
||||
let best = 0;
|
||||
for (let i = 0; i < LADDER.length; i++) if (Math.abs(LADDER[i] - n) < Math.abs(LADDER[best] - n)) best = i;
|
||||
return best;
|
||||
}
|
||||
|
||||
// angles of n targets along the fence, centred straight up (-π/2), spread at most `spread` radians
|
||||
export function fenceAngles(n, spread = Math.PI / 2) {
|
||||
if (n <= 0) return [];
|
||||
if (n === 1) return [-Math.PI / 2];
|
||||
const s = Math.min(spread, (n - 1) * 0.55);
|
||||
return Array.from({ length: n }, (_, i) => -Math.PI / 2 - s / 2 + (s * i) / (n - 1));
|
||||
}
|
||||
|
||||
export function fencePoint(angle, g = GEOM) {
|
||||
return { x: g.rx + g.fence * Math.cos(angle), y: g.ry + g.fence * Math.sin(angle) };
|
||||
}
|
||||
|
||||
// clamp a pull (pointer minus rest point) to the band's reach
|
||||
export function clampPull(dx, dy, g = GEOM) {
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d <= g.max || d === 0) return { dx, dy, stretch: d };
|
||||
return { dx: (dx / d) * g.max, dy: (dy / d) * g.max, stretch: g.max };
|
||||
}
|
||||
|
||||
// who a pull aims at: the stone flies opposite the pull. null when aimed down, or too far off every target
|
||||
export function aimAt(dx, dy, angles) {
|
||||
const ax = -dx, ay = -dy;
|
||||
if (ay > -6 || !angles.length) return null;
|
||||
const a = Math.atan2(ay, ax);
|
||||
let best = 0;
|
||||
for (let i = 1; i < angles.length; i++) if (Math.abs(a - angles[i]) < Math.abs(a - angles[best])) best = i;
|
||||
const gap = angles.length > 1 ? Math.abs(angles[1] - angles[0]) : 0.6;
|
||||
return Math.abs(a - angles[best]) <= Math.max(0.45, gap * 0.75) ? best : null;
|
||||
}
|
||||
|
||||
// the stone's arc: a quadratic from the fork to the target, with its top above both
|
||||
export function arcPoint(from, to, t) {
|
||||
const c = { x: (from.x + to.x) / 2 + (to.x - from.x) * 0.12, y: Math.min(from.y, to.y) - 70 };
|
||||
const u = 1 - t;
|
||||
return { x: u * u * from.x + 2 * u * t * c.x + t * t * to.x, y: u * u * from.y + 2 * u * t * c.y + t * t * to.y };
|
||||
}
|
||||
|
||||
export function arcPath(from, to) {
|
||||
const c = { x: (from.x + to.x) / 2 + (to.x - from.x) * 0.12, y: Math.min(from.y, to.y) - 70 };
|
||||
return `M${from.x.toFixed(1)} ${from.y.toFixed(1)} Q${c.x.toFixed(1)} ${c.y.toFixed(1)} ${to.x.toFixed(1)} ${to.y.toFixed(1)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------- the drawing
|
||||
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const el = (name, attrs = {}, parent = null) => {
|
||||
const e = document.createElementNS(NS, name);
|
||||
for (const [k, v] of Object.entries(attrs)) if (v != null) e.setAttribute(k, String(v));
|
||||
if (parent) parent.appendChild(e);
|
||||
return e;
|
||||
};
|
||||
|
||||
const reduced = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
const ease = (t) => 1 - Math.pow(1 - t, 3);
|
||||
|
||||
export class Slingshot {
|
||||
constructor(svg, handlers = {}) {
|
||||
this.svg = svg;
|
||||
this.h = handlers;
|
||||
this.g = GEOM;
|
||||
this.targets = [];
|
||||
this.angles = [];
|
||||
this.pouch = { x: GEOM.rx, y: GEOM.ry };
|
||||
this.pull = { dx: 0, dy: 0, stretch: 0 };
|
||||
this.aim = null;
|
||||
this.detent = -1;
|
||||
this.dragging = false;
|
||||
this.flying = false;
|
||||
this.keyTarget = 0;
|
||||
this.keyDetent = -1;
|
||||
this.noted = false;
|
||||
this.build();
|
||||
this.bind();
|
||||
this.render();
|
||||
}
|
||||
|
||||
build() {
|
||||
const g = this.g, s = this.svg;
|
||||
s.setAttribute('viewBox', `0 ${g.top} ${g.w} ${g.h - g.top}`);
|
||||
s.innerHTML = '';
|
||||
const defs = el('defs', {}, s);
|
||||
this.clipBase = 'clip' + Math.random().toString(36).slice(2, 7);
|
||||
this.defs = defs;
|
||||
|
||||
// the fence: an arc of equal distance from the fork, so distance never matters, only who and how much
|
||||
this.fenceArc = el('path', { class: 'fence-arc' }, s);
|
||||
this.rings = el('g', { class: 'rings', 'aria-hidden': 'true' }, s);
|
||||
this.ringEls = LADDER.map((v, i) => {
|
||||
const r = ringRadius(i);
|
||||
return el('circle', { cx: g.rx, cy: g.ry, r, class: 'ring' + (i === 0 ? ' ring-note' : '') }, this.rings);
|
||||
});
|
||||
this.ringLabels = LADDER.map((v, i) => {
|
||||
const r = ringRadius(i), a = (-18 * Math.PI) / 180;
|
||||
const t = el('text', { x: g.rx + r * Math.cos(a) + 4, y: g.ry + r * Math.sin(a) + 4, class: 'ring-label' }, this.rings);
|
||||
t.textContent = i === 0 ? 'note' : fmt(v);
|
||||
if (i !== LADDER.length - 1) t.classList.add('minor');
|
||||
return t;
|
||||
});
|
||||
this.suggestRing = el('circle', { cx: g.rx, cy: g.ry, r: 0, class: 'ring-suggest' }, s);
|
||||
this.path = el('path', { class: 'trajectory' }, s);
|
||||
this.targetLayer = el('g', { class: 'targets' }, s);
|
||||
|
||||
// the fork, drawn as the Y in the name
|
||||
const fork = el('g', { class: 'fork', 'aria-hidden': 'true' }, s);
|
||||
const crotch = g.ry + 64, top = g.ry + 4, t = g.tip;
|
||||
el('path', { d: `M${g.rx} ${g.ry + 152} L${g.rx} ${crotch}`, class: 'fork-handle' }, fork);
|
||||
el('path', { d: `M${g.rx - t} ${top} C${g.rx - t + 1} ${g.ry + 42} ${g.rx - 20} ${crotch - 6} ${g.rx} ${crotch} C${g.rx + 20} ${crotch - 6} ${g.rx + t - 1} ${g.ry + 42} ${g.rx + t} ${top}`, class: 'fork-arms' }, fork);
|
||||
const w = [44, 58, 72].map((dy) => `M${g.rx - 11} ${crotch + dy} L${g.rx + 11} ${crotch + dy}`).join(' ');
|
||||
el('path', { d: w, class: 'fork-wrap' }, fork);
|
||||
this.tipL = { x: g.rx - t, y: top };
|
||||
this.tipR = { x: g.rx + t, y: top };
|
||||
this.bandL = el('line', { class: 'band', x1: this.tipL.x, y1: this.tipL.y }, s);
|
||||
this.bandR = el('line', { class: 'band', x1: this.tipR.x, y1: this.tipR.y }, s);
|
||||
this.pouchEl = el('path', { class: 'pouch' }, s);
|
||||
this.noteTag = el('g', { class: 'note-tag' }, s);
|
||||
el('line', { x1: 0, y1: 0, x2: 13, y2: -15, class: 'note-string' }, this.noteTag);
|
||||
el('rect', { x: 9, y: -29, width: 22, height: 15, rx: 2, class: 'note-paper' }, this.noteTag);
|
||||
el('path', { d: 'M13 -24 L27 -24 M13 -19 L23 -19', class: 'note-lines' }, this.noteTag);
|
||||
this.stone = el('circle', { r: 10.5, class: 'stone' }, s);
|
||||
this.hit = el('circle', { cx: g.rx, cy: g.ry + 16, r: g.grab, class: 'grab' }, s);
|
||||
this.fx = el('g', { class: 'fx', 'aria-hidden': 'true' }, s);
|
||||
}
|
||||
|
||||
bind() {
|
||||
const s = this.svg;
|
||||
const pt = (e) => {
|
||||
const r = s.getBoundingClientRect();
|
||||
return { x: ((e.clientX - r.left) / r.width) * this.g.w, y: this.g.top + ((e.clientY - r.top) / r.height) * (this.g.h - this.g.top) };
|
||||
};
|
||||
const near = (p) => Math.hypot(p.x - this.g.rx, p.y - (this.g.ry + 16)) <= this.g.grab;
|
||||
s.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1 && !this.flying && near(pt(e.touches[0]))) e.preventDefault();
|
||||
}, { passive: false });
|
||||
s.addEventListener('touchmove', (e) => { if (this.dragging) e.preventDefault(); }, { passive: false });
|
||||
s.addEventListener('pointerdown', (e) => {
|
||||
if (this.flying || e.button > 0) return;
|
||||
const p = pt(e);
|
||||
if (!near(p)) return;
|
||||
e.preventDefault();
|
||||
this.dragging = true;
|
||||
this.keyDetent = -1;
|
||||
try { s.setPointerCapture(e.pointerId); } catch {}
|
||||
s.classList.add('pulling');
|
||||
this.move(p);
|
||||
this.h.onStart?.();
|
||||
});
|
||||
s.addEventListener('pointermove', (e) => { if (this.dragging) this.move(pt(e)); });
|
||||
const end = (cancelled) => {
|
||||
if (!this.dragging) return;
|
||||
this.dragging = false;
|
||||
s.classList.remove('pulling');
|
||||
this.letGo(cancelled);
|
||||
};
|
||||
s.addEventListener('pointerup', () => end(false));
|
||||
s.addEventListener('pointercancel', () => end(true));
|
||||
s.addEventListener('keydown', (e) => this.key(e));
|
||||
}
|
||||
|
||||
move(p) {
|
||||
const c = clampPull(p.x - this.g.rx, p.y - this.g.ry);
|
||||
this.pull = c;
|
||||
this.pouch = { x: this.g.rx + c.dx, y: this.g.ry + c.dy };
|
||||
const aim = aimAt(c.dx, c.dy, this.angles);
|
||||
const detent = aim == null ? -1 : detentFor(c.stretch);
|
||||
const changed = aim !== this.aim || detent !== this.detent;
|
||||
this.aim = aim;
|
||||
this.detent = detent;
|
||||
this.render();
|
||||
if (changed) this.h.onAim?.({ target: aim == null ? null : this.targets[aim], index: aim, detent, sats: detent < 0 ? 0 : LADDER[detent] });
|
||||
}
|
||||
|
||||
// keyboard: arrows aim (left, right) and stretch (down pulls back, up eases off); Enter lets go; Escape lets the band go slack
|
||||
key(e) {
|
||||
if (this.flying || !this.targets.length) return;
|
||||
const k = e.key;
|
||||
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' ', 'Escape'].includes(k)) return;
|
||||
e.preventDefault();
|
||||
if (k === 'Escape') { this.keyDetent = -1; this.reset(); this.h.onAim?.({ target: null, index: null, detent: -1, sats: 0 }); return; }
|
||||
if (k === 'Enter' || k === ' ') { if (this.keyDetent >= 0) this.letGo(false); return; }
|
||||
if (k === 'ArrowLeft') this.keyTarget = Math.max(0, this.keyTarget - 1);
|
||||
if (k === 'ArrowRight') this.keyTarget = Math.min(this.targets.length - 1, this.keyTarget + 1);
|
||||
if (k === 'ArrowDown') this.keyDetent = Math.min(LADDER.length - 1, this.keyDetent + 1);
|
||||
if (k === 'ArrowUp') this.keyDetent = Math.max(-1, this.keyDetent - 1);
|
||||
if (this.keyDetent < 0) { this.reset(); this.h.onAim?.({ target: null, index: null, detent: -1, sats: 0 }); return; }
|
||||
const a = this.angles[this.keyTarget] + Math.PI;
|
||||
const r = ringRadius(this.keyDetent) + 0.5;
|
||||
this.move({ x: this.g.rx + r * Math.cos(a), y: this.g.ry + r * Math.sin(a) });
|
||||
}
|
||||
|
||||
letGo(cancelled) {
|
||||
const aim = this.aim, detent = this.detent;
|
||||
if (cancelled || aim == null || detent < 0) {
|
||||
this.keyDetent = -1;
|
||||
this.snapBack();
|
||||
this.h.onAim?.({ target: null, index: null, detent: -1, sats: 0 });
|
||||
this.h.onSlack?.();
|
||||
return;
|
||||
}
|
||||
this.keyDetent = -1;
|
||||
const ok = this.h.onRelease?.({ target: this.targets[aim], index: aim, detent, sats: LADDER[detent] });
|
||||
if (ok === false) this.snapBack();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.pull = { dx: 0, dy: 0, stretch: 0 };
|
||||
this.pouch = { x: this.g.rx, y: this.g.ry };
|
||||
this.aim = null;
|
||||
this.detent = -1;
|
||||
this.render();
|
||||
}
|
||||
|
||||
snapBack() {
|
||||
const from = { ...this.pouch };
|
||||
this.aim = null;
|
||||
this.detent = -1;
|
||||
if (reduced()) { this.reset(); return; }
|
||||
const t0 = performance.now(), dur = 260;
|
||||
const step = (t) => {
|
||||
const u = Math.min(1, (t - t0) / dur);
|
||||
const k = 1 - ease(u) + Math.sin(u * Math.PI) * 0.12 * (1 - u);
|
||||
this.pouch = { x: this.g.rx + (from.x - this.g.rx) * k, y: this.g.ry + (from.y - this.g.ry) * k };
|
||||
this.pull = { dx: 0, dy: 0, stretch: 0 };
|
||||
this.render();
|
||||
if (u < 1) requestAnimationFrame(step); else this.reset();
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
setTargets(list) {
|
||||
this.targets = list.slice(0, 5);
|
||||
this.angles = fenceAngles(this.targets.length);
|
||||
this.keyTarget = Math.min(this.keyTarget, Math.max(0, this.targets.length - 1));
|
||||
const layer = this.targetLayer;
|
||||
layer.innerHTML = '';
|
||||
this.defs.innerHTML = '';
|
||||
this.targetEls = this.targets.map((t, i) => {
|
||||
const p = fencePoint(this.angles[i]);
|
||||
const grp = el('g', { class: 'target', transform: `translate(${p.x.toFixed(1)} ${p.y.toFixed(1)})` }, layer);
|
||||
el('circle', { r: 27, class: 'target-halo' }, grp);
|
||||
el('circle', { r: 22, class: 'target-face' }, grp);
|
||||
const ini = el('text', { y: 6, class: 'target-initial', 'text-anchor': 'middle' }, grp);
|
||||
ini.textContent = t.initial || '?';
|
||||
if (t.picture) {
|
||||
const id = `${this.clipBase}-${i}`;
|
||||
const cp = el('clipPath', { id }, this.defs);
|
||||
el('circle', { r: 21 }, cp);
|
||||
const img = el('image', { href: t.picture, x: -21, y: -21, width: 42, height: 42, 'clip-path': `url(#${id})`, preserveAspectRatio: 'xMidYMid slice' }, grp);
|
||||
img.addEventListener('error', () => img.remove());
|
||||
}
|
||||
const label = el('text', { y: 42, class: 'target-name', 'text-anchor': 'middle' }, grp);
|
||||
label.textContent = t.label.length > 10 ? t.label.slice(0, 9) + '…' : t.label;
|
||||
if (!t.canTakeSats) {
|
||||
const n = el('text', { y: 56, class: 'target-sub', 'text-anchor': 'middle' }, grp);
|
||||
n.textContent = 'notes only';
|
||||
}
|
||||
return grp;
|
||||
});
|
||||
if (this.targets.length) {
|
||||
const a0 = this.angles[0] - 0.22, a1 = this.angles[this.angles.length - 1] + 0.22;
|
||||
const p0 = fencePoint(a0), p1 = fencePoint(a1);
|
||||
this.fenceArc.setAttribute('d', `M${p0.x.toFixed(1)} ${p0.y.toFixed(1)} A${this.g.fence} ${this.g.fence} 0 0 1 ${p1.x.toFixed(1)} ${p1.y.toFixed(1)}`);
|
||||
} else {
|
||||
this.fenceArc.removeAttribute('d');
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
setNoted(on) { this.noted = Boolean(on); this.render(); }
|
||||
|
||||
setSuggested(detent) {
|
||||
this.suggestRing.setAttribute('r', detent > 0 ? ringRadius(detent) : 0);
|
||||
this.svg.classList.toggle('suggesting', detent > 0);
|
||||
}
|
||||
|
||||
pointFor(index) { return fencePoint(this.angles[index]); }
|
||||
|
||||
render() {
|
||||
const { x, y } = this.pouch;
|
||||
for (const b of [this.bandL, this.bandR]) { b.setAttribute('x2', x.toFixed(1)); b.setAttribute('y2', y.toFixed(1)); }
|
||||
const tension = Math.min(1, this.pull.stretch / this.g.max);
|
||||
this.svg.style.setProperty('--tension', tension.toFixed(3));
|
||||
// the pouch: a short strap across the stone, turned to face along the bands
|
||||
const ang = Math.atan2(this.g.ry - y, this.g.rx - x || 0.0001);
|
||||
const nx = Math.cos(ang + Math.PI / 2), ny = Math.sin(ang + Math.PI / 2);
|
||||
const bx = x - Math.cos(ang) * 9, by = y - Math.sin(ang) * 9;
|
||||
this.pouchEl.setAttribute('d', `M${(x + nx * 13).toFixed(1)} ${(y + ny * 13).toFixed(1)} Q${(bx).toFixed(1)} ${(by).toFixed(1)} ${(x - nx * 13).toFixed(1)} ${(y - ny * 13).toFixed(1)}`);
|
||||
if (!this.flying) {
|
||||
this.stone.setAttribute('cx', x.toFixed(1));
|
||||
this.stone.setAttribute('cy', y.toFixed(1));
|
||||
this.noteTag.setAttribute('transform', `translate(${x.toFixed(1)} ${y.toFixed(1)})`);
|
||||
}
|
||||
this.noteTag.style.display = this.noted ? '' : 'none';
|
||||
this.ringEls.forEach((r, i) => {
|
||||
r.classList.toggle('reached', this.detent > i);
|
||||
r.classList.toggle('at', this.detent === i);
|
||||
});
|
||||
const top = LADDER.length - 1;
|
||||
this.ringLabels.forEach((t, i) => {
|
||||
t.classList.toggle('current', this.detent === i);
|
||||
t.classList.toggle('crowded', i === top && this.detent >= top - 2 && this.detent !== top);
|
||||
});
|
||||
(this.targetEls || []).forEach((t, i) => t.classList.toggle('aimed', this.aim === i));
|
||||
if (this.aim != null && this.detent >= 0) {
|
||||
this.path.setAttribute('d', arcPath({ x: this.g.rx, y: this.g.ry }, this.pointFor(this.aim)));
|
||||
this.path.classList.add('on');
|
||||
} else {
|
||||
this.path.classList.remove('on');
|
||||
}
|
||||
}
|
||||
|
||||
// fly the stone to a target. Returns { cancel } while it is in the air; onLand runs when it gets there.
|
||||
launch(index, { duration = 900, onLand, onBack } = {}) {
|
||||
const from = { x: this.g.rx, y: this.g.ry };
|
||||
const to = this.pointFor(index);
|
||||
this.flying = true;
|
||||
this.svg.classList.add('flying');
|
||||
this.pouch = { x: this.g.rx, y: this.g.ry };
|
||||
this.pull = { dx: 0, dy: 0, stretch: 0 };
|
||||
const keepAim = this.aim;
|
||||
this.detent = -1;
|
||||
this.render();
|
||||
(this.targetEls || []).forEach((t, i) => t.classList.toggle('aimed', keepAim === i));
|
||||
this.path.setAttribute('d', arcPath(from, to));
|
||||
this.path.classList.add('on', 'live');
|
||||
const dur = reduced() ? Math.min(duration, 400) : duration;
|
||||
let raf = 0, done = false, t0 = performance.now();
|
||||
const place = (p) => {
|
||||
this.stone.setAttribute('cx', p.x.toFixed(1));
|
||||
this.stone.setAttribute('cy', p.y.toFixed(1));
|
||||
this.noteTag.setAttribute('transform', `translate(${p.x.toFixed(1)} ${p.y.toFixed(1)})`);
|
||||
};
|
||||
const finish = () => {
|
||||
this.flying = false;
|
||||
this.svg.classList.remove('flying');
|
||||
this.path.classList.remove('on', 'live');
|
||||
this.aim = null;
|
||||
this.reset();
|
||||
};
|
||||
const step = (t) => {
|
||||
if (done) return;
|
||||
const u = Math.min(1, (t - t0) / dur);
|
||||
place(arcPoint(from, to, u));
|
||||
if (u < 1) { raf = requestAnimationFrame(step); return; }
|
||||
done = true;
|
||||
this.splash(index);
|
||||
finish();
|
||||
onLand?.();
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return {
|
||||
cancel: () => {
|
||||
if (done) return false;
|
||||
done = true;
|
||||
cancelAnimationFrame(raf);
|
||||
const cur = { x: Number(this.stone.getAttribute('cx')), y: Number(this.stone.getAttribute('cy')) };
|
||||
const b0 = performance.now();
|
||||
const back = (t) => {
|
||||
const u = Math.min(1, (t - b0) / 300);
|
||||
const k = ease(u);
|
||||
place({ x: cur.x + (from.x - cur.x) * k, y: cur.y + (from.y - cur.y) * k });
|
||||
if (u < 1) requestAnimationFrame(back); else { finish(); onBack?.(); }
|
||||
};
|
||||
requestAnimationFrame(back);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// a small ring where the stone lands
|
||||
splash(index) {
|
||||
if (index == null || !this.angles.length) return;
|
||||
const p = this.pointFor(index);
|
||||
const c = el('circle', { cx: p.x, cy: p.y, r: 24, class: 'splash' }, this.fx);
|
||||
setTimeout(() => c.remove(), 700);
|
||||
}
|
||||
|
||||
// a figure that rises above someone: "+2,100" when they catch sats, or a word
|
||||
float(index, text) {
|
||||
if (index == null || !this.angles.length) return;
|
||||
const p = this.pointFor(index);
|
||||
const t = el('text', { x: p.x, y: p.y - 34, class: 'float', 'text-anchor': 'middle' }, this.fx);
|
||||
t.textContent = text;
|
||||
setTimeout(() => t.remove(), 1500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
@font-face { font-family: "Slyng Condensed"; src: url("fonts/slyng-condensed-bold.woff") format("woff"); font-weight: 700; font-style: normal; font-display: swap; }
|
||||
@font-face { font-family: "Slyng Text"; src: url("fonts/slyng-text-regular.woff") format("woff"); font-weight: 400; font-style: normal; font-display: swap; }
|
||||
@font-face { font-family: "Slyng Text"; src: url("fonts/slyng-text-bold.woff") format("woff"); font-weight: 700; font-style: normal; font-display: swap; }
|
||||
|
||||
:root {
|
||||
--sky: #D8E8F0;
|
||||
--sky-deep: #C7DCE7;
|
||||
--haze: #EEF3F6;
|
||||
--paper: #FBFCFD;
|
||||
--ink: #15243B;
|
||||
--ink-2: #4A5B73;
|
||||
--ink-3: #7A889B;
|
||||
--line: rgba(21, 36, 59, 0.16);
|
||||
--rubber: #E2412E;
|
||||
--rubber-dark: #B52E1F;
|
||||
--wood: #8A5A35;
|
||||
--wood-dark: #5E3B20;
|
||||
--leather: #6B4A2E;
|
||||
--stone: #F0A532;
|
||||
--stone-dark: #A86814;
|
||||
--cond: "Slyng Condensed", "Arial Narrow", "Helvetica Neue", sans-serif;
|
||||
--text: "Slyng Text", "Century Gothic", "Avenir", system-ui, sans-serif;
|
||||
--radius: 12px;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--haze);
|
||||
color: var(--ink);
|
||||
font: 400 17px/1.55 var(--text);
|
||||
font-kerning: normal;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
a { color: var(--ink); text-decoration-thickness: 1.5px; text-underline-offset: 3px; }
|
||||
a:hover { color: var(--rubber-dark); }
|
||||
:focus-visible { outline: 3px solid var(--ink); outline-offset: 2px; border-radius: 6px; }
|
||||
.sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||||
h2, h3 { font-family: var(--cond); font-weight: 700; letter-spacing: 0.005em; line-height: 1.05; margin: 0 0 0.5em; }
|
||||
h2 { font-size: clamp(2rem, 4.4vw, 2.75rem); }
|
||||
h3 { font-size: 1.45rem; }
|
||||
p { margin: 0 0 0.9em; }
|
||||
.tiny { font-size: 0.82rem; color: var(--ink-2); }
|
||||
|
||||
/* ------------------------------------------------------------------ the bar */
|
||||
.bar {
|
||||
display: flex; align-items: center; gap: 22px;
|
||||
padding: 12px clamp(16px, 3vw, 32px);
|
||||
background: var(--sky);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--ink); }
|
||||
.mark { width: 30px; height: 30px; }
|
||||
.m-wood { fill: none; stroke: var(--wood); stroke-width: 4.4; stroke-linecap: round; }
|
||||
.m-band { fill: none; stroke: var(--rubber); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.m-stone { fill: var(--stone); stroke: var(--stone-dark); stroke-width: 1; }
|
||||
.word { font-family: var(--cond); font-weight: 700; font-size: 1.75rem; letter-spacing: 0.04em; line-height: 1; padding-top: 2px; }
|
||||
.motto { margin: 0; color: var(--ink-2); font-size: 0.95rem; }
|
||||
.links { display: flex; gap: 18px; margin-left: auto; font-size: 0.95rem; }
|
||||
.links a { text-decoration: none; }
|
||||
.links a:hover { text-decoration: underline; }
|
||||
.join {
|
||||
font-size: 0.92rem; text-decoration: none; padding: 8px 15px; border-radius: 999px;
|
||||
border: 1.5px solid var(--ink); white-space: nowrap;
|
||||
}
|
||||
.join:hover { background: var(--ink); color: var(--paper); }
|
||||
|
||||
/* ------------------------------------------------------------------ the studio */
|
||||
.studio {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
|
||||
background: var(--sky);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.range { display: flex; flex-direction: column; align-items: center; padding: 14px 16px 22px; min-width: 0; }
|
||||
.chips { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
|
||||
.chip {
|
||||
font: 400 0.88rem/1 var(--text); color: var(--ink);
|
||||
background: rgba(251, 252, 253, 0.55); border: 1.5px solid var(--line); border-radius: 999px;
|
||||
padding: 8px 13px; cursor: pointer;
|
||||
}
|
||||
.chip:hover { border-color: var(--ink-2); background: var(--paper); }
|
||||
.chip.live { border-color: var(--ink); }
|
||||
.aimed { margin: 10px 0 0; font-size: 0.9rem; max-width: 34em; text-align: center; color: var(--ink); background: rgba(240, 165, 50, 0.22); padding: 6px 12px; border-radius: 8px; }
|
||||
|
||||
.sling { width: min(100%, 470px); height: auto; aspect-ratio: 420 / 436; display: block; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }
|
||||
.sling:focus-visible { outline: 3px solid var(--ink); outline-offset: -3px; border-radius: 18px; }
|
||||
.sling .grab { fill: transparent; touch-action: none; cursor: grab; }
|
||||
.sling.pulling .grab { cursor: grabbing; }
|
||||
.fence-arc { fill: none; stroke: var(--ink); stroke-opacity: 0.28; stroke-width: 2; stroke-dasharray: 0.5 8; stroke-linecap: round; }
|
||||
.ring { fill: none; stroke: var(--ink); stroke-opacity: 0.16; stroke-width: 1; opacity: 0; transition: opacity 0.15s, stroke-opacity 0.1s; }
|
||||
.ring-note { stroke-dasharray: 3 4; }
|
||||
.ring.reached { stroke: var(--rubber); stroke-opacity: 0.22; }
|
||||
.ring.at { stroke: var(--rubber); stroke-opacity: 0.95; stroke-width: 2; }
|
||||
.ring-label { font: 400 11px var(--text); fill: var(--ink-2); opacity: 0; transition: opacity 0.15s; }
|
||||
.ring-label.minor { display: none; }
|
||||
.ring-label.current { display: inline; fill: var(--ink); font-weight: 700; }
|
||||
.ring-label.crowded { display: none; }
|
||||
.sling.pulling .ring, .sling:focus-visible .ring, .sling.keyed .ring { opacity: 1; }
|
||||
.sling.pulling .ring-label, .sling:focus-visible .ring-label, .sling.keyed .ring-label { opacity: 1; }
|
||||
.ring-suggest { fill: none; stroke: var(--stone-dark); stroke-width: 2; stroke-dasharray: 5 4; opacity: 0; }
|
||||
.sling.suggesting .ring-suggest { opacity: 0.9; }
|
||||
.trajectory { fill: none; stroke: var(--ink); stroke-opacity: 0.5; stroke-width: 2; stroke-dasharray: 0.5 7; stroke-linecap: round; opacity: 0; }
|
||||
.trajectory.on { opacity: 1; }
|
||||
.trajectory.live { stroke: var(--rubber); stroke-opacity: 0.8; }
|
||||
.target-face { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; }
|
||||
.target-halo { fill: none; stroke: transparent; stroke-width: 2.5; }
|
||||
.target.aimed .target-halo { stroke: var(--rubber); }
|
||||
.target.aimed .target-face { stroke-width: 2; }
|
||||
.target-initial { font: 700 21px var(--cond); fill: var(--ink); }
|
||||
.target-name { font: 400 12.5px var(--text); fill: var(--ink); }
|
||||
.target-sub { font: 400 10.5px var(--text); fill: var(--ink-2); }
|
||||
.fork-handle, .fork-arms { fill: none; stroke: var(--wood); stroke-width: 16; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.fork-wrap { fill: none; stroke: var(--wood-dark); stroke-width: 3.5; stroke-linecap: round; }
|
||||
.band { stroke: var(--rubber); stroke-width: calc(5.5px - 2.6px * var(--tension, 0)); stroke-linecap: round; }
|
||||
.pouch { fill: none; stroke: var(--leather); stroke-width: 7; stroke-linecap: round; }
|
||||
.stone { fill: var(--stone); stroke: var(--stone-dark); stroke-width: 1.5; }
|
||||
.note-paper { fill: var(--paper); stroke: var(--ink); stroke-width: 1; }
|
||||
.note-lines { stroke: var(--ink-2); stroke-width: 1; }
|
||||
.note-string { stroke: var(--ink-2); stroke-width: 1; }
|
||||
.splash { fill: none; stroke: var(--stone); stroke-width: 3; transform-box: fill-box; transform-origin: center; animation: splash 0.7s ease-out forwards; }
|
||||
@keyframes splash { from { transform: scale(0.8); opacity: 1; } to { transform: scale(1.9); opacity: 0; } }
|
||||
.float { font: 700 21px var(--cond); fill: var(--ink); animation: float 1.5s ease-out forwards; }
|
||||
@keyframes float { from { transform: translateY(0); opacity: 1; } 70% { opacity: 1; } to { transform: translateY(-26px); opacity: 0; } }
|
||||
|
||||
.readout { text-align: center; min-height: 5.2em; margin-top: -6px; }
|
||||
.amt { font: 700 clamp(2.3rem, 8vw, 3.3rem)/1 var(--cond); margin: 0 0 6px; letter-spacing: 0.01em; font-variant-numeric: tabular-nums lining-nums; }
|
||||
.sub { margin: 0; color: var(--ink-2); font-size: 0.95rem; min-height: 1.5em; text-wrap: balance; }
|
||||
.note { width: min(100%, 420px); margin-top: 8px; }
|
||||
.note textarea {
|
||||
width: 100%; resize: none; font: 400 1rem/1.4 var(--text); color: var(--ink);
|
||||
background: rgba(251, 252, 253, 0.75); border: 1.5px solid var(--line); border-radius: 10px;
|
||||
padding: 10px 14px; min-height: 44px; max-height: 7.5em; text-align: center;
|
||||
}
|
||||
.note textarea:focus { outline: none; border-color: var(--ink); background: var(--paper); text-align: left; }
|
||||
.note textarea::placeholder { color: var(--ink-3); }
|
||||
.flight { margin-top: 12px; }
|
||||
.fence-row { margin: 14px 0 0; font-size: 0.92rem; }
|
||||
|
||||
/* ------------------------------------------------------------------ buttons */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
font: 700 0.95rem/1 var(--text); padding: 11px 18px; border-radius: 999px; cursor: pointer;
|
||||
color: var(--paper); background: var(--ink); border: 1.5px solid var(--ink); text-decoration: none;
|
||||
}
|
||||
.pill:hover { background: #22375A; color: var(--paper); }
|
||||
.pill.small { font-size: 0.86rem; padding: 8px 14px; }
|
||||
.pill.ghost { background: transparent; color: var(--ink); border-color: var(--line); }
|
||||
.pill.ghost:hover { border-color: var(--ink); background: var(--paper); color: var(--ink); }
|
||||
.pill.catch { background: var(--rubber); border-color: var(--rubber); font-size: 1rem; padding: 12px 22px; }
|
||||
.pill.catch:hover { background: var(--rubber-dark); border-color: var(--rubber-dark); }
|
||||
.pill[disabled] { opacity: 0.45; cursor: default; }
|
||||
.linkish { font: inherit; background: none; border: 0; padding: 0; color: var(--ink); text-decoration: underline; text-decoration-thickness: 1.5px; text-underline-offset: 3px; cursor: pointer; }
|
||||
.linkish:hover { color: var(--rubber-dark); }
|
||||
|
||||
/* ------------------------------------------------------------------ the log */
|
||||
.log { background: rgba(238, 243, 246, 0.6); border-left: 1px solid var(--line); padding: 16px 18px 20px; min-width: 0; }
|
||||
.log-tabs { display: flex; gap: 18px; border-bottom: 1px solid var(--line); margin-bottom: 6px; }
|
||||
.log-tabs button {
|
||||
font: 700 1.25rem/1 var(--cond); letter-spacing: 0.01em; background: none; border: 0; color: var(--ink-2);
|
||||
padding: 6px 0 9px; border-bottom: 3px solid transparent; margin-bottom: -1px; cursor: pointer;
|
||||
}
|
||||
.log-tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--ink); }
|
||||
.count { font: 400 0.8rem var(--text); color: var(--ink-2); }
|
||||
.entries { list-style: none; margin: 0; padding: 0; max-height: 520px; overflow-y: auto; }
|
||||
.entry { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 10px; padding: 11px 0; border-bottom: 1px solid var(--line); }
|
||||
.entry:last-child { border-bottom: 0; }
|
||||
.face {
|
||||
width: 34px; height: 34px; border-radius: 50%; overflow: hidden; display: grid; place-items: center;
|
||||
background: var(--paper); border: 1.5px solid var(--ink); font: 700 15px var(--cond);
|
||||
}
|
||||
.face img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.entry-top { display: flex; gap: 8px; align-items: baseline; justify-content: space-between; }
|
||||
.entry-what { font-size: 0.93rem; line-height: 1.35; }
|
||||
.entry-what b { font-weight: 700; }
|
||||
.entry-when { font-size: 0.78rem; color: var(--ink-3); white-space: nowrap; }
|
||||
.entry-note { margin: 4px 0 0; font-size: 0.93rem; line-height: 1.4; overflow-wrap: anywhere; }
|
||||
.entry-note::before { content: "\201C"; }
|
||||
.entry-note::after { content: "\201D"; }
|
||||
.entry-status { display: inline-block; margin-top: 5px; font-size: 0.78rem; color: var(--ink-2); }
|
||||
.entry-status.bad { color: var(--rubber-dark); }
|
||||
.entry-status.good { color: var(--ink); font-weight: 700; }
|
||||
.entry button.aim-back { display: block; margin-top: 6px; font-size: 0.84rem; }
|
||||
.empty { color: var(--ink-2); font-size: 0.92rem; margin: 12px 0 0; }
|
||||
|
||||
/* ------------------------------------------------------------------ the page below */
|
||||
main > section:not(.studio) { padding: clamp(40px, 7vw, 76px) clamp(16px, 5vw, 48px); max-width: 1120px; margin: 0 auto; }
|
||||
.story { display: grid; grid-template-columns: minmax(0, 1fr); gap: clamp(18px, 3vw, 30px); }
|
||||
.bigword {
|
||||
font: 700 clamp(5rem, 21vw, 15rem)/0.82 var(--cond); letter-spacing: 0.02em; margin: 0;
|
||||
display: flex; align-items: flex-end; color: var(--ink);
|
||||
}
|
||||
.bigy { height: 0.74em; width: auto; margin: 0 0.035em 0.005em; overflow: visible; }
|
||||
.y-wood { fill: none; stroke: var(--wood); stroke-width: 9.5; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.y-band { fill: none; stroke: var(--rubber); stroke-width: 3.2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.y-stone { fill: var(--stone); stroke: var(--stone-dark); stroke-width: 1.5; }
|
||||
.story-text { max-width: 38em; columns: 2 18em; column-gap: 40px; }
|
||||
.story-text h2 { column-span: all; }
|
||||
|
||||
.steps { list-style: none; margin: 18px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(20px, 4vw, 44px); }
|
||||
.steps p { color: var(--ink-2); max-width: 32em; }
|
||||
.diagram { width: 150px; height: 100px; display: block; margin-bottom: 12px; }
|
||||
.d-fence { fill: none; stroke: var(--ink); stroke-opacity: 0.35; stroke-width: 1.5; stroke-dasharray: 0.5 5; stroke-linecap: round; }
|
||||
.d-dot { fill: var(--paper); stroke: var(--ink); stroke-width: 1.5; }
|
||||
.d-dot.on { stroke: var(--rubber); stroke-width: 2.5; }
|
||||
.d-arrow { fill: none; stroke: var(--ink); stroke-opacity: 0.55; stroke-width: 1.5; stroke-dasharray: 0.5 5; stroke-linecap: round; }
|
||||
.d-pull { fill: none; stroke: var(--rubber); stroke-width: 3; stroke-linecap: round; }
|
||||
.d-ring { fill: none; stroke: var(--ink); stroke-opacity: 0.25; stroke-width: 1.2; }
|
||||
.d-ring.on { stroke: var(--rubber); stroke-opacity: 0.8; stroke-width: 1.6; }
|
||||
.d-stone { fill: var(--stone); stroke: var(--stone-dark); stroke-width: 1; }
|
||||
.d-arc { fill: none; stroke: var(--ink); stroke-opacity: 0.5; stroke-width: 1.6; stroke-dasharray: 0.5 6; stroke-linecap: round; }
|
||||
|
||||
.agents > * { max-width: 760px; }
|
||||
.lead { font-size: 1.25rem; line-height: 1.4; }
|
||||
.snippet { position: relative; background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; margin: 0 0 18px; }
|
||||
.snippet pre { margin: 0 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; font: 400 0.92rem/1.5 var(--text); }
|
||||
|
||||
.plan-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: 0; border-top: 2px solid var(--ink); }
|
||||
.plan { padding: 22px 26px 6px 0; }
|
||||
.plan + .plan { padding-left: 26px; border-left: 1px solid var(--line); }
|
||||
.price { font: 700 1.15rem/1.3 var(--cond); letter-spacing: 0.01em; }
|
||||
.join-plan { margin: 4px 0 10px; }
|
||||
|
||||
.fine dl { margin: 0; display: grid; grid-template-columns: minmax(150px, 220px) minmax(0, 1fr); gap: 12px 28px; max-width: 900px; }
|
||||
.fine dt { font-weight: 700; }
|
||||
.fine dd { margin: 0; color: var(--ink-2); max-width: 40em; }
|
||||
.next ul { margin: 0; padding-left: 1.1em; max-width: 44em; }
|
||||
.next li { margin-bottom: 0.45em; }
|
||||
.next li::marker { color: var(--rubber); }
|
||||
|
||||
.foot { padding: 26px clamp(16px, 5vw, 48px) 40px; border-top: 1px solid var(--line); color: var(--ink-2); font-size: 0.86rem; max-width: 1120px; margin: 0 auto; }
|
||||
|
||||
/* ------------------------------------------------------------------ dialogs */
|
||||
.dlg { border: 0; border-radius: 16px; padding: 0; width: min(520px, calc(100vw - 24px)); color: var(--ink); background: var(--paper); box-shadow: 0 18px 60px rgba(21, 36, 59, 0.28); }
|
||||
.dlg.wide { width: min(620px, calc(100vw - 24px)); }
|
||||
.dlg::backdrop { background: rgba(21, 36, 59, 0.38); }
|
||||
.form { padding: 22px 22px 18px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.form h2 { font-size: 1.9rem; margin: 0; }
|
||||
.form p { margin: 0; }
|
||||
.form label { display: flex; flex-direction: column; gap: 6px; font-size: 0.92rem; }
|
||||
.form label.check { flex-direction: row; align-items: center; gap: 8px; }
|
||||
.form input[type="password"], .form input[type="search"], .form input[type="text"] {
|
||||
font: 400 1rem var(--text); color: var(--ink); border: 1.5px solid var(--line); border-radius: 10px; padding: 10px 12px; background: #fff; width: 100%;
|
||||
}
|
||||
.form input:focus { outline: none; border-color: var(--ink); }
|
||||
.choices { display: grid; gap: 8px; }
|
||||
.choice {
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 3px; text-align: left; cursor: pointer;
|
||||
background: #fff; border: 1.5px solid var(--line); border-radius: var(--radius); padding: 12px 14px; color: var(--ink); font: inherit;
|
||||
}
|
||||
.choice:hover { border-color: var(--ink); }
|
||||
.choice b { font-size: 1rem; }
|
||||
.choice span { font-size: 0.86rem; color: var(--ink-2); }
|
||||
.choice[disabled] { opacity: 0.55; cursor: default; }
|
||||
.group summary { cursor: pointer; font-size: 0.92rem; }
|
||||
.group[open] { display: flex; flex-direction: column; gap: 10px; }
|
||||
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.row.end { justify-content: flex-end; }
|
||||
.status { min-height: 1.2em; font-size: 0.9rem; color: var(--ink-2); }
|
||||
.status.bad { color: var(--rubber-dark); }
|
||||
.secret { margin: 0; padding: 12px 14px; background: var(--haze); border-radius: 10px; font: 400 0.92rem/1.4 var(--text); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.people { list-style: none; margin: 0; padding: 0; max-height: 50vh; overflow-y: auto; border-top: 1px solid var(--line); }
|
||||
.people li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--line); }
|
||||
.people .who-name { font-size: 0.95rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.people .who-sub { font-size: 0.78rem; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.people button[aria-pressed="true"] { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||
|
||||
.toast {
|
||||
position: fixed; left: 50%; bottom: 22px; transform: translateX(-50%); z-index: 20;
|
||||
background: var(--ink); color: var(--paper); padding: 11px 18px; border-radius: 12px; font-size: 0.93rem;
|
||||
max-width: min(92vw, 520px); box-shadow: 0 10px 30px rgba(21, 36, 59, 0.3);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ narrow screens */
|
||||
@media (max-width: 960px) {
|
||||
.motto { display: none; }
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.studio { grid-template-columns: minmax(0, 1fr); }
|
||||
.log { border-left: 0; border-top: 1px solid var(--line); }
|
||||
.entries { max-height: none; }
|
||||
.story { grid-template-columns: minmax(0, 1fr); }
|
||||
.steps { grid-template-columns: minmax(0, 1fr); }
|
||||
.plan-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.plan + .plan { padding-left: 0; border-left: 0; border-top: 1px solid var(--line); }
|
||||
.fine dl { grid-template-columns: minmax(0, 1fr); gap: 4px; }
|
||||
.fine dd { margin-bottom: 12px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.links { display: none; }
|
||||
.bar { gap: 12px; padding: 10px 14px; }
|
||||
.join { margin-left: auto; padding: 7px 12px; font-size: 0.86rem; }
|
||||
.word { font-size: 1.55rem; }
|
||||
.range { padding: 10px 6px 18px; }
|
||||
.sub { font-size: 0.9rem; padding: 0 10px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
.splash, .float { animation-duration: 0.01s; }
|
||||
.ring, .ring-label { transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// node --test tests/*.test.js
|
||||
// The pure modules against published test vectors, then the whole flow against the local devnet.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import nodeCrypto from 'node:crypto';
|
||||
import * as c from '../lib/crypto.js';
|
||||
import {
|
||||
bech32Decode, bech32Encode, npubEncode, nsecEncode, nprofileEncode, parsePerson, parseSecret, finalizeEvent, verifyEvent,
|
||||
serializeEvent, LocalSigner, Pool, normalizeRelay, parseProfile, relayListFrom, dmRelaysFrom, now,
|
||||
} from '../lib/nostr.js';
|
||||
import { invoiceMsats, lnurlEncode, lnurlDecode, lightningAddressUrl, zapRequestTemplate, readZapReceipt, fetchPayEndpoint, fetchInvoice, payUrlFor } from '../lib/zap.js';
|
||||
import { parseNwcUri, NwcWallet } from '../lib/wallet.js';
|
||||
import { sealNote, openWrap, rumor, wrapFor } from '../lib/dm.js';
|
||||
import { LADDER, GEOM, detentFor, detentForAmount, ringRadius, fenceAngles, aimAt, clampPull, fencePoint } from '../sling.js';
|
||||
import { startDevnet } from '../tools/devnet.mjs';
|
||||
|
||||
const h = (s) => c.unhex(s.toLowerCase());
|
||||
|
||||
test('sha256 and hmac match node', () => {
|
||||
for (const n of [0, 1, 55, 56, 64, 65, 1000]) {
|
||||
const m = nodeCrypto.randomBytes(n);
|
||||
assert.equal(c.hex(c.sha256(m)), nodeCrypto.createHash('sha256').update(m).digest('hex'));
|
||||
const k = nodeCrypto.randomBytes(n % 80);
|
||||
assert.equal(c.hex(c.hmacSha256(k, m)), nodeCrypto.createHmac('sha256', k).update(m).digest('hex'));
|
||||
}
|
||||
});
|
||||
|
||||
test('BIP-340 test vectors 0 and 1', () => {
|
||||
const vectors = [
|
||||
['0000000000000000000000000000000000000000000000000000000000000003', 'F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9',
|
||||
'0000000000000000000000000000000000000000000000000000000000000000', '0000000000000000000000000000000000000000000000000000000000000000',
|
||||
'E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0'],
|
||||
['B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF', 'DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659',
|
||||
'0000000000000000000000000000000000000000000000000000000000000001', '243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89',
|
||||
'6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A'],
|
||||
];
|
||||
for (const [sk, pk, aux, msg, sig] of vectors) {
|
||||
assert.equal(c.hex(c.getPublicKey(h(sk))), pk.toLowerCase());
|
||||
assert.equal(c.hex(c.schnorrSign(h(msg), h(sk), h(aux))), sig.toLowerCase());
|
||||
assert.ok(c.schnorrVerify(h(sig), h(msg), h(pk)));
|
||||
const bad = h(sig); bad[40] ^= 1;
|
||||
assert.ok(!c.schnorrVerify(bad, h(msg), h(pk)));
|
||||
}
|
||||
});
|
||||
|
||||
test('NIP-44 v2 vectors: conversation key, payload, padding', () => {
|
||||
const one = h('00'.repeat(31) + '01'), two = h('00'.repeat(31) + '02');
|
||||
const ck = c.nip44ConversationKey(one, c.getPublicKey(two));
|
||||
assert.equal(c.hex(ck), 'c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d');
|
||||
const payload = c.nip44Encrypt('a', ck, h('00'.repeat(31) + '01'));
|
||||
assert.equal(payload, 'AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb');
|
||||
assert.equal(c.nip44Decrypt(payload, ck), 'a');
|
||||
const pads = [[16, 32], [32, 32], [33, 64], [37, 64], [45, 64], [49, 64], [64, 64], [65, 96], [100, 128], [111, 128], [200, 224], [250, 256], [320, 320], [383, 384], [384, 384], [400, 448], [500, 512], [512, 512], [515, 640], [700, 768], [800, 896], [900, 1024], [1020, 1024], [65536, 65536]];
|
||||
for (const [n, want] of pads) assert.equal(c.calcPaddedLen(n), want, `padded length of ${n}`);
|
||||
const long = 'ü sling '.repeat(4000);
|
||||
assert.equal(c.nip44Decrypt(c.nip44Encrypt(long, ck), ck), long);
|
||||
assert.throws(() => c.nip44Decrypt(payload.slice(0, 50) + (payload[50] === 'A' ? 'B' : 'A') + payload.slice(51), ck));
|
||||
});
|
||||
|
||||
test('NIP-04 round trip (for wallets)', async () => {
|
||||
const a = c.generatePrivateKey(), b = c.generatePrivateKey();
|
||||
const enc = await c.nip04Encrypt('pay_invoice', a, c.getPublicKey(b));
|
||||
assert.match(enc, /\?iv=/);
|
||||
assert.equal(await c.nip04Decrypt(enc, b, c.getPublicKey(a)), 'pay_invoice');
|
||||
});
|
||||
|
||||
test('NIP-19 examples', () => {
|
||||
assert.equal(npubEncode('7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e'), 'npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg');
|
||||
assert.equal(nsecEncode(h('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa')), 'nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5');
|
||||
assert.deepEqual(parsePerson('nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg'), { pubkey: '7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e', relays: [] });
|
||||
const np = parsePerson('nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p');
|
||||
assert.equal(np.pubkey, '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d');
|
||||
assert.deepEqual(np.relays, ['wss://r.x.com', 'wss://djbas.sadkb.com']);
|
||||
assert.equal(parsePerson(nprofileEncode(np.pubkey, np.relays)).pubkey, np.pubkey);
|
||||
assert.equal(c.hex(parseSecret('nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5')), '67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa');
|
||||
assert.throws(() => parseSecret('npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg'));
|
||||
assert.throws(() => bech32Decode('npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptx'));
|
||||
assert.equal(parsePerson('not a person'), null);
|
||||
});
|
||||
|
||||
test('LNURL (LUD-01 example) and lightning addresses', () => {
|
||||
const lnurl = 'LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS';
|
||||
const url = 'https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df';
|
||||
assert.equal(lnurlDecode(lnurl), url);
|
||||
assert.equal(lnurlEncode(url), lnurl);
|
||||
assert.equal(lightningAddressUrl('Bob@Example.com'), 'https://Example.com/.well-known/lnurlp/bob');
|
||||
assert.equal(lightningAddressUrl('bob@127.0.0.1:8790'), 'http://127.0.0.1:8790/.well-known/lnurlp/bob');
|
||||
assert.equal(lightningAddressUrl('not an address'), null);
|
||||
assert.equal(payUrlFor({ lud06: lnurl }), url);
|
||||
assert.equal(payUrlFor({}), null);
|
||||
});
|
||||
|
||||
test('invoice amounts (BOLT-11 human-readable part)', () => {
|
||||
assert.equal(invoiceMsats('lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq'), 250_000_000);
|
||||
assert.equal(invoiceMsats('lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq'), 2_000_000_000);
|
||||
assert.equal(invoiceMsats('lnbc2100n1pqqqqq'), 210_000);
|
||||
assert.equal(invoiceMsats('lightning:LNBC10N1PQQQQ'), 1_000);
|
||||
assert.equal(invoiceMsats('lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypq'), null);
|
||||
assert.equal(invoiceMsats('lntbs1500n1pqqqq'), 150_000);
|
||||
assert.equal(invoiceMsats('lnbc15p1pqqqq'), null);
|
||||
assert.equal(invoiceMsats('hello'), null);
|
||||
});
|
||||
|
||||
test('events: serialize, sign, verify, tamper', () => {
|
||||
const sk = c.generatePrivateKey();
|
||||
const e = finalizeEvent({ kind: 1, content: 'sling "this"\n🪨', tags: [['t', 'slyng']], created_at: 1700000000 }, sk);
|
||||
assert.ok(verifyEvent(e));
|
||||
assert.equal(e.id, nodeCrypto.createHash('sha256').update(serializeEvent(e)).digest('hex'));
|
||||
assert.ok(!verifyEvent({ ...e, content: 'changed' }));
|
||||
assert.ok(!verifyEvent({ ...e, created_at: e.created_at + 1 }));
|
||||
});
|
||||
|
||||
test('profiles and relay lists', () => {
|
||||
const sk = c.generatePrivateKey();
|
||||
const p = parseProfile(finalizeEvent({ kind: 0, content: JSON.stringify({ name: 'ben', display_name: 'Ben', picture: 'http://insecure.example/x.png', lud16: 'ben@example.com' }), tags: [] }, sk));
|
||||
assert.equal(p.name, 'Ben');
|
||||
assert.equal(p.picture, '');
|
||||
assert.equal(p.lud16, 'ben@example.com');
|
||||
assert.equal(parseProfile({ pubkey: 'x', content: 'not json' }).name, '');
|
||||
const rl = relayListFrom({ tags: [['r', 'wss://a.example'], ['r', 'wss://b.example/', 'write'], ['r', 'https://nope'], ['r', 'wss://c.example', 'read']] });
|
||||
assert.deepEqual(rl.read, ['wss://a.example', 'wss://c.example']);
|
||||
assert.deepEqual(rl.write, ['wss://a.example', 'wss://b.example']);
|
||||
assert.deepEqual(dmRelaysFrom({ tags: [['relay', 'wss://inbox.example'], ['relay', 'ftp://no']] }), ['wss://inbox.example']);
|
||||
assert.equal(normalizeRelay('WSS://Relay.Example.com/'), 'wss://relay.example.com');
|
||||
});
|
||||
|
||||
test('private notes: seal, wrap, open; strangers and tampering fail', async () => {
|
||||
const alice = new LocalSigner(c.generatePrivateKey()), bob = new LocalSigner(c.generatePrivateKey()), eve = new LocalSigner(c.generatePrivateKey());
|
||||
const { toThem, toMe, rumor: r } = await sealNote(alice, bob.pubkey, 'see you at the market');
|
||||
assert.equal(toThem.kind, 1059);
|
||||
assert.ok(verifyEvent(toThem));
|
||||
assert.notEqual(toThem.pubkey, alice.pubkey, 'the wrap is signed by a throwaway key');
|
||||
assert.ok(toThem.created_at <= now());
|
||||
const opened = await openWrap(bob, toThem);
|
||||
assert.equal(opened.content, 'see you at the market');
|
||||
assert.equal(opened.pubkey, alice.pubkey);
|
||||
assert.equal(opened.id, r.id);
|
||||
assert.equal(await openWrap(eve, toThem), null);
|
||||
const mine = await openWrap(alice, toMe);
|
||||
assert.equal(mine.to, bob.pubkey);
|
||||
// a seal signed by eve that claims to be from alice is refused
|
||||
const forged = rumor(alice.pubkey, [bob.pubkey], 'send me your keys');
|
||||
assert.equal(await openWrap(bob, await wrapFor(eve, forged, bob.pubkey)), null);
|
||||
});
|
||||
|
||||
test('zap requests and receipts', () => {
|
||||
const sender = c.generatePrivateKey(), server = c.generatePrivateKey();
|
||||
const recipient = c.hex(c.getPublicKey(c.generatePrivateKey()));
|
||||
const zr = finalizeEvent(zapRequestTemplate({ recipient, msats: 210_000, relays: ['wss://r.example'], lnurl: lnurlEncode('https://x.example/.well-known/lnurlp/bob') }), sender);
|
||||
assert.equal(zr.kind, 9734);
|
||||
assert.deepEqual(zr.tags.find((t) => t[0] === 'amount'), ['amount', '210000']);
|
||||
const receipt = finalizeEvent({ kind: 9735, tags: [['p', recipient], ['P', zr.pubkey], ['bolt11', 'lnbc2100n1pqqqq'], ['description', JSON.stringify(zr)]], content: '' }, server);
|
||||
const z = readZapReceipt(receipt);
|
||||
assert.equal(z.sats, 210);
|
||||
assert.equal(z.from, zr.pubkey);
|
||||
assert.equal(z.to, recipient);
|
||||
assert.equal(z.requestId, zr.id);
|
||||
const lying = finalizeEvent({ kind: 9735, tags: [['p', recipient], ['bolt11', 'lnbc1n1pqqqq'], ['description', JSON.stringify(zr)]], content: '' }, server);
|
||||
assert.equal(readZapReceipt(lying), null, 'a receipt for less than was asked is ignored');
|
||||
});
|
||||
|
||||
test('the slingshot: steps, aim, fence', () => {
|
||||
assert.equal(detentFor(0), -1);
|
||||
assert.equal(detentFor(GEOM.dead - 1), -1);
|
||||
assert.equal(detentFor(GEOM.dead), 0);
|
||||
assert.equal(detentFor(GEOM.max), LADDER.length - 1);
|
||||
assert.equal(detentFor(GEOM.max + 50), LADDER.length - 1);
|
||||
for (let i = 0; i < LADDER.length; i++) assert.equal(detentFor(ringRadius(i)), i, `ring ${i}`);
|
||||
assert.equal(detentForAmount(2100), LADDER.indexOf(2100));
|
||||
assert.equal(detentForAmount(2500), LADDER.indexOf(2100));
|
||||
assert.equal(detentForAmount(0), 0);
|
||||
const angles = fenceAngles(3);
|
||||
assert.equal(angles.length, 3);
|
||||
assert.ok(Math.abs(angles[1] + Math.PI / 2) < 1e-9, 'the middle one is straight up');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const p = fencePoint(angles[i]);
|
||||
const dx = GEOM.rx - p.x, dy = GEOM.ry - p.y;
|
||||
const d = Math.hypot(dx, dy);
|
||||
assert.equal(aimAt((dx / d) * 60, (dy / d) * 60, angles), i, `pulling away from target ${i} aims at it`);
|
||||
}
|
||||
assert.equal(aimAt(0, -60, angles), null, 'pulling up aims down: nobody');
|
||||
assert.deepEqual(clampPull(300, 400).stretch, GEOM.max);
|
||||
});
|
||||
|
||||
test('NWC strings', () => {
|
||||
const wallet = c.hex(c.getPublicKey(c.generatePrivateKey()));
|
||||
const secret = c.hex(c.generatePrivateKey());
|
||||
const p = parseNwcUri(`nostr+walletconnect://${wallet}?relay=wss%3A%2F%2Frelay.example.com&secret=${secret}&lud16=me%40example.com`);
|
||||
assert.equal(p.wallet, wallet);
|
||||
assert.deepEqual(p.relays, ['wss://relay.example.com']);
|
||||
assert.equal(c.hex(p.secret), secret);
|
||||
assert.throws(() => parseNwcUri('https://example.com'));
|
||||
assert.throws(() => parseNwcUri(`nostr+walletconnect://${wallet}?secret=${secret}`));
|
||||
});
|
||||
|
||||
test('against the devnet: AUTH, notes, zaps and a wallet', async (t) => {
|
||||
const net = await startDevnet({ auth: true });
|
||||
t.after(() => net.close());
|
||||
const alice = new LocalSigner(c.unhex(net.people.alice.sk));
|
||||
const bob = new LocalSigner(c.unhex(net.people.bob.sk));
|
||||
const pa = new Pool({ signer: alice }), pb = new Pool({ signer: bob });
|
||||
t.after(() => { pa.close(); pb.close(); });
|
||||
|
||||
// alice's follows and bob's lightning address come back from the relay, verified
|
||||
const evs = await pa.query([net.relay], [{ kinds: [0, 3], authors: [net.people.alice.pk, net.people.bob.pk] }]);
|
||||
const bobProfile = parseProfile(evs.find((e) => e.kind === 0 && e.pubkey === net.people.bob.pk));
|
||||
assert.match(bobProfile.lud16, /^bob@127\.0\.0\.1:\d+$/);
|
||||
assert.ok(evs.find((e) => e.kind === 3).tags.some((t) => t[1] === net.people.bob.pk));
|
||||
|
||||
// a private note: the relay wants AUTH before it shows bob his gift wraps
|
||||
const { toThem } = await sealNote(alice, bob.pubkey, 'for the records');
|
||||
assert.ok((await pa.publish([net.relay], toThem)).ok);
|
||||
const wraps = await pb.query([net.relay], [{ kinds: [1059], '#p': [bob.pubkey] }], { timeout: 4000 });
|
||||
assert.equal(wraps.length, 1, 'bob authenticated and got his note');
|
||||
assert.equal((await openWrap(bob, wraps[0])).content, 'for the records');
|
||||
assert.ok(net.stats.authed >= 1);
|
||||
const strangers = new Pool();
|
||||
t.after(() => strangers.close());
|
||||
assert.equal((await strangers.query([net.relay], [{ kinds: [1059], '#p': [bob.pubkey] }], { timeout: 1500 })).length, 0, 'no AUTH, no notes');
|
||||
|
||||
// a zap: invoice for exactly 210 sats, paid through the NWC wallet, receipt published
|
||||
const ep = await fetchPayEndpoint(payUrlFor(bobProfile));
|
||||
assert.ok(ep.allowsNostr);
|
||||
const zr = await alice.signEvent(zapRequestTemplate({ recipient: bob.pubkey, msats: 210_000, relays: [net.relay], lnurl: lnurlEncode(ep.url) }));
|
||||
const pr = await fetchInvoice(ep, 210_000, zr);
|
||||
assert.equal(invoiceMsats(pr), 210_000);
|
||||
await assert.rejects(fetchInvoice(ep, 999, null), /sats at a time/);
|
||||
const wallet = await NwcWallet.connect(net.nwc, pa);
|
||||
assert.equal(wallet.encryption, 'nip44');
|
||||
const paid = await wallet.pay(pr);
|
||||
assert.match(paid.preimage, /^[0-9a-f]{64}$/);
|
||||
const receipts = await pb.query([net.relay], [{ kinds: [9735], '#p': [bob.pubkey] }]);
|
||||
const z = readZapReceipt(receipts[0]);
|
||||
assert.equal(z.sats, 210);
|
||||
assert.equal(z.from, alice.pubkey);
|
||||
await assert.rejects(wallet.pay('lnbc10n1pnope'), /no route/);
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SLYNG in Chromium (Playwright), desktop and phone.
|
||||
|
||||
Demo: a mouse sling, a touch sling, a keyboard sling, a note on its own, and a link an agent made.
|
||||
Live, against tools/devnet.mjs (a local relay with AUTH, a lightning address server and a wallet): alice slings
|
||||
bob 210 sats and a note; bob catches both; alice sees the zap receipt come back as caught.
|
||||
|
||||
python3 tests/smoke.py [--shots DIR] [--extra DIR] DIR gets desktop.png and phone.png; --extra the live ones
|
||||
|
||||
Needs node 20+ and the playwright package with Chromium.
|
||||
"""
|
||||
import argparse, functools, http.server, json, os, socketserver, subprocess, sys, threading, time
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SITE = ROOT
|
||||
|
||||
DRAG_POINTS = """(a) => {
|
||||
const svg = document.getElementById('sling');
|
||||
const r = svg.getBoundingClientRect();
|
||||
const s = r.width / 420;
|
||||
const R = { x: r.left + 210 * s, y: r.top + (300 - 34) * s };
|
||||
const t = [...svg.querySelectorAll('.target')].find((g) => g.querySelector('.target-name').textContent === a.name);
|
||||
if (!t) return null;
|
||||
const m = /translate\\(([-\\d.]+) ([-\\d.]+)\\)/.exec(t.getAttribute('transform'));
|
||||
const dx = Number(m[1]) - 210, dy = Number(m[2]) - 300, d = Math.hypot(dx, dy);
|
||||
const rr = (22 + (96 * a.detent) / 8 + 1) * s;
|
||||
return { sx: R.x, sy: R.y, ex: R.x - (dx / d) * rr, ey: R.y - (dy / d) * rr };
|
||||
}"""
|
||||
|
||||
WEBLN = """(ln) => {
|
||||
window.paid = [];
|
||||
window.webln = {
|
||||
enable: async () => {},
|
||||
getInfo: async () => ({ node: { alias: 'Test wallet' } }),
|
||||
sendPayment: async (pr) => {
|
||||
const r = await fetch(ln + '/settle?pr=' + encodeURIComponent(pr));
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.error || 'payment failed');
|
||||
window.paid.push(pr);
|
||||
return { preimage: j.preimage };
|
||||
},
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Quiet(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
def serve(directory):
|
||||
handler = functools.partial(Quiet, directory=str(directory))
|
||||
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), handler)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd, f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
|
||||
|
||||
def drag(page, name, detent, touch=False):
|
||||
pts = page.evaluate(DRAG_POINTS, {"name": name, "detent": detent})
|
||||
assert pts, f"{name} is not on the fence"
|
||||
if touch:
|
||||
cdp = page.context.new_cdp_session(page)
|
||||
def ev(kind, x, y):
|
||||
cdp.send("Input.dispatchTouchEvent", {"type": kind, "touchPoints": [] if kind == "touchEnd" else [{"x": x, "y": y}]})
|
||||
ev("touchStart", pts["sx"], pts["sy"])
|
||||
for i in range(1, 11):
|
||||
ev("touchMove", pts["sx"] + (pts["ex"] - pts["sx"]) * i / 10, pts["sy"] + (pts["ey"] - pts["sy"]) * i / 10)
|
||||
page.wait_for_timeout(16)
|
||||
ev("touchEnd", 0, 0)
|
||||
else:
|
||||
page.mouse.move(pts["sx"], pts["sy"])
|
||||
page.mouse.down()
|
||||
page.mouse.move(pts["ex"], pts["ey"], steps=10)
|
||||
page.wait_for_timeout(60)
|
||||
page.mouse.up()
|
||||
|
||||
|
||||
def watch(page, errors, label):
|
||||
page.on("pageerror", lambda e: errors.append(f"[{label}] {e}"))
|
||||
page.on("console", lambda m: m.type == "error" and errors.append(f"[{label}] console: {m.text}"))
|
||||
|
||||
|
||||
def ready(page):
|
||||
page.wait_for_selector("html[data-ready=true]", timeout=15000)
|
||||
page.evaluate("document.fonts.ready")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--shots", default=None, help="a folder for desktop.png and phone.png (default: none)")
|
||||
ap.add_argument("--extra", default=None, help="a folder for the live screenshots (bob catching, alice slinging)")
|
||||
args = ap.parse_args()
|
||||
shots = Path(args.shots) if args.shots else None
|
||||
if shots:
|
||||
shots.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
devnet = subprocess.Popen(["node", str(ROOT / "tools" / "devnet.mjs"), "--auth"], stdout=subprocess.PIPE, text=True)
|
||||
net = json.loads(devnet.stdout.readline())
|
||||
httpd, base = serve(SITE)
|
||||
errors = []
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--no-sandbox"])
|
||||
|
||||
# ---- demo, desktop
|
||||
ctx = browser.new_context(viewport={"width": 1440, "height": 900})
|
||||
page = ctx.new_page()
|
||||
watch(page, errors, "desktop")
|
||||
page.goto(base + "/")
|
||||
ready(page)
|
||||
page.wait_for_timeout(250)
|
||||
if shots:
|
||||
page.screenshot(path=str(shots / "desktop.png"))
|
||||
expect(page.locator("#amt")).to_have_text("Pull back to sling")
|
||||
drag(page, "ben", 5)
|
||||
expect(page.locator("#amt")).to_have_text("Slung", timeout=4000)
|
||||
expect(page.locator("#sub")).to_contain_text("2,100 sats to ben")
|
||||
expect(page.locator("#wallet")).to_have_text("18,900 demo sats")
|
||||
expect(page.locator("#caught .entry")).to_have_count(1, timeout=4000)
|
||||
expect(page.locator("#caught .entry").first).to_contain_text("ben slung you a note")
|
||||
# a note on its own, to dev, who has no lightning address
|
||||
page.fill("#note", "see you at the market")
|
||||
drag(page, "dev", 3)
|
||||
expect(page.locator("#amt")).to_have_text("Notes only")
|
||||
page.wait_for_timeout(400)
|
||||
drag(page, "dev", 0)
|
||||
expect(page.locator("#amt")).to_have_text("Slung", timeout=4000)
|
||||
page.click("#t-slung")
|
||||
expect(page.locator("#slung .entry")).to_have_count(2)
|
||||
expect(page.locator("#slung .entry").first).to_contain_text("see you at the market")
|
||||
# keyboard: aim at ana with the arrows, pull back three rings, let go
|
||||
page.focus("#sling")
|
||||
for _ in range(4):
|
||||
page.keyboard.press("ArrowDown")
|
||||
expect(page.locator("#amt")).to_have_text("210 sats")
|
||||
expect(page.locator("#sub")).to_have_text("to ana")
|
||||
page.keyboard.press("Enter")
|
||||
expect(page.locator("#amt")).to_have_text("Slung", timeout=4000)
|
||||
# catch it back
|
||||
page.focus("#sling")
|
||||
page.keyboard.press("ArrowRight")
|
||||
for _ in range(2):
|
||||
page.keyboard.press("ArrowDown")
|
||||
page.keyboard.press("Enter")
|
||||
page.click("#catchback")
|
||||
expect(page.locator("#amt")).to_have_text("Caught it back", timeout=3000)
|
||||
expect(page.locator("#slung .entry")).to_have_count(3)
|
||||
ctx.close()
|
||||
|
||||
# ---- demo, phone, with a touch sling and a link an agent made
|
||||
ctx = browser.new_context(viewport={"width": 390, "height": 844}, device_scale_factor=2, is_mobile=True, has_touch=True)
|
||||
page = ctx.new_page()
|
||||
watch(page, errors, "phone")
|
||||
page.goto(base + "/")
|
||||
ready(page)
|
||||
page.wait_for_timeout(250)
|
||||
if shots:
|
||||
page.screenshot(path=str(shots / "phone.png"))
|
||||
drag(page, "cass", 4, touch=True)
|
||||
expect(page.locator("#amt")).to_have_text("Slung", timeout=4000)
|
||||
expect(page.locator("#sub")).to_contain_text("1,000 sats to cass")
|
||||
page.goto(base + f"/#to={net['people']['bob']['npub']}&sats=2500¬e=for%20the%20records")
|
||||
page.reload()
|
||||
ready(page)
|
||||
expect(page.locator("#aimed")).to_contain_text("2,100 sats")
|
||||
expect(page.locator("#note")).to_have_value("for the records")
|
||||
assert page.locator(".target-name").first.text_content().startswith("npub1"), "the linked person stands first on the fence"
|
||||
assert page.locator("#sling.suggesting").count() == 1
|
||||
ctx.close()
|
||||
|
||||
# ---- live: alice slings bob 210 sats and a note; bob catches both
|
||||
live = f"{base}/?relays={net['relay']}&flight=500"
|
||||
bob_ctx = browser.new_context(viewport={"width": 1280, "height": 860})
|
||||
bob_ctx.add_init_script(f"sessionStorage.setItem('slyng.account', JSON.stringify({{type: 'local', sk: '{net['people']['bob']['sk']}'}}))")
|
||||
bob = bob_ctx.new_page()
|
||||
watch(bob, errors, "bob")
|
||||
bob.goto(live)
|
||||
ready(bob)
|
||||
expect(bob.locator("#account")).to_have_text("bob", timeout=10000)
|
||||
|
||||
ctx = browser.new_context(viewport={"width": 1280, "height": 860})
|
||||
ctx.add_init_script(f"sessionStorage.setItem('slyng.account', JSON.stringify({{type: 'local', sk: '{net['people']['alice']['sk']}'}}))")
|
||||
ctx.add_init_script(f"({WEBLN})('{net['ln']}')")
|
||||
alice = ctx.new_page()
|
||||
watch(alice, errors, "alice")
|
||||
alice.goto(live)
|
||||
ready(alice)
|
||||
expect(alice.locator("#account")).to_have_text("alice", timeout=10000)
|
||||
expect(alice.locator(".target-name", has_text="bob")).to_have_count(1, timeout=10000)
|
||||
expect(alice.locator(".target-name", has_text="carol")).to_have_count(1)
|
||||
alice.click("#wallet")
|
||||
alice.click("#wal-webln")
|
||||
expect(alice.locator("#wallet")).to_have_text("Test wallet", timeout=5000)
|
||||
alice.fill("#note", "for the records")
|
||||
drag(alice, "bob", 3)
|
||||
expect(alice.locator("#amt")).to_have_text("Slung", timeout=10000)
|
||||
expect(alice.locator("#sub")).to_have_text("210 sats and a note to bob.")
|
||||
assert alice.evaluate("window.paid.length") == 1
|
||||
alice.click("#t-slung")
|
||||
expect(alice.locator("#slung .entry").first).to_contain_text("Caught", timeout=10000)
|
||||
|
||||
expect(bob.locator("#caught .entry")).to_have_count(2, timeout=10000)
|
||||
expect(bob.locator("#caught")).to_contain_text("alice slung you 210 sats")
|
||||
expect(bob.locator("#caught")).to_contain_text("for the records")
|
||||
# carol has no lightning address: sats are refused before anything flies
|
||||
drag(alice, "carol", 4)
|
||||
expect(alice.locator("#amt")).to_have_text("Notes only")
|
||||
if args.extra:
|
||||
Path(args.extra).mkdir(parents=True, exist_ok=True)
|
||||
bob.screenshot(path=str(Path(args.extra) / "bob.png"))
|
||||
alice.screenshot(path=str(Path(args.extra) / "alice.png"))
|
||||
ctx.close()
|
||||
bob_ctx.close()
|
||||
browser.close()
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
devnet.terminate()
|
||||
if errors:
|
||||
print("\n".join(errors))
|
||||
sys.exit(1)
|
||||
print("smoke: demo (mouse, touch, keyboard, note, catch back, agent link) and live (zap + note via devnet) all landed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Pull-and-release loop for the chosen icon -> animated WebP (+ storyboard SVGs).
|
||||
|
||||
Timeline at 30 fps, 90 frames (3 s): rest, pull back, hold, release (stone flies
|
||||
up and away while the band snaps past straight and rings down), empty, reload
|
||||
(a new stone drops in and the band catches it), rest."""
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
from PIL import Image
|
||||
from playwright.sync_api import sync_playwright
|
||||
from kit import chromium
|
||||
import direction as d
|
||||
|
||||
FPS = 30
|
||||
N = 90
|
||||
OUT = pathlib.Path(__file__).parent / "out"
|
||||
DRAW = d.S * 0.12
|
||||
|
||||
|
||||
def ease_io(t):
|
||||
return 4 * t ** 3 if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2
|
||||
|
||||
|
||||
def state(fr):
|
||||
"""Return kwargs for icon_svg at frame fr."""
|
||||
cx = d.CX
|
||||
y_rest, y_draw, y_tip = d.P_REST[1], d.UB - DRAW, d.TIP_Y
|
||||
if fr < 12:
|
||||
return dict()
|
||||
if fr < 30:
|
||||
u = ease_io((fr - 12) / 18)
|
||||
y = y_rest + (y_draw - y_rest) * u
|
||||
return dict(pouch=(cx, y), stone_scale=1 + 0.1 * u)
|
||||
if fr < 36:
|
||||
jx = 4.0 * math.sin((fr - 30) * 2.6)
|
||||
return dict(pouch=(cx + jx, y_draw), stone=(cx + jx, y_draw), stone_scale=1.1)
|
||||
if fr < 39:
|
||||
t = (fr - 35) / 4 # 0.25..0.75
|
||||
y = y_draw + (y_tip - y_draw) * t * t
|
||||
return dict(pouch=(cx, y), stone=(cx, y), stone_scale=1.1)
|
||||
# band rings down around straight
|
||||
tau = (fr - 39) / FPS
|
||||
band_y = y_tip + d.S * 0.085 * math.exp(-6.5 * tau) * math.sin(2 * math.pi * 3.0 * tau)
|
||||
out = dict(pouch=(cx, band_y), stone=False)
|
||||
if fr < 43:
|
||||
k = (fr - 39 + 1) / 4
|
||||
sy = y_tip + (d.S + 140 - y_tip) * k
|
||||
out.update(stone=(cx, sy), stone_scale=1.1 - 0.55 * k)
|
||||
return out
|
||||
if fr < 66:
|
||||
return out
|
||||
# reload: a new stone falls in and the band catches it
|
||||
if fr < 75:
|
||||
t = (fr - 66 + 1) / 9
|
||||
y0 = d.S + 90
|
||||
sy = y0 + (y_rest - y0) * t * t
|
||||
py = min(band_y, sy) if sy < y_tip else band_y
|
||||
return dict(pouch=(cx, py), stone=(cx, sy))
|
||||
tau = (fr - 74) / FPS
|
||||
sy = y_rest - d.S * 0.05 * math.exp(-7 * tau) * math.sin(2 * math.pi * 2.6 * tau)
|
||||
return dict(pouch=(cx, sy), stone=(cx, sy))
|
||||
|
||||
|
||||
def render(px=512, out_name="icon-pull-release.webp"):
|
||||
frames = []
|
||||
with sync_playwright() as p:
|
||||
b = chromium(p)
|
||||
pg = b.new_page(viewport={"width": px, "height": px})
|
||||
pg.set_content('<html><body style="margin:0;background:transparent"><div id="a"></div></body></html>')
|
||||
for fr in range(N):
|
||||
svg = d.icon_svg(size=px, **state(fr))
|
||||
pg.evaluate("s => { document.getElementById('a').innerHTML = s; }", svg)
|
||||
path = OUT / f"_frame{fr:03d}.png"
|
||||
pg.locator("svg").screenshot(path=str(path), omit_background=True)
|
||||
frames.append(Image.open(path).convert("RGBA"))
|
||||
b.close()
|
||||
frames[0].save(OUT / out_name, save_all=True, append_images=frames[1:], duration=int(1000 / FPS),
|
||||
loop=0, quality=92, method=6)
|
||||
for fp in OUT.glob("_frame*.png"):
|
||||
fp.unlink()
|
||||
return OUT / out_name
|
||||
|
||||
|
||||
def storyboard():
|
||||
y_draw = d.UB - DRAW
|
||||
keys = {
|
||||
"story-1-loaded": dict(),
|
||||
"story-2-drawn": dict(pouch=(d.CX, y_draw), stone_scale=1.1),
|
||||
"story-3-release": dict(pouch=(d.CX, d.TIP_Y + d.S * 0.06), stone=(d.CX, d.S * 0.95), stone_scale=0.62),
|
||||
"story-4-empty": dict(pouch=(d.CX, d.TIP_Y), stone=False),
|
||||
"story-5-reload": dict(pouch=(d.CX, d.TIP_Y), stone=(d.CX, d.S * 0.9)),
|
||||
}
|
||||
for name, kw in keys.items():
|
||||
(OUT / f"{name}.svg").write_text(d.icon_svg(label=f"SLYNG icon, {name.split('-')[-1]}", **kw))
|
||||
return list(keys)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from PIL import features
|
||||
print("webp anim:", features.check("webp_anim"))
|
||||
print(storyboard())
|
||||
print(render())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Animated wordmark: the icon-Y does the pull-and-release inside SLYNG."""
|
||||
import pathlib
|
||||
from PIL import Image
|
||||
from playwright.sync_api import sync_playwright
|
||||
from kit import chromium
|
||||
import direction as d
|
||||
import anim
|
||||
|
||||
OUT = pathlib.Path(__file__).parent / "out"
|
||||
|
||||
|
||||
def render(kind="unbounded", tone="dark", width=960):
|
||||
mk0 = d.wordmark_icon_y(kind, tone)
|
||||
box = mk0.box
|
||||
frames = []
|
||||
with sync_playwright() as p:
|
||||
b = chromium(p)
|
||||
pg = b.new_page(viewport={"width": width, "height": 400})
|
||||
pg.set_content(f'<html><body style="margin:0;background:transparent"><div id="a" style="width:{width}px"></div></body></html>')
|
||||
for fr in range(anim.N):
|
||||
mk = d.wordmark_icon_y(kind, tone, state=anim.state(fr), uid=f"f{fr}")
|
||||
svg, _ = mk.svg(pad=(24, 24, 24, 24), box=box, label="SLYNG")
|
||||
svg = svg.replace("<svg ", '<svg style="display:block;width:100%;height:auto" ', 1)
|
||||
pg.evaluate("s => { document.getElementById('a').innerHTML = s; }", svg)
|
||||
path = OUT / f"_wm{fr:03d}.png"
|
||||
pg.locator("#a svg").screenshot(path=str(path), omit_background=True)
|
||||
frames.append(Image.open(path).convert("RGBA"))
|
||||
b.close()
|
||||
name = f"wm-anim-{kind}-{tone}.webp"
|
||||
frames[0].save(OUT / name, save_all=True, append_images=frames[1:], duration=int(1000 / anim.FPS),
|
||||
loop=0, quality=92, method=6)
|
||||
for fp in OUT.glob("_wm*.png"):
|
||||
fp.unlink()
|
||||
return OUT / name, frames[0].size
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for tone in ("dark", "light"):
|
||||
print(render("unbounded", tone))
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Animated wordmark with the bare slingshot as the Y (same motion as the icon loop).
|
||||
|
||||
Without the icon's square to clip it, the stone flies out through the top edge
|
||||
of the image and the reload drops in through the same edge, so the image gets
|
||||
headroom above the word."""
|
||||
import math
|
||||
import pathlib
|
||||
from PIL import Image
|
||||
from playwright.sync_api import sync_playwright
|
||||
from kit import chromium
|
||||
import direction as d
|
||||
import anim
|
||||
import slingy
|
||||
|
||||
OUT = pathlib.Path(__file__).parent / "out"
|
||||
TOP_Y = d.S + 300 # icon units: where the stone leaves / re-enters (beyond the image top)
|
||||
|
||||
|
||||
def state2(fr):
|
||||
st = anim.state(fr)
|
||||
cx = d.CX
|
||||
y_tip, y_rest = d.TIP_Y, d.P_REST[1]
|
||||
if 39 <= fr < 43: # flight: same timing, but out past the image top
|
||||
k = (fr - 39 + 1) / 4
|
||||
st["stone"] = (cx, y_tip + (TOP_Y - y_tip) * k)
|
||||
st["stone_scale"] = 1.1 - 0.5 * k
|
||||
if 66 <= fr < 75: # reload: fall from beyond the image top
|
||||
t = (fr - 66 + 1) / 9
|
||||
sy = TOP_Y + (y_rest - TOP_Y) * t * t
|
||||
tau = (fr - 39) / anim.FPS
|
||||
band_y = y_tip + d.S * 0.085 * math.exp(-6.5 * tau) * math.sin(2 * math.pi * 3.0 * tau)
|
||||
py = min(band_y, sy) if sy < y_tip else band_y
|
||||
st = dict(pouch=(cx, py), stone=(cx, sy))
|
||||
return st
|
||||
|
||||
|
||||
def render(kind="unbounded", tone="dark", width=960):
|
||||
face = d.WORDMARKS[kind]["face"]
|
||||
H = face.cap
|
||||
box = slingy.bare_wordmark(kind, tone).box
|
||||
pad = (24, 0.5 * H, 24, 0.5 * H) # headroom above for the stone, same below so the word stays centred
|
||||
frames = []
|
||||
with sync_playwright() as p:
|
||||
b = chromium(p)
|
||||
pg = b.new_page(viewport={"width": width, "height": 500})
|
||||
pg.set_content(f'<html><body style="margin:0;background:transparent"><div id="a" style="width:{width}px"></div></body></html>')
|
||||
for fr in range(anim.N):
|
||||
mk = slingy.bare_wordmark(kind, tone, state=state2(fr))
|
||||
svg, _ = mk.svg(pad=pad, box=box, label="SLYNG")
|
||||
svg = svg.replace("<svg ", '<svg style="display:block;width:100%;height:auto" ', 1)
|
||||
pg.evaluate("s => { document.getElementById('a').innerHTML = s; }", svg)
|
||||
path = OUT / f"_wb{fr:03d}.png"
|
||||
pg.locator("#a svg").screenshot(path=str(path), omit_background=True)
|
||||
frames.append(Image.open(path).convert("RGBA"))
|
||||
b.close()
|
||||
name = f"wm-sling-anim-{kind}-{tone}.webp"
|
||||
frames[0].save(OUT / name, save_all=True, append_images=frames[1:], duration=int(1000 / anim.FPS),
|
||||
loop=0, quality=92, method=6)
|
||||
for fp in OUT.glob("_wb*.png"):
|
||||
fp.unlink()
|
||||
return OUT / name, frames[0].size
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for tone in ("dark", "light-volt"):
|
||||
print(render("unbounded", tone))
|
||||
@@ -0,0 +1,360 @@
|
||||
"""The SLYNG logo concepts. Each cN() returns a Mark (font units, y up)."""
|
||||
import math
|
||||
from kit import *
|
||||
from sling import make_y, P, f
|
||||
|
||||
LEX = Face("Lexend.ttf", wght=800)
|
||||
ARW = Face("Archivo.ttf", wght=900, wdth=125)
|
||||
FRED = Face("Fredoka.ttf", wght=600, wdth=100)
|
||||
SORA = Face("Sora.ttf", wght=800)
|
||||
SORA_L = Face("Sora.ttf", wght=400)
|
||||
UNB = Face("Unbounded.ttf", wght=800)
|
||||
BSD = Face("BigShouldersDisplay.ttf", wght=900)
|
||||
BSD8 = Face("BigShouldersDisplay.ttf", wght=800)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 1 Loaded
|
||||
def c1(ink=INK, red=RED, ground=BONE):
|
||||
face = LEX
|
||||
H, stem = face.cap, face.stem("L")[0]
|
||||
outer = 396
|
||||
mk = Mark()
|
||||
pos, _ = word(mk, face, "SLYNG", ink, slot="Y", slot_adv=2 * outer + 34,
|
||||
kern={"LY": -4, "YN": 22})
|
||||
cx = pos[2][1] + outer + 17
|
||||
Y = make_y(cx, H, stem * 0.93, stem * 0.82, outer, H * 0.30, r_u=70, r_o=34, tips="round")
|
||||
mk.add(ypath(Y, ink), y_box(Y))
|
||||
L, R = Y.anchors()
|
||||
ub = Y.geometry()["u_bottom"]
|
||||
Pt = (cx, ub + 64 + 20)
|
||||
mk.add(band(L, Pt, R, 44, red))
|
||||
mk.add(dot(Pt, 64, red))
|
||||
return mk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 2 Full draw
|
||||
def c2(ink=INK, band_c=WHITE, ground=RED):
|
||||
face = ARW
|
||||
H, stem = face.cap, face.stem("L")[0]
|
||||
outer = 430
|
||||
mk = Mark()
|
||||
pos, _ = word(mk, face, "SLYNG", ink, slot="Y", slot_adv=2 * outer + 36, kern={"LY": -30})
|
||||
cx = pos[2][1] + outer + 18
|
||||
Y = make_y(cx, H, stem * 0.92, stem * 0.82, outer, H * 0.40, r_u=70, r_o=40, tips="round")
|
||||
L, R = Y.anchors()
|
||||
Pt = (cx, H * -0.5)
|
||||
# the band runs behind the frame: drawn first
|
||||
mk.add(band(L, Pt, R, 40, band_c) + dot(Pt, 62, band_c))
|
||||
mk.add(ypath(Y, ink), y_box(Y))
|
||||
mk.grow(cx - 100, Pt[1] - 62, cx + 100, H)
|
||||
return mk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 3 Pocket (lowercase)
|
||||
def c3(ink=INK, red=RED, ground=PERI):
|
||||
face = FRED
|
||||
xh = face.xh
|
||||
stem = face.hscan("l", xh * 0.5)
|
||||
stem = stem[1] - stem[0]
|
||||
outer = 290
|
||||
mk = Mark()
|
||||
pos, _ = word(mk, face, "slyng", ink, slot="y", slot_adv=2 * outer + 24,
|
||||
kern={"ly": 26, "yn": 14})
|
||||
cx = pos[2][1] + outer + 12
|
||||
Y = make_y(cx, xh, stem * 1.0, stem * 0.92, outer, xh * 0.06, base=-232,
|
||||
r_u=58, r_o=40, tips="round", bottom="round", tip_over=6)
|
||||
mk.add(ypath(Y, ink), y_box(Y))
|
||||
L, R = Y.anchors()
|
||||
ub = Y.geometry()["u_bottom"]
|
||||
Pt = (cx, ub + 46 + 16)
|
||||
mk.add(band(L, Pt, R, 34, red))
|
||||
mk.add(dot(Pt, 46, red))
|
||||
return mk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 4 Landing dot
|
||||
def c4(suffix="app", ink=INK, red=RED, ground=WHITE, trail=True):
|
||||
face = SORA
|
||||
H, stem = face.cap, face.stem("L")[0]
|
||||
outer = 352
|
||||
mk = Mark()
|
||||
pos, x = word(mk, face, "SLYNG", ink, slot="Y", slot_adv=2 * outer + 30,
|
||||
kern={"LY": -6, "YN": 20})
|
||||
cx = pos[2][1] + outer + 15
|
||||
Y = make_y(cx, H, stem * 0.93, stem * 0.84, outer, H * 0.30, r_u=62, r_o=30, tips="round")
|
||||
mk.add(ypath(Y, ink), y_box(Y))
|
||||
L, R = Y.anchors()
|
||||
# released band: straight across the tips, the pouch is empty
|
||||
mk.add(f'<path d="M{f(L[0])} {f(L[1])} L{f(R[0])} {f(R[1])}" fill="none" stroke="{red}" '
|
||||
f'stroke-width="40" stroke-linecap="round"/>')
|
||||
# the landed stone = the domain dot
|
||||
rd = stem * 0.56
|
||||
dx = x + 40 + rd
|
||||
D = (dx, rd)
|
||||
mk.add(dot(D, rd, red))
|
||||
mk.grow(dx - rd, 0, dx + rd, 2 * rd)
|
||||
sx = dx + rd + 36
|
||||
word(mk, SORA_L, suffix, ink, x0=sx)
|
||||
if trail:
|
||||
S0 = (cx, H + 70)
|
||||
C1 = (cx + 0.30 * (D[0] - cx), H * 2.05)
|
||||
C2 = (D[0] + 24, H * 1.95)
|
||||
pts = []
|
||||
n = 80
|
||||
for i in range(n + 1):
|
||||
t = i / n * 0.9
|
||||
mt = 1 - t
|
||||
bx = mt ** 3 * S0[0] + 3 * mt * mt * t * C1[0] + 3 * mt * t * t * C2[0] + t ** 3 * D[0]
|
||||
by = mt ** 3 * S0[1] + 3 * mt * mt * t * C1[1] + 3 * mt * t * t * C2[1] + t ** 3 * D[1]
|
||||
pts.append((bx, by))
|
||||
d = "M" + " L".join(f"{f(a)} {f(b)}" for a, b in pts)
|
||||
mk.add(f'<path d="{d}" fill="none" stroke="{red}" stroke-width="26" stroke-linecap="round" '
|
||||
f'stroke-dasharray="0 64"/>')
|
||||
for a, b in pts:
|
||||
mk.grow(a - 13, b - 13, a + 13, b + 13)
|
||||
return mk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 5 App icon
|
||||
def squircle(cx, cy, r, n=5.0, k=240):
|
||||
pts = []
|
||||
for i in range(k):
|
||||
t = 2 * math.pi * i / k
|
||||
c, s = math.cos(t), math.sin(t)
|
||||
x = cx + r * math.copysign(abs(c) ** (2 / n), c)
|
||||
y = cy + r * math.copysign(abs(s) ** (2 / n), s)
|
||||
pts.append(f"{f(x)} {f(y)}")
|
||||
return "M" + " L".join(pts) + " Z"
|
||||
|
||||
|
||||
def icon_mark(frame=WHITE, red=RED, ground=INK, size=1024, band_c=None):
|
||||
band_c = band_c or red
|
||||
mk = Mark()
|
||||
s = size
|
||||
mk.add(f'<path d="{squircle(s / 2, s / 2, s / 2)}" fill="{ground}"/>', (0, 0, s, s))
|
||||
H = s * 0.70
|
||||
base = s * 0.14
|
||||
cx = s / 2
|
||||
Y = make_y(cx, H, s * 0.15, s * 0.14, s * 0.30, H * 0.40, base=0,
|
||||
r_u=s * 0.075, r_o=s * 0.04, tips="round", bottom="round")
|
||||
g = f'<g transform="translate(0 {f(base)})">'
|
||||
g += ypath(Y, frame)
|
||||
L, R = Y.anchors()
|
||||
ub = Y.geometry()["u_bottom"]
|
||||
Pt = (cx, ub + s * 0.065 + s * 0.02)
|
||||
g += band(L, Pt, R, s * 0.045, band_c)
|
||||
g += dot(Pt, s * 0.065, band_c)
|
||||
g += "</g>"
|
||||
mk.add(g)
|
||||
return mk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 6 Price sticker
|
||||
def hanging_tag(cx, top, w, h, fill, hole_fill, string=None, sw=0):
|
||||
"""A price tag hanging point-up from (cx, top): the hole sits just under the point."""
|
||||
p = w / 2
|
||||
tip = w * 0.62
|
||||
pts = [(cx, top), (cx + p, top - tip), (cx + p, top - h), (cx - p, top - h), (cx - p, top - tip)]
|
||||
d = "M" + " L".join(f"{f(x)} {f(y)}" for x, y in pts) + " Z"
|
||||
r = w * 0.13
|
||||
return (f'<path d="{d}" fill="{fill}" stroke="{fill}" stroke-width="{f(w * 0.16)}" stroke-linejoin="round"/>'
|
||||
f'<circle cx="{f(cx)}" cy="{f(top - tip * 0.62)}" r="{f(r)}" fill="{hole_fill}"/>')
|
||||
|
||||
|
||||
def star4(cx, cy, r, fill):
|
||||
k = 0.3
|
||||
pts = []
|
||||
for i in range(8):
|
||||
a = math.pi / 2 + i * math.pi / 4
|
||||
rr = r if i % 2 == 0 else r * k
|
||||
pts.append((cx + rr * math.cos(a), cy + rr * math.sin(a)))
|
||||
return '<path d="M' + " L".join(f"{f(x)} {f(y)}" for x, y in pts) + f' Z" fill="{fill}"/>'
|
||||
|
||||
|
||||
def arc_text(face, text, R, scale, fill, where="top", track=0):
|
||||
"""Upright text along the top (clockwise, baseline radius R) or the bottom
|
||||
(counter-clockwise, baseline radius R, letters pointing inward) of a circle."""
|
||||
advs = [(face.adv(ch) + track) * scale for ch in text]
|
||||
total = sum(advs) - track * scale
|
||||
out = []
|
||||
if where == "top":
|
||||
th = math.pi / 2 + (total / R) / 2
|
||||
for ch, adv in zip(text, advs):
|
||||
gw = face.adv(ch) * scale
|
||||
mid = th - (gw / 2) / R
|
||||
c, s_ = math.cos(mid), math.sin(mid)
|
||||
tx, ty = s_, -c
|
||||
e = (R * c - (gw / 2) * tx, R * s_ - (gw / 2) * ty)
|
||||
out.append(f'<path transform="matrix({s_ * scale:.5f} {-c * scale:.5f} {c * scale:.5f} '
|
||||
f'{s_ * scale:.5f} {e[0]:.1f} {e[1]:.1f})" d="{face.path(ch)}" fill="{fill}"/>')
|
||||
th -= adv / R
|
||||
else:
|
||||
th = 1.5 * math.pi - (total / R) / 2
|
||||
for ch, adv in zip(text, advs):
|
||||
gw = face.adv(ch) * scale
|
||||
mid = th + (gw / 2) / R
|
||||
c, s_ = math.cos(mid), math.sin(mid)
|
||||
tx, ty = -s_, c
|
||||
e = (R * c - (gw / 2) * tx, R * s_ - (gw / 2) * ty)
|
||||
out.append(f'<path transform="matrix({-s_ * scale:.5f} {c * scale:.5f} {-c * scale:.5f} '
|
||||
f'{-s_ * scale:.5f} {e[0]:.1f} {e[1]:.1f})" d="{face.path(ch)}" fill="{fill}"/>')
|
||||
th += adv / R
|
||||
return "".join(out), total / R
|
||||
|
||||
|
||||
def c6(ink=INK, sticker=DAYGLO, ground=CONCRETE, rot=-8):
|
||||
face = BSD
|
||||
H, stem = face.cap, face.stem("L")[0]
|
||||
Rs = 2700
|
||||
outer = 640
|
||||
wordmk = Mark()
|
||||
pos, x = word(wordmk, face, "SLYNG", ink, slot="Y", slot_adv=2 * outer + 40,
|
||||
kern={"LY": -60, "YN": 10})
|
||||
cx = pos[2][1] + outer + 20
|
||||
Y = make_y(cx, H, stem * 0.9, stem * 0.76, outer, H * 0.34, r_u=130, r_o=50, tips="round")
|
||||
wordmk.add(ypath(Y, ink), y_box(Y))
|
||||
L, R = Y.anchors()
|
||||
ub = Y.geometry()["u_bottom"]
|
||||
knot = (cx, ub + 560)
|
||||
wordmk.add(band(L, knot, R, 66, ink))
|
||||
wordmk.add(hanging_tag(cx, knot[1] + 20, 300, 500, ink, sticker))
|
||||
wb = wordmk.box
|
||||
ww = wb[2] - wb[0]
|
||||
sc = (Rs * 1.30) / ww
|
||||
tx = -(wb[0] + ww / 2) * sc
|
||||
ty = -(H / 2) * sc
|
||||
ring_out, ring_in = Rs - 120, Rs - 700
|
||||
cap_s = 0.24
|
||||
capu = BSD8.cap * cap_s
|
||||
mid_r = (ring_out + ring_in) / 2
|
||||
body = f'<circle cx="0" cy="0" r="{Rs}" fill="{sticker}"/>'
|
||||
body += f'<circle cx="0" cy="0" r="{ring_out}" fill="none" stroke="{ink}" stroke-width="36"/>'
|
||||
body += f'<circle cx="0" cy="0" r="{ring_in}" fill="none" stroke="{ink}" stroke-width="22"/>'
|
||||
body += f'<g transform="translate({f(tx)} {f(ty)}) scale({sc:.5f})">{"".join(wordmk.parts)}</g>'
|
||||
top, _ = arc_text(BSD8, "LOCAL GOODS", mid_r - capu / 2, cap_s, ink, "top", track=90)
|
||||
bot, _ = arc_text(BSD8, "DIGITAL GOODS", mid_r + capu / 2, cap_s, ink, "bottom", track=90)
|
||||
body += top + bot
|
||||
body += star4(-mid_r, 0, 110, ink) + star4(mid_r, 0, 110, ink)
|
||||
shadow = f'<circle cx="46" cy="-80" r="{Rs}" fill="#000" opacity="0.14"/>'
|
||||
mk = Mark()
|
||||
mk.add(f'<g transform="rotate({-rot})">{shadow}{body}</g>', (-Rs - 80, -Rs - 130, Rs + 130, Rs + 40))
|
||||
return mk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 7 Monoline
|
||||
def c7(line=WHITE, accent=VOLT, ground=COBALT, construct=True):
|
||||
H = 700
|
||||
t = 62
|
||||
lo, hi = t / 2, H - t / 2
|
||||
gap = 74
|
||||
mk = Mark()
|
||||
strokes = []
|
||||
guides = []
|
||||
st = lambda d, w=t, c=line: (f'<path d="{d}" fill="none" stroke="{c}" stroke-width="{f(w)}" '
|
||||
f'stroke-linecap="round" stroke-linejoin="round"/>')
|
||||
x = 0
|
||||
# S: two stacked circles, the lower one slightly larger
|
||||
rr = (H - t) / 2
|
||||
r1 = rr * 0.48
|
||||
r2 = rr - r1
|
||||
cxs = x + r2 + t / 2
|
||||
yU, yL = hi - r1, lo + r2
|
||||
sweep = math.radians(236)
|
||||
a_top = math.pi * 1.5 - sweep
|
||||
p = P()
|
||||
p.M((cxs + r1 * math.cos(a_top), yU + r1 * math.sin(a_top)))
|
||||
p.arc((cxs, yU), r1, a_top, 1.5 * math.pi)
|
||||
p.arc((cxs, yL), r2, math.pi / 2, math.pi / 2 - sweep)
|
||||
strokes.append(st(p.s()))
|
||||
guides += [(cxs, yU, r1), (cxs, yL, r2)]
|
||||
x = cxs + r2 + t / 2 + gap
|
||||
# L
|
||||
xl = x + t / 2
|
||||
wl = 290
|
||||
strokes.append(st(f"M{f(xl)} {f(hi)} L{f(xl)} {f(lo)} L{f(xl + wl)} {f(lo)}"))
|
||||
x = xl + wl + t / 2 + gap * 0.55
|
||||
# Y slingshot: straight arms tangent to a U, then the handle
|
||||
a = 245
|
||||
cxy = x + a + t / 2
|
||||
rU = 116
|
||||
yu_c = 336
|
||||
tip = (cxy - a, hi)
|
||||
dxv, dyv = tip[0] - cxy, tip[1] - yu_c
|
||||
dist = math.hypot(dxv, dyv)
|
||||
tang_ang = math.atan2(dyv, dxv) + math.acos(rU / dist)
|
||||
TLp = (cxy + rU * math.cos(tang_ang), yu_c + rU * math.sin(tang_ang))
|
||||
p = P().M(tip).L(TLp)
|
||||
a0, a1 = tang_ang, math.pi - tang_ang
|
||||
while a1 < a0:
|
||||
a1 += 2 * math.pi
|
||||
p.arc((cxy, yu_c), rU, a0, a1)
|
||||
p.L((2 * cxy - tip[0], hi))
|
||||
strokes.append(st(p.s()))
|
||||
ubot = yu_c - rU
|
||||
strokes.append(st(f"M{f(cxy)} {f(ubot)} L{f(cxy)} {f(lo)}"))
|
||||
guides += [(cxy, yu_c, rU)]
|
||||
Pt = (cxy, yu_c + 30)
|
||||
band_s = st(f"M{f(tip[0])} {f(hi)} L{f(Pt[0])} {f(Pt[1])} L{f(2 * cxy - tip[0])} {f(hi)}", w=t * 0.36, c=accent)
|
||||
stone = f'<circle cx="{f(Pt[0])}" cy="{f(Pt[1])}" r="{f(t * 0.78)}" fill="{accent}"/>'
|
||||
x = cxy + a + t / 2 + gap * 0.55
|
||||
# N
|
||||
xn = x + t / 2
|
||||
wn = 400
|
||||
strokes.append(st(f"M{f(xn)} {f(lo)} L{f(xn)} {f(hi)} L{f(xn + wn)} {f(lo)} L{f(xn + wn)} {f(hi)}"))
|
||||
x = xn + wn + t / 2 + gap
|
||||
# G: open circle with a bar at the centre line
|
||||
rg = (H - t) / 2
|
||||
cg = (x + rg + t / 2, H / 2)
|
||||
p = P()
|
||||
g0 = math.radians(44)
|
||||
p.M((cg[0] + rg * math.cos(g0), cg[1] + rg * math.sin(g0)))
|
||||
p.arc(cg, rg, g0, 2 * math.pi)
|
||||
p.L((cg[0] + rg * 0.30, cg[1]))
|
||||
strokes.append(st(p.s()))
|
||||
guides += [(cg[0], cg[1], rg)]
|
||||
x_end = cg[0] + rg + t / 2
|
||||
if construct:
|
||||
gl = []
|
||||
for gx, gy, gr in guides:
|
||||
gl.append(f'<circle cx="{f(gx)}" cy="{f(gy)}" r="{f(gr)}" fill="none" stroke="{line}" '
|
||||
f'stroke-width="3" opacity="0.34"/>')
|
||||
gl.append(f'<circle cx="{f(gx)}" cy="{f(gy)}" r="8" fill="{line}" opacity="0.5"/>')
|
||||
for gy in (lo, hi, H / 2):
|
||||
gl.append(f'<line x1="-140" y1="{f(gy)}" x2="{f(x_end + 140)}" y2="{f(gy)}" stroke="{line}" '
|
||||
f'stroke-width="3" opacity="0.22"/>')
|
||||
# the band's geometry: tip-to-stone lines extended
|
||||
mk.add("".join(gl))
|
||||
mk.add("".join(strokes) + band_s + stone, (0, 0, x_end, H))
|
||||
return mk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 8 Zap
|
||||
def bolt(cx, cy, h, fill, knock=None, kw=0):
|
||||
w = h * 0.58
|
||||
pts = [(0.16, 0.5), (-0.5, -0.06), (-0.04, -0.06), (-0.2, -0.5), (0.5, 0.1), (0.04, 0.1)]
|
||||
pts = [(cx + px * w, cy + py * h) for px, py in pts]
|
||||
d = "M" + " L".join(f"{f(x)} {f(y)}" for x, y in pts) + " Z"
|
||||
s = ""
|
||||
if knock:
|
||||
s += (f'<path d="{d}" fill="{knock}" stroke="{knock}" stroke-width="{f(2 * kw + h * 0.05)}" '
|
||||
f'stroke-linejoin="round"/>')
|
||||
return s + (f'<path d="{d}" fill="{fill}" stroke="{fill}" stroke-width="{f(h * 0.05)}" '
|
||||
f'stroke-linejoin="round"/>')
|
||||
|
||||
|
||||
def c8(ink=WHITE, volt=VOLT, ground=INK):
|
||||
face = UNB
|
||||
H, stem = face.cap, face.stem("L")[0]
|
||||
outer = 470
|
||||
mk = Mark()
|
||||
pos, _ = word(mk, face, "SLYNG", ink, slot="Y", slot_adv=2 * outer + 36,
|
||||
kern={"LY": -40, "YN": 14})
|
||||
cx = pos[2][1] + outer + 18
|
||||
Y = make_y(cx, H, stem * 0.92, stem * 0.82, outer, H * 0.30, r_u=90, r_o=40, tips="round")
|
||||
mk.add(ypath(Y, ink), y_box(Y))
|
||||
L, R = Y.anchors()
|
||||
ub = Y.geometry()["u_bottom"]
|
||||
Pt = (cx, ub + 150)
|
||||
mk.add(band(L, Pt, R, 42, volt))
|
||||
mk.add(bolt(Pt[0], Pt[1] + 22, 236, volt, knock=ground, kw=16))
|
||||
return mk
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Chosen direction: the concept-5 app icon in concept 8's palette (ink, white, volt).
|
||||
|
||||
The icon is drawn in a 1024 box (y up). `icon_svg(state)` renders any moment of
|
||||
the pull-and-release, so the static icon, the storyboard and the animation all
|
||||
come from one geometry."""
|
||||
import math
|
||||
from kit import INK, WHITE, VOLT, BONE, Mark
|
||||
from concepts import squircle, LEX, UNB, c1, c8
|
||||
from sling import make_y, f
|
||||
|
||||
S = 1024
|
||||
CX = S / 2
|
||||
H_FRAME = S * 0.70
|
||||
BASE = S * 0.14
|
||||
R_STONE = S * 0.065
|
||||
BAND_W = S * 0.045
|
||||
|
||||
Y = make_y(CX, H_FRAME, S * 0.15, S * 0.14, S * 0.30, H_FRAME * 0.40, base=0,
|
||||
r_u=S * 0.075, r_o=S * 0.04, tips="round", bottom="round")
|
||||
_L, _R = Y.anchors()
|
||||
L = (_L[0], _L[1] + BASE)
|
||||
R = (_R[0], _R[1] + BASE)
|
||||
UB = Y.geometry()["u_bottom"] + BASE # top of the U, icon coords
|
||||
P_REST = (CX, UB + R_STONE + S * 0.02) # pouch when loaded
|
||||
P_DRAWN = (CX, UB - S * 0.17) # pouch at full draw
|
||||
TIP_Y = L[1] # straight band height
|
||||
|
||||
|
||||
PALETTES = {
|
||||
"zap": dict(ground=INK, frame=WHITE, band=VOLT, stone=VOLT, knock=INK),
|
||||
"volt": dict(ground=VOLT, frame=INK, band=INK, stone=INK, knock=VOLT),
|
||||
}
|
||||
|
||||
|
||||
def icon_svg(pouch=None, stone=None, stone_scale=1.0, palette="zap", knock=True,
|
||||
band_behind=False, size=None, label="SLYNG app icon", extra_stone=None):
|
||||
"""pouch: band apex (None = loaded rest). stone: stone centre (None = in the
|
||||
pouch; False = no stone). extra_stone: (x, y, scale) for a second stone
|
||||
(the reload)."""
|
||||
c = PALETTES[palette]
|
||||
pouch = P_REST if pouch is None else pouch
|
||||
if stone is None:
|
||||
stone = pouch
|
||||
kw = S * 0.016 if knock else 0
|
||||
sq = squircle(S / 2, S / 2, S / 2)
|
||||
frame = f'<path transform="translate(0 {f(BASE)})" d="{Y.path()}" fill="{c["frame"]}"/>'
|
||||
d = f"M{f(L[0])} {f(L[1])} L{f(pouch[0])} {f(pouch[1])} L{f(R[0])} {f(R[1])}"
|
||||
band = ""
|
||||
if knock and not band_behind:
|
||||
band += (f'<path d="{d}" fill="none" stroke="{c["knock"]}" stroke-width="{f(BAND_W + 2 * kw)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>')
|
||||
band += (f'<path d="{d}" fill="none" stroke="{c["band"]}" stroke-width="{f(BAND_W)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>')
|
||||
|
||||
def stone_el(x, y, sc):
|
||||
r = R_STONE * sc
|
||||
s = ""
|
||||
if knock:
|
||||
s += f'<circle cx="{f(x)}" cy="{f(y)}" r="{f(r + kw)}" fill="{c["knock"]}"/>'
|
||||
return s + f'<circle cx="{f(x)}" cy="{f(y)}" r="{f(r)}" fill="{c["stone"]}"/>'
|
||||
|
||||
stones = ""
|
||||
if stone is not False:
|
||||
stones += stone_el(stone[0], stone[1], stone_scale)
|
||||
if extra_stone:
|
||||
stones += stone_el(*extra_stone)
|
||||
art = (band + frame) if band_behind else (frame + band)
|
||||
body = (f'<defs><clipPath id="sq"><path d="{sq}"/></clipPath></defs>'
|
||||
f'<path d="{sq}" fill="{c["ground"]}"/>'
|
||||
f'<g clip-path="url(#sq)">{art}{stones}</g>')
|
||||
size_attr = f' width="{size}" height="{size}"' if size else ""
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 {-S} {S} {S}"{size_attr} role="img" '
|
||||
f'aria-label="{label}"><g transform="scale(1 -1)">{body}</g></svg>')
|
||||
|
||||
|
||||
def icon_mark_z(palette="zap", knock=True, band_behind=False):
|
||||
"""The static icon as a Mark (for lockups)."""
|
||||
svg = icon_svg(palette=palette, knock=knock, band_behind=band_behind)
|
||||
inner = svg[svg.index('<g transform="scale(1 -1)">') + len('<g transform="scale(1 -1)">'):-len("</g></svg>")]
|
||||
# clip ids must be unique per document; rename for embedding
|
||||
inner = inner.replace('id="sq"', f'id="sq-{palette}"').replace("url(#sq)", f"url(#sq-{palette})")
|
||||
mk = Mark()
|
||||
mk.add(inner, (0, 0, S, S))
|
||||
return mk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- wordmarks for lockups
|
||||
from kit import word, ypath, y_box, band as band_fn, dot as dot_fn, glyph
|
||||
|
||||
WORDMARKS = {
|
||||
# face, outer half-width of the slingshot Y, kerning, handle/arm weights, crotch height, fillets
|
||||
"lexend": dict(face=LEX, outer=396, kern={"LY": -4, "YN": 22}, w_k=0.93, wa_k=0.82, yj=0.30, r_u=70, r_o=34,
|
||||
bw=44, r_dot=64, lift=20),
|
||||
"unbounded": dict(face=UNB, outer=470, kern={"LY": -40, "YN": 14}, w_k=0.92, wa_k=0.82, yj=0.30, r_u=90,
|
||||
r_o=40, bw=46, r_dot=70, lift=30),
|
||||
}
|
||||
|
||||
|
||||
def wordmark_z(kind="lexend", mode="full", ink=WHITE, band_c=VOLT, knock=INK, kw=16):
|
||||
"""mode: full = slingshot Y with band and stone, frame = slingshot Y only,
|
||||
plain = the typeface's own Y."""
|
||||
p = WORDMARKS[kind]
|
||||
face = p["face"]
|
||||
H, stem = face.cap, face.stem("L")[0]
|
||||
mk = Mark()
|
||||
if mode == "plain":
|
||||
word(mk, face, "SLYNG", ink, kern={"LY": -40 if kind == "unbounded" else -30})
|
||||
return mk
|
||||
outer = p["outer"]
|
||||
pos, _ = word(mk, face, "SLYNG", ink, slot="Y", slot_adv=2 * outer + 34, kern=p["kern"])
|
||||
cx = pos[2][1] + outer + 17
|
||||
Yw = make_y(cx, H, stem * p["w_k"], stem * p["wa_k"], outer, H * p["yj"], r_u=p["r_u"], r_o=p["r_o"],
|
||||
tips="round")
|
||||
mk.add(ypath(Yw, ink), y_box(Yw))
|
||||
if mode == "full":
|
||||
Lw, Rw = Yw.anchors()
|
||||
ub = Yw.geometry()["u_bottom"]
|
||||
Pt = (cx, ub + p["r_dot"] + p["lift"])
|
||||
mk.add(band_fn(Lw, Pt, Rw, p["bw"], band_c, knock=knock, kw=kw))
|
||||
mk.add(dot_fn(Pt, p["r_dot"], band_c, knock=knock, kw=kw))
|
||||
return mk
|
||||
|
||||
|
||||
def lockup_z(kind="lexend", mode="full", palette="zap", ink=WHITE, band_c=VOLT, knock=INK):
|
||||
wm = wordmark_z(kind, mode, ink, band_c, knock)
|
||||
H = WORDMARKS[kind]["face"].cap
|
||||
ic = icon_mark_z(palette)
|
||||
s = 1.3 * H / S
|
||||
gap = 0.33 * H
|
||||
x0 = wm.box[0] - (S * s + gap)
|
||||
y0 = H / 2 - (S / 2) * s
|
||||
mk = Mark()
|
||||
mk.add(f'<g transform="translate({f(x0)} {f(y0)}) scale({s:.5f})">{"".join(ic.parts)}</g>',
|
||||
(x0, y0, x0 + S * s, y0 + S * s))
|
||||
mk.parts += wm.parts
|
||||
mk.grow(*wm.box)
|
||||
return mk
|
||||
|
||||
|
||||
def mark_bare(palette="zap"):
|
||||
"""The slingshot without its squircle (what the icon reads as on an ink ground)."""
|
||||
c = PALETTES[palette]
|
||||
kw = S * 0.016
|
||||
frame = f'<path transform="translate(0 {f(BASE)})" d="{Y.path()}" fill="{c["frame"]}"/>'
|
||||
P = P_REST
|
||||
dd = f"M{f(L[0])} {f(L[1])} L{f(P[0])} {f(P[1])} L{f(R[0])} {f(R[1])}"
|
||||
band = (f'<path d="{dd}" fill="none" stroke="{c["knock"]}" stroke-width="{f(BAND_W + 2 * kw)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
f'<path d="{dd}" fill="none" stroke="{c["band"]}" stroke-width="{f(BAND_W)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>')
|
||||
stone = (f'<circle cx="{f(P[0])}" cy="{f(P[1])}" r="{f(R_STONE + kw)}" fill="{c["knock"]}"/>'
|
||||
f'<circle cx="{f(P[0])}" cy="{f(P[1])}" r="{f(R_STONE)}" fill="{c["stone"]}"/>')
|
||||
mk = Mark()
|
||||
half = S * 0.30
|
||||
mk.add(frame + band + stone, (CX - half, BASE, CX + half, BASE + H_FRAME))
|
||||
return mk
|
||||
|
||||
|
||||
def lockup_dark(kind="lexend"):
|
||||
"""Ink-ground lockup: the bare slingshot sized to the wordmark, white type."""
|
||||
wm = wordmark_z(kind, "frame", ink=WHITE)
|
||||
H = WORDMARKS[kind]["face"].cap
|
||||
bm = mark_bare()
|
||||
s = 1.24 * H / H_FRAME
|
||||
gap = 0.42 * H
|
||||
bx0, by0, bx1, by1 = bm.box
|
||||
x_right = wm.box[0] - gap
|
||||
tx = x_right - bx1 * s
|
||||
ty = (H / 2) - ((by0 + by1) / 2) * s
|
||||
mk = Mark()
|
||||
mk.add(f'<g transform="translate({f(tx)} {f(ty)}) scale({s:.5f})">{"".join(bm.parts)}</g>',
|
||||
(tx + bx0 * s, ty + by0 * s, tx + bx1 * s, ty + by1 * s))
|
||||
mk.parts += wm.parts
|
||||
mk.grow(*wm.box)
|
||||
return mk
|
||||
|
||||
|
||||
def icon_inner(palette="zap", uid="i", **state):
|
||||
"""The icon's drawing (y up, 0..S) for nesting inside another mark."""
|
||||
svg = icon_svg(palette=palette, **state)
|
||||
head = '<g transform="scale(1 -1)">'
|
||||
inner = svg[svg.index(head) + len(head):-len("</g></svg>")]
|
||||
return inner.replace('id="sq"', f'id="sq-{uid}"').replace("url(#sq)", f"url(#sq-{uid})")
|
||||
|
||||
|
||||
GAPS = {"unbounded": (0.095, 0.115), "lexend": (0.12, 0.15)}
|
||||
|
||||
|
||||
def wordmark_icon_y(kind="unbounded", tone="light", k=1.08, state=None, uid=None):
|
||||
"""SLYNG where the Y is the app icon itself. Light: ink letters with the
|
||||
ink icon; dark: white letters with the volt icon. `state` animates the icon."""
|
||||
face = WORDMARKS[kind]["face"]
|
||||
H = face.cap
|
||||
ink = INK if tone == "light" else WHITE
|
||||
palette = "zap" if tone == "light" else "volt"
|
||||
size = H * k
|
||||
gl, gr = GAPS[kind]
|
||||
mk = Mark()
|
||||
pos, x = word(mk, face, "SL", ink)
|
||||
lb = face.bounds("L")
|
||||
x_icon = pos[1][1] + lb[2] + H * gl
|
||||
y_icon = H / 2 - size / 2
|
||||
s = size / S
|
||||
inner = icon_inner(palette, uid or f"{kind}-{tone}", **(state or {}))
|
||||
mk.add(f'<g transform="translate({f(x_icon)} {f(y_icon)}) scale({s:.5f})">{inner}</g>',
|
||||
(x_icon, y_icon, x_icon + size, y_icon + size))
|
||||
nb = face.bounds("N")
|
||||
x_n = x_icon + size + H * gr - nb[0]
|
||||
word(mk, face, "NG", ink, x0=x_n)
|
||||
return mk
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Final SLYNG wordmark: Unbounded, the Y is Unbounded's own Y.
|
||||
|
||||
Logo pose: the stone sits on the vertex of the Y; the two bands run parallel
|
||||
to the inner edges of the arms (a thin ink gap between band and arm) all the
|
||||
way up to the tips, cut flush with the cap line.
|
||||
|
||||
`pose(...)` draws any moment of the motion: the bands always run from the
|
||||
pouch to the anchors at the tips; when a band is steep its tip end is cut
|
||||
flush with the cap line, when it is shallow (snapped straight, ringing) it
|
||||
ends round."""
|
||||
import math
|
||||
from concepts import UNB
|
||||
from kit import INK, WHITE, VOLT, BONE, Mark, word
|
||||
from sling import f
|
||||
|
||||
FACE = UNB
|
||||
H = FACE.cap
|
||||
KERN = {"LY": -40, "YN": 0}
|
||||
TONES = {
|
||||
"dark": dict(bg=INK, letters=WHITE, band=VOLT, stone=VOLT, knock=INK),
|
||||
"light": dict(bg=BONE, letters=INK, band=VOLT, stone=VOLT, knock=INK),
|
||||
}
|
||||
BW = 0.056 * H # band width
|
||||
KW = 0.022 * H # ink gap / outline
|
||||
R_STONE = 0.082 * H
|
||||
|
||||
|
||||
def _geometry():
|
||||
mk = Mark()
|
||||
pos, _ = word(mk, FACE, "SLYNG", "#000", kern=KERN)
|
||||
yx = pos[2][1]
|
||||
m = FACE.measure_Y()
|
||||
cx = yx + m["cx"]
|
||||
y1, y2 = H * 0.96, H * 0.74
|
||||
e1 = (yx + FACE.hscan("Y", y1)[1], y1)
|
||||
e2 = (yx + FACE.hscan("Y", y2)[1], y2)
|
||||
dx, dy = e2[0] - e1[0], e2[1] - e1[1]
|
||||
L = math.hypot(dx, dy)
|
||||
ux, uy = dx / L, dy / L # down the left inner edge
|
||||
nx, ny = -uy, ux # into the fork
|
||||
if nx < 0:
|
||||
nx, ny = -nx, -ny
|
||||
# the left inner edge as x(y)
|
||||
x_in = lambda y: e1[0] + (y - e1[1]) * dx / dy
|
||||
vertex = (cx, e1[1] + (cx - e1[0]) * dy / dx)
|
||||
delta = KW + BW / 2
|
||||
x_off = lambda y: x_in(y) + delta / math.cos(math.atan(abs(dx / dy)))
|
||||
# band centrelines meet on the axis here (hidden under the stone)
|
||||
apex_y = e1[1] + (cx - x_off(e1[1])) * dy / dx
|
||||
anchor_L = (x_off(H), H)
|
||||
anchor_R = (2 * cx - anchor_L[0], H)
|
||||
return dict(cx=cx, vertex=vertex, apex=(cx, apex_y), L=anchor_L, R=anchor_R, unit=H / 0.70)
|
||||
|
||||
|
||||
G = _geometry()
|
||||
|
||||
|
||||
def _strip(p, t, hw, flush_y=None):
|
||||
"""Polygon for a band of half-width hw from p (pouch end) to t (tip end).
|
||||
With flush_y the tip end is cut horizontally at that height."""
|
||||
dx, dy = t[0] - p[0], t[1] - p[1]
|
||||
L = math.hypot(dx, dy) or 1e-6
|
||||
ux, uy = dx / L, dy / L
|
||||
nx, ny = -uy * hw, ux * hw
|
||||
a = (p[0] + nx, p[1] + ny)
|
||||
b = (p[0] - nx, p[1] - ny)
|
||||
if flush_y is not None and uy > 0.35:
|
||||
# walk each long edge until it meets y = flush_y
|
||||
ka = (flush_y - a[1]) / uy
|
||||
kb = (flush_y - b[1]) / uy
|
||||
a2 = (a[0] + ux * ka, flush_y)
|
||||
b2 = (b[0] + ux * kb, flush_y)
|
||||
return f"M{f(a[0])} {f(a[1])} L{f(a2[0])} {f(a2[1])} L{f(b2[0])} {f(b2[1])} L{f(b[0])} {f(b[1])} Z", False
|
||||
a2 = (t[0] + nx, t[1] + ny)
|
||||
b2 = (t[0] - nx, t[1] - ny)
|
||||
return f"M{f(a[0])} {f(a[1])} L{f(a2[0])} {f(a2[1])} L{f(b2[0])} {f(b2[1])} L{f(b[0])} {f(b[1])} Z", True
|
||||
|
||||
|
||||
def _band(pouch, color, hw, flush):
|
||||
out = []
|
||||
for t in (G["L"], G["R"]):
|
||||
d, round_end = _strip(pouch, t, hw, H if flush else None)
|
||||
out.append(f'<path d="{d}" fill="{color}"/>')
|
||||
if round_end:
|
||||
out.append(f'<circle cx="{f(t[0])}" cy="{f(t[1])}" r="{f(hw)}" fill="{color}"/>')
|
||||
out.append(f'<circle cx="{f(pouch[0])}" cy="{f(pouch[1])}" r="{f(hw)}" fill="{color}"/>')
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def pose(tone="dark", pouch=None, stone=None, stone_scale=1.0, flush=True):
|
||||
c = TONES[tone]
|
||||
pouch = G["apex"] if pouch is None else pouch
|
||||
if stone is None:
|
||||
stone = G["vertex"]
|
||||
mk = Mark()
|
||||
word(mk, FACE, "SLYNG", c["letters"], kern=KERN)
|
||||
mk.add(_band(pouch, c["knock"], BW / 2 + KW, flush) + _band(pouch, c["band"], BW / 2, flush))
|
||||
if stone is not False:
|
||||
r = R_STONE * stone_scale
|
||||
mk.add(f'<circle cx="{f(stone[0])}" cy="{f(stone[1])}" r="{f(r + KW)}" fill="{c["knock"]}"/>'
|
||||
f'<circle cx="{f(stone[0])}" cy="{f(stone[1])}" r="{f(r)}" fill="{c["stone"]}"/>')
|
||||
return mk
|
||||
|
||||
|
||||
def static_box():
|
||||
return pose("dark").box
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Font helpers: load (variable) fonts at a fixed instance, emit glyph outlines
|
||||
as SVG path data in font units (y up), and measure stems / Y proportions by
|
||||
scanlines over flattened contours."""
|
||||
import math
|
||||
import os
|
||||
from fontTools.ttLib import TTFont
|
||||
from fontTools.varLib import instancer
|
||||
from fontTools.pens.svgPathPen import SVGPathPen
|
||||
from fontTools.pens.basePen import BasePen
|
||||
from fontTools.pens.boundsPen import BoundsPen
|
||||
|
||||
# the fonts: tools/brand/make.py fetches them (google/fonts at a fixed commit) and points BRAND_FONTS at them
|
||||
FONTDIR = os.environ.get("BRAND_FONTS") or os.path.join(os.path.dirname(os.path.abspath(__file__)), "fonts")
|
||||
|
||||
|
||||
class FlattenPen(BasePen):
|
||||
"""Collects contours as polylines."""
|
||||
|
||||
def __init__(self, glyphSet, steps=24):
|
||||
super().__init__(glyphSet)
|
||||
self.contours = []
|
||||
self.cur = None
|
||||
self.steps = steps
|
||||
|
||||
def _moveTo(self, p):
|
||||
self.cur = [p]
|
||||
|
||||
def _lineTo(self, p):
|
||||
self.cur.append(p)
|
||||
|
||||
def _curveToOne(self, p1, p2, p3):
|
||||
p0 = self.cur[-1]
|
||||
for i in range(1, self.steps + 1):
|
||||
t = i / self.steps
|
||||
mt = 1 - t
|
||||
x = mt**3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t**3 * p3[0]
|
||||
y = mt**3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t**3 * p3[1]
|
||||
self.cur.append((x, y))
|
||||
|
||||
def _qCurveToOne(self, p1, p2):
|
||||
p0 = self.cur[-1]
|
||||
for i in range(1, self.steps + 1):
|
||||
t = i / self.steps
|
||||
mt = 1 - t
|
||||
x = mt * mt * p0[0] + 2 * mt * t * p1[0] + t * t * p2[0]
|
||||
y = mt * mt * p0[1] + 2 * mt * t * p1[1] + t * t * p2[1]
|
||||
self.cur.append((x, y))
|
||||
|
||||
def _closePath(self):
|
||||
if self.cur:
|
||||
self.contours.append(self.cur)
|
||||
self.cur = None
|
||||
|
||||
_endPath = _closePath
|
||||
|
||||
|
||||
class Face:
|
||||
def __init__(self, filename, **axes):
|
||||
f = TTFont(os.path.join(FONTDIR, filename))
|
||||
if "fvar" in f:
|
||||
fv = {a.axisTag: a for a in f["fvar"].axes}
|
||||
pin = {}
|
||||
for tag, ax in fv.items():
|
||||
v = axes.get(tag, ax.defaultValue)
|
||||
pin[tag] = max(ax.minValue, min(ax.maxValue, v))
|
||||
f = instancer.instantiateVariableFont(f, pin)
|
||||
self.font = f
|
||||
self.gs = f.getGlyphSet()
|
||||
self.cmap = f.getBestCmap()
|
||||
self.upm = f["head"].unitsPerEm
|
||||
os2 = f["OS/2"]
|
||||
self.cap = getattr(os2, "sCapHeight", 0) or self.bounds("H")[3]
|
||||
self.xh = getattr(os2, "sxHeight", 0) or self.bounds("x")[3]
|
||||
self.desc = f["hhea"].descent
|
||||
|
||||
def g(self, ch):
|
||||
return self.cmap[ord(ch)]
|
||||
|
||||
def adv(self, ch):
|
||||
return self.font["hmtx"][self.g(ch)][0]
|
||||
|
||||
def path(self, ch):
|
||||
pen = SVGPathPen(self.gs)
|
||||
self.gs[self.g(ch)].draw(pen)
|
||||
return pen.getCommands()
|
||||
|
||||
def bounds(self, ch):
|
||||
pen = BoundsPen(self.gs)
|
||||
self.gs[self.g(ch)].draw(pen)
|
||||
return pen.bounds # xMin, yMin, xMax, yMax
|
||||
|
||||
def contours(self, ch):
|
||||
pen = FlattenPen(self.gs)
|
||||
self.gs[self.g(ch)].draw(pen)
|
||||
return pen.contours
|
||||
|
||||
def hscan(self, ch, y):
|
||||
"""x positions where the outline crosses the horizontal line y (sorted)."""
|
||||
xs = []
|
||||
for c in self.contours(ch):
|
||||
n = len(c)
|
||||
for i in range(n):
|
||||
(x0, y0), (x1, y1) = c[i], c[(i + 1) % n]
|
||||
if (y0 <= y < y1) or (y1 <= y < y0):
|
||||
t = (y - y0) / (y1 - y0)
|
||||
xs.append(x0 + t * (x1 - x0))
|
||||
return sorted(xs)
|
||||
|
||||
def vscan(self, ch, x):
|
||||
ys = []
|
||||
for c in self.contours(ch):
|
||||
n = len(c)
|
||||
for i in range(n):
|
||||
(x0, y0), (x1, y1) = c[i], c[(i + 1) % n]
|
||||
if (x0 <= x < x1) or (x1 <= x < x0):
|
||||
t = (x - x0) / (x1 - x0)
|
||||
ys.append(y0 + t * (y1 - y0))
|
||||
return sorted(ys)
|
||||
|
||||
def stem(self, ch="L", frac=0.5):
|
||||
xs = self.hscan(ch, self.cap * frac)
|
||||
return xs[1] - xs[0], xs[0], xs[1]
|
||||
|
||||
def measure_Y(self, ch="Y", top=None):
|
||||
"""Measure the font's own Y: top extents, arm slope, crotch, stem."""
|
||||
H = self.cap if top is None else top
|
||||
b = self.bounds(ch)
|
||||
cx_guess = (b[0] + b[2]) / 2
|
||||
yt = H * 0.94
|
||||
xs_t = self.hscan(ch, yt)
|
||||
ym = H * 0.72
|
||||
xs_m = self.hscan(ch, ym)
|
||||
xs_s = self.hscan(ch, H * 0.12)
|
||||
s0, s1 = xs_s[0], xs_s[-1]
|
||||
cx = (s0 + s1) / 2
|
||||
# inner edges at two heights -> slope of inner left edge
|
||||
li_t, li_m = xs_t[1], xs_m[1]
|
||||
dxdy = (li_t - li_m) / (yt - ym) # negative: moves left going up
|
||||
lo_t = xs_t[0]
|
||||
# crotch: first ink going down the centre line from the top
|
||||
ys = [y for y in self.vscan(ch, cx) if y < H]
|
||||
y_v = max(ys) if ys else H * 0.45
|
||||
return dict(H=H, cx=cx, s0=s0, s1=s1, stem=s1 - s0,
|
||||
lo_top=lo_t + (H - yt) * dxdy, li_top=li_t + (H - yt) * dxdy,
|
||||
dxdy=dxdy, y_v=y_v, adv=self.adv(ch), bounds=b,
|
||||
arm_h=xs_t[1] - xs_t[0])
|
||||
@@ -0,0 +1,128 @@
|
||||
"""SLYNG in Unbounded with the Y drawn as a slingshot two ways:
|
||||
font - Unbounded's own Y glyph, untouched (flat cuts, same weight)
|
||||
round - the icon's rounded slingshot (for comparison)
|
||||
In both, the band runs parallel to the inner edges of the arms (an inline that
|
||||
follows the Y) and the stone sits in the fork, cradled where the band meets.
|
||||
|
||||
`sling_geom` returns everything the animation needs in the word's own units."""
|
||||
import math
|
||||
from concepts import UNB
|
||||
from kit import INK, WHITE, VOLT, BONE, Mark, word
|
||||
from sling import f
|
||||
import direction as d
|
||||
import slingy
|
||||
|
||||
FACE = UNB
|
||||
H = FACE.cap
|
||||
TONES = {
|
||||
"dark": dict(bg=INK, letters=WHITE, band=VOLT, stone=VOLT, knock=INK),
|
||||
"light": dict(bg=BONE, letters=INK, band=VOLT, stone=VOLT, knock=INK),
|
||||
}
|
||||
BW = 0.056 * H # band width
|
||||
KW = 0.022 * H # ink outline around band and stone
|
||||
R_STONE = 0.086 * H
|
||||
KERN = {"LY": -40, "YN": 0}
|
||||
ROUND_K = 1.12 # rounded slingshot height as a multiple of cap height
|
||||
|
||||
|
||||
def _offset_line(p_top, p_bot, delta):
|
||||
"""Shift the line through two points (left arm inner edge, top then bottom)
|
||||
by `delta` towards the fork interior (to its right)."""
|
||||
dx, dy = p_bot[0] - p_top[0], p_bot[1] - p_top[1]
|
||||
L = math.hypot(dx, dy)
|
||||
ux, uy = dx / L, dy / L # pointing down the edge
|
||||
nx, ny = -uy, ux # rotate +90: points right-up for a down-right edge
|
||||
if nx < 0:
|
||||
nx, ny = -nx, -ny
|
||||
return (p_top[0] + nx * delta, p_top[1] + ny * delta), (ux, uy)
|
||||
|
||||
|
||||
def sling_geom(style="font"):
|
||||
"""Place SLYNG and compute the Y's sling geometry (word units, y up)."""
|
||||
mk = Mark()
|
||||
if style == "font":
|
||||
pos, x_end = word(mk, FACE, "SLYNG", "#000", kern=KERN)
|
||||
yx = pos[2][1]
|
||||
m = FACE.measure_Y()
|
||||
cx = yx + m["cx"]
|
||||
# inner edge of the left arm from two scanlines
|
||||
y1, y2 = H * 0.96, H * 0.74
|
||||
e1 = (yx + FACE.hscan("Y", y1)[1], y1)
|
||||
e2 = (yx + FACE.hscan("Y", y2)[1], y2)
|
||||
apex_y = m["y_v"]
|
||||
top_y = H
|
||||
info = dict(yx=yx)
|
||||
else:
|
||||
# rounded slingshot from the icon, scaled into the Y slot
|
||||
pos, _ = word(mk, FACE, "SL", "#000")
|
||||
lb = FACE.bounds("L")
|
||||
bx0, by0, bx1, by1 = slingy.BARE_BOX
|
||||
s = ROUND_K * H / (by1 - by0)
|
||||
gl, gr = slingy.GAPS["unbounded"]
|
||||
tx = pos[1][1] + lb[2] + gl * H - bx0 * s
|
||||
ty = H / 2 - ((by0 + by1) / 2) * s
|
||||
Yi = d.Y
|
||||
to_w = lambda p: (tx + p[0] * s, ty + (p[1] + d.BASE) * s)
|
||||
cx = tx + d.CX * s
|
||||
ya, yb = Yi.Hc * 0.92, Yi.Hc * 0.70
|
||||
e1 = to_w((Yi.x_in(ya), ya))
|
||||
e2 = to_w((Yi.x_in(yb), yb))
|
||||
g = Yi.geometry()
|
||||
apex_y = to_w((0, g["y_v"]))[1]
|
||||
top_y = to_w((0, Yi.Hc))[1]
|
||||
info = dict(tx=tx, ty=ty, s=s, x_n=tx + bx1 * s + gr * H)
|
||||
delta = KW + BW / 2
|
||||
o_top, (ux, uy) = _offset_line(e1, e2, delta)
|
||||
# where the offset line crosses the axis = pouch apex at rest
|
||||
t_axis = (cx - o_top[0]) / ux
|
||||
apex = (cx, o_top[1] + uy * t_axis)
|
||||
# top end of the band: on the offset line, just under the top of the arm
|
||||
y_top_end = top_y - BW / 2 - KW - (0 if style == "font" else 0)
|
||||
t_top = (y_top_end - o_top[1]) / uy
|
||||
Lp = (o_top[0] + ux * t_top, y_top_end)
|
||||
Rp = (2 * cx - Lp[0], Lp[1])
|
||||
# font Y has a sharp crotch: seat the stone low enough that its outline
|
||||
# covers the tip of the V; the rounded slingshot's U cradles it higher
|
||||
stone_lift = 0.08 * R_STONE if style == "font" else 0.6 * R_STONE
|
||||
return dict(style=style, cx=cx, L=Lp, R=Rp, apex=apex, apex_y=apex_y, top_y=top_y,
|
||||
stone_lift=stone_lift, unit=(top_y - 0) / 0.70 if style == "font" else (H * ROUND_K) / 0.70,
|
||||
**info)
|
||||
|
||||
|
||||
GEOM = {s: sling_geom(s) for s in ("font", "round")}
|
||||
|
||||
|
||||
def draw(style="font", tone="dark", pouch=None, stone=None, stone_scale=1.0, bg=False):
|
||||
"""The whole wordmark at one moment. pouch: band apex; stone: stone centre
|
||||
(None = resting in the pouch, False = none)."""
|
||||
g = GEOM[style]
|
||||
c = TONES[tone]
|
||||
pouch = g["apex"] if pouch is None else pouch
|
||||
if stone is None:
|
||||
stone = (pouch[0], pouch[1] + g["stone_lift"])
|
||||
mk = Mark()
|
||||
if style == "font":
|
||||
word(mk, FACE, "SLYNG", c["letters"], kern=KERN)
|
||||
else:
|
||||
word(mk, FACE, "SL", c["letters"])
|
||||
frame = f'<path transform="translate({f(g["tx"])} {f(g["ty"])}) scale({g["s"]:.5f}) translate(0 {f(d.BASE)})" d="{d.Y.path()}" fill="{c["letters"]}"/>'
|
||||
bx0, by0, bx1, by1 = slingy.BARE_BOX
|
||||
s = g["s"]
|
||||
mk.add(frame, (g["tx"] + bx0 * s, g["ty"] + by0 * s, g["tx"] + bx1 * s, g["ty"] + by1 * s))
|
||||
nb = FACE.bounds("N")
|
||||
word(mk, FACE, "NG", c["letters"], x0=g["x_n"] - nb[0])
|
||||
Lp, Rp = g["L"], g["R"]
|
||||
dd = f"M{f(Lp[0])} {f(Lp[1])} L{f(pouch[0])} {f(pouch[1])} L{f(Rp[0])} {f(Rp[1])}"
|
||||
mk.add(f'<path d="{dd}" fill="none" stroke="{c["knock"]}" stroke-width="{f(BW + 2 * KW)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
f'<path d="{dd}" fill="none" stroke="{c["band"]}" stroke-width="{f(BW)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>')
|
||||
if stone is not False:
|
||||
r = R_STONE * stone_scale
|
||||
mk.add(f'<circle cx="{f(stone[0])}" cy="{f(stone[1])}" r="{f(r + KW)}" fill="{c["knock"]}"/>'
|
||||
f'<circle cx="{f(stone[0])}" cy="{f(stone[1])}" r="{f(r)}" fill="{c["stone"]}"/>')
|
||||
return mk
|
||||
|
||||
|
||||
def static_box(style):
|
||||
return draw(style, "dark").box
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Shared drawing kit for the SLYNG concepts (font units, y up)."""
|
||||
import math
|
||||
import os
|
||||
from fontkit import Face
|
||||
from sling import make_y, f
|
||||
|
||||
INK = "#111014"
|
||||
BONE = "#F1F0EC"
|
||||
WHITE = "#FFFFFF"
|
||||
RED = "#FF3D2E" # band vermilion
|
||||
VOLT = "#D9FF3D" # zap lime
|
||||
COBALT = "#2340FF"
|
||||
PERI = "#C9C6FF" # periwinkle
|
||||
DAYGLO = "#FFE229" # price-sticker yellow
|
||||
CONCRETE = "#D7D5CF"
|
||||
|
||||
|
||||
class Mark:
|
||||
"""Collects SVG snippets drawn in font units with y up."""
|
||||
|
||||
def __init__(self):
|
||||
self.parts = []
|
||||
self.box = [1e9, 1e9, -1e9, -1e9]
|
||||
|
||||
def add(self, s, box=None):
|
||||
self.parts.append(s)
|
||||
if box:
|
||||
self.grow(*box)
|
||||
|
||||
def grow(self, x0, y0, x1, y1):
|
||||
b = self.box
|
||||
self.box = [min(b[0], x0), min(b[1], y0), max(b[2], x1), max(b[3], y1)]
|
||||
|
||||
def svg(self, pad=(0, 0, 0, 0), bg=None, box=None, label="SLYNG logo"):
|
||||
"""pad = (left, bottom, right, top) in font units."""
|
||||
x0, y0, x1, y1 = box or self.box
|
||||
l, b, r, t = pad
|
||||
vx, vy = x0 - l, -(y1 + t)
|
||||
W, H = (x1 + r) - vx, (y1 + t) - (y0 - b)
|
||||
rect = f'<rect x="{f(vx)}" y="{f(vy)}" width="{f(W)}" height="{f(H)}" fill="{bg}"/>' if bg else ""
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{f(vx)} {f(vy)} {f(W)} {f(H)}" '
|
||||
f'role="img" aria-label="{label}">{rect}'
|
||||
f'<g transform="scale(1 -1)">{"".join(self.parts)}</g></svg>'), (W, H)
|
||||
|
||||
|
||||
def glyph(face, ch, x, fill, y=0, sx=1.0):
|
||||
tr = f"translate({f(x)} {f(y)})" + (f" scale({sx} 1)" if sx != 1 else "")
|
||||
return f'<path transform="{tr}" d="{face.path(ch)}" fill="{fill}"/>'
|
||||
|
||||
|
||||
def word(mark, face, text, fill, kern=None, track=0, x0=0, y0=0, slot=None, slot_adv=None):
|
||||
"""Place `text`; the character `slot` (e.g. 'Y') is left empty and its
|
||||
origin returned so a custom glyph can go there. Returns (positions, end x)."""
|
||||
kern = kern or {}
|
||||
x = x0
|
||||
pos = {}
|
||||
prev = None
|
||||
for i, ch in enumerate(text):
|
||||
if prev is not None:
|
||||
x += kern.get(prev + ch, 0) + track
|
||||
pos[i] = (ch, x)
|
||||
if ch != slot:
|
||||
mark.add(glyph(face, ch, x, fill, y0))
|
||||
b = face.bounds(ch)
|
||||
if b:
|
||||
mark.grow(x + b[0], y0 + b[1], x + b[2], y0 + b[3])
|
||||
adv = slot_adv if (ch == slot and slot_adv) else face.adv(ch)
|
||||
x += adv
|
||||
prev = ch
|
||||
return pos, x
|
||||
|
||||
|
||||
def band(L, P, R, width, color, knock=None, kw=0, join="round"):
|
||||
d = f"M{f(L[0])} {f(L[1])} L{f(P[0])} {f(P[1])} L{f(R[0])} {f(R[1])}"
|
||||
s = ""
|
||||
if knock:
|
||||
s += (f'<path d="{d}" fill="none" stroke="{knock}" stroke-width="{f(width + 2 * kw)}" '
|
||||
f'stroke-linejoin="{join}" stroke-linecap="round"/>')
|
||||
s += (f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{f(width)}" '
|
||||
f'stroke-linejoin="{join}" stroke-linecap="round"/>')
|
||||
return s
|
||||
|
||||
|
||||
def dot(c, r, color, knock=None, kw=0):
|
||||
s = ""
|
||||
if knock:
|
||||
s += f'<circle cx="{f(c[0])}" cy="{f(c[1])}" r="{f(r + kw)}" fill="{knock}"/>'
|
||||
return s + f'<circle cx="{f(c[0])}" cy="{f(c[1])}" r="{f(r)}" fill="{color}"/>'
|
||||
|
||||
|
||||
def ypath(Y, fill):
|
||||
return f'<path d="{Y.path()}" fill="{fill}"/>'
|
||||
|
||||
|
||||
def y_box(Y):
|
||||
if Y.tips == "round":
|
||||
half = Y.a + Y.wa / 2
|
||||
else:
|
||||
half = Y.cx - Y.x_out(Y.H)
|
||||
return (Y.cx - half, Y.base, Y.cx + half, Y.H + Y.tip_over)
|
||||
|
||||
|
||||
def chromium(p):
|
||||
"""Chromium for the animations: Playwright's own, or the one at CHROME=/path/to/chrome."""
|
||||
return p.chromium.launch(executable_path=os.environ.get("CHROME") or None)
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tools/brand/make.py: every image on the brand page (branding/img/), made from scratch.
|
||||
|
||||
The logo, the icon, their motion, and every draft from the six rounds that found them, drawn from the fonts'
|
||||
own outlines by the modules next to this file (one per round of work, kept as they were). Needs Python 3.10+,
|
||||
fontTools, Pillow, and Playwright with Chromium (or CHROME=/path/to/chrome) for the animations.
|
||||
|
||||
python3 tools/brand/make.py # everything, into branding/img/
|
||||
python3 tools/brand/make.py --stills # just the SVGs (seconds); the animations take Chrome a few minutes
|
||||
python3 tools/brand/make.py --out DIR
|
||||
|
||||
The fonts come from google/fonts at a fixed commit, checked against their sha256, into BRAND_FONTS (default
|
||||
~/.cache/slyng-brand-fonts). With the same fontTools the SVGs come out byte for byte the same anywhere; the
|
||||
WebPs and og.png depend on Chrome and libwebp. branding/img/MANIFEST ends up with every file's sha256, which is
|
||||
how tools/brand/restore.sh brings the folder back on a refresh.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
COMMIT = "1d0e46c7933245c8180fe83e47917961c913bbdb" # google/fonts
|
||||
FONTS = { # name here: (path in google/fonts, sha256)
|
||||
"Lexend.ttf": ("ofl/lexend/Lexend[wght].ttf",
|
||||
"3add53e641fbc81da64da4bb254285e2831b52b029527bc0714e2b9610832ee6"),
|
||||
"Archivo.ttf": ("ofl/archivo/Archivo[wdth,wght].ttf",
|
||||
"0e094a7d3c7c4c25cf1310c4b30014f1dae9332220b1c2c88f4fa996f0b05053"),
|
||||
"Fredoka.ttf": ("ofl/fredoka/Fredoka[wdth,wght].ttf",
|
||||
"2ba02e68b152868aef9ba28e24b3648c7d457fe6f25c761f2c2c53fb61a73fc8"),
|
||||
"Sora.ttf": ("ofl/sora/Sora[wght].ttf",
|
||||
"84ff7096ae3ec6c8be47d906d1a0ba4de7f2ce78c615275c77301964a316e16c"),
|
||||
"Unbounded.ttf": ("ofl/unbounded/Unbounded[wght].ttf",
|
||||
"323b511be380c8d474ef030686b71aedde501f8d9cd46da558b7c40454372c3f"),
|
||||
"BigShouldersDisplay.ttf": ("ofl/bigshouldersdisplay/BigShouldersDisplay[wght].ttf",
|
||||
"60e208dc276a1c35fc5b62e94f9fb959c40c11783a9eb7548175c14b1fbeb720"),
|
||||
}
|
||||
|
||||
|
||||
def sha256(path):
|
||||
return hashlib.sha256(pathlib.Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def fetch_fonts(into):
|
||||
into.mkdir(parents=True, exist_ok=True)
|
||||
for name, (path, want) in FONTS.items():
|
||||
dest = into / name
|
||||
if dest.is_file() and sha256(dest) == want:
|
||||
continue
|
||||
url = f"https://raw.githubusercontent.com/google/fonts/{COMMIT}/{urllib.parse.quote(path)}"
|
||||
with urllib.request.urlopen(url, timeout=120) as r:
|
||||
data = r.read()
|
||||
got = hashlib.sha256(data).hexdigest()
|
||||
if got != want:
|
||||
sys.exit(f"{name}: {url} has sha256 {got}, expected {want}")
|
||||
dest.write_bytes(data)
|
||||
print(f"font {name} ({len(data)} bytes)")
|
||||
|
||||
|
||||
def stills(out):
|
||||
"""Every SVG: the drafts in the order they were made, then the files the page adds."""
|
||||
import anim
|
||||
import concepts as c
|
||||
import direction as d
|
||||
import final as F
|
||||
import fontstyle as fs
|
||||
import motion3 as m3
|
||||
import slingy
|
||||
from kit import INK, WHITE, BONE, VOLT, RED, PERI, Mark, word
|
||||
from sling import f
|
||||
|
||||
made = []
|
||||
|
||||
def save(name, svg):
|
||||
(out / f"{name}.svg").write_text(svg)
|
||||
made.append(name)
|
||||
|
||||
# Round 1: eight concepts (concepts.py)
|
||||
def lockup(): # app icon + Loaded wordmark side by side
|
||||
word_mk = c.c1()
|
||||
ic = c.icon_mark()
|
||||
H = c.LEX.cap
|
||||
s = 1.3 * H / 1024
|
||||
gap = 230
|
||||
x0 = -(1024 * s + gap)
|
||||
y0 = H / 2 - 512 * s
|
||||
mk = Mark()
|
||||
mk.add(f'<g transform="translate({f(x0)} {f(y0)}) scale({s:.5f})">{"".join(ic.parts)}</g>',
|
||||
(x0, y0, x0 + 1024 * s, y0 + 1024 * s))
|
||||
mk.parts += word_mk.parts
|
||||
mk.grow(*word_mk.box)
|
||||
return mk
|
||||
|
||||
word_pad, none = (40, 40, 40, 40), (0, 0, 0, 0)
|
||||
round1 = [
|
||||
("c1-main", c.c1(), word_pad, "SLYNG wordmark, Loaded"),
|
||||
("c1-inv", c.c1(ink=BONE), word_pad, "SLYNG wordmark, Loaded, reversed"),
|
||||
("c2-main", c.c2(), word_pad, "SLYNG wordmark, Full draw"),
|
||||
("c2-inv", c.c2(ink=WHITE, band_c=RED, ground=INK), word_pad, "SLYNG wordmark, Full draw, reversed"),
|
||||
("c3-main", c.c3(), word_pad, "slyng wordmark, Pocket"),
|
||||
("c3-inv", c.c3(ink=PERI), word_pad, "slyng wordmark, Pocket, reversed"),
|
||||
("c4-app", c.c4("app"), word_pad, "SLYNG.app lockup"),
|
||||
("c4-money", c.c4("money"), word_pad, "SLYNG.money lockup"),
|
||||
("c4-ing", c.c4("ing"), word_pad, "SLYNG.ing lockup"),
|
||||
("c4-inv", c.c4("app", ink=WHITE), word_pad, "SLYNG.app lockup, reversed"),
|
||||
("c5-ink", c.icon_mark(), none, "SLYNG app icon, ink"),
|
||||
("c5-red", c.icon_mark(frame=INK, ground=RED, band_c=WHITE), none, "SLYNG app icon, vermilion"),
|
||||
("c5-lockup", lockup(), word_pad, "SLYNG app icon with wordmark"),
|
||||
("c6-yellow", c.c6(), none, "SLYNG price sticker, yellow"),
|
||||
("c6-orange", c.c6(sticker="#FF8A1F", rot=7), none, "SLYNG price sticker, orange"),
|
||||
("c6-green", c.c6(sticker="#7CFF5B", rot=-15), none, "SLYNG price sticker, green"),
|
||||
("c7-construct", c.c7(), (170, 120, 170, 120), "SLYNG monoline wordmark with construction"),
|
||||
("c7-clean", c.c7(construct=False), word_pad, "SLYNG monoline wordmark"),
|
||||
("c8-main", c.c8(), word_pad, "SLYNG wordmark, Zap"),
|
||||
("c8-inv", c.c8(ink=INK, volt=INK, ground=VOLT), word_pad, "SLYNG wordmark, Zap, on volt"),
|
||||
]
|
||||
for name, mk, pad, label in round1:
|
||||
save(name, mk.svg(pad=pad, label=label)[0])
|
||||
|
||||
# Round 2: concept 5 in Zap's colours (direction.py), its lockups, and the motion storyboard (anim.py)
|
||||
save("dir-icon-zap", d.icon_svg(label="SLYNG app icon: white slingshot, volt band and stone, on ink"))
|
||||
save("dir-icon-volt", d.icon_svg(palette="volt", label="SLYNG app icon reversed: ink slingshot on volt"))
|
||||
p30, p24 = (30, 30, 30, 30), (24, 24, 24, 24)
|
||||
for kind in ("lexend", "unbounded"):
|
||||
save(f"dir-lockup-{kind}-light", d.lockup_z(kind, "frame", ink=INK).svg(
|
||||
pad=p30, label=f"SLYNG icon and wordmark lockup ({kind}, light ground)")[0])
|
||||
save(f"dir-lockup-{kind}-dark", d.lockup_dark(kind).svg(
|
||||
pad=p30, label=f"SLYNG slingshot mark and wordmark lockup ({kind}, ink ground)")[0])
|
||||
anim.OUT = out
|
||||
made += anim.storyboard()
|
||||
|
||||
# Round 3: the icon as the Y
|
||||
for kind in ("unbounded", "lexend"):
|
||||
for tone in ("light", "dark"):
|
||||
save(f"wm-icony-{kind}-{tone}", d.wordmark_icon_y(kind, tone).svg(
|
||||
pad=p24, label=f"SLYNG wordmark where the Y is the app icon ({kind}, {tone} ground)")[0])
|
||||
|
||||
# Round 4: the bare slingshot as the Y (slingy.py)
|
||||
for kind in ("unbounded", "lexend"):
|
||||
for tone, ground in (("dark", "ink"), ("light-volt", "light")):
|
||||
save(f"wm-sling-{kind}-{tone}", slingy.bare_wordmark(kind, tone).svg(
|
||||
pad=p24, label=f"SLYNG wordmark with the slingshot as the Y ({kind}, {ground} ground)")[0])
|
||||
|
||||
# Round 5: Unbounded's own Y, and a rounded slingshot drawn to match (fontstyle.py)
|
||||
round5 = {
|
||||
("font", "dark"): "SLYNG in white Unbounded on ink; the Y is Unbounded's own Y with a volt band along its "
|
||||
"arms and a volt stone in the fork",
|
||||
("font", "light"): "SLYNG in ink Unbounded on bone; the Y is Unbounded's own Y with an ink-outlined volt "
|
||||
"band along its arms and a stone in the fork",
|
||||
("round", "dark"): "SLYNG in white Unbounded on ink; the Y is the rounded slingshot with the band along its arms",
|
||||
("round", "light"): "SLYNG in ink Unbounded on bone; the Y is the rounded slingshot with the band along its arms",
|
||||
}
|
||||
for (style, tone), label in round5.items():
|
||||
save(f"wm-final-{style}-{tone}", fs.draw(style, tone).svg(pad=p24, label=label)[0])
|
||||
|
||||
# Round 6: the logo (final.py)
|
||||
logo = {
|
||||
"dark": "SLYNG in white Unbounded on ink; a volt stone sits on the vertex of the Y and volt bands run up "
|
||||
"the inside of its arms to the tips",
|
||||
"light": "SLYNG in ink Unbounded on bone; a volt stone sits on the vertex of the Y and ink-outlined volt "
|
||||
"bands run up the inside of its arms to the tips",
|
||||
}
|
||||
for tone, label in logo.items():
|
||||
save(f"wm-logo-{tone}", F.pose(tone).svg(pad=p24, label=label)[0])
|
||||
|
||||
# For the page: the first frame of each loop (the logo pose, framed like the animation, ground included),
|
||||
# a close-up of the Y, and Unbounded ExtraBold set out
|
||||
for tone, label in logo.items():
|
||||
save(f"wm-final-still-{tone}", F.pose(tone).svg(box=m3.panel_box(), bg=F.TONES[tone]["bg"], label=label)[0])
|
||||
H = F.H
|
||||
y_only = F.pose("dark")
|
||||
y_only.parts = [y_only.parts[2]] + y_only.parts[5:] # the Y, its bands and its stone; not S, L, N, G
|
||||
pos, _ = word(Mark(), F.FACE, "SLYNG", INK, kern=F.KERN)
|
||||
yb = F.FACE.bounds("Y")
|
||||
pad = 0.07 * H
|
||||
save("wm-logo-y", y_only.svg(
|
||||
box=(pos[2][1] + yb[0] - pad, -pad, pos[2][1] + yb[2] + pad, H + pad),
|
||||
label="Close-up of the Y in the SLYNG logo: the stone on the vertex, the bands inside the arms, "
|
||||
"cut flush with the tips")[0])
|
||||
spec = Mark()
|
||||
step = 1.42 * c.UNB.cap
|
||||
for i, text in enumerate(["ABCDEFGHIJKLM", "NOPQRSTUVWXYZ", "0123456789"]):
|
||||
word(spec, c.UNB, text, INK, y0=-i * step)
|
||||
save("type-unbounded", spec.svg(pad=(20, 20, 20, 20), label="Unbounded ExtraBold: A to Z and 0 to 9")[0])
|
||||
return made
|
||||
|
||||
|
||||
def motion(out):
|
||||
"""The animations, and og.png for link previews. Minutes of Chrome."""
|
||||
import anim
|
||||
import anim_wm
|
||||
import anim_wm2
|
||||
import motion2
|
||||
import motion3
|
||||
import final as F
|
||||
from playwright.sync_api import sync_playwright
|
||||
from kit import chromium, INK
|
||||
|
||||
for m in (anim, anim_wm, anim_wm2, motion2, motion3):
|
||||
m.OUT = out
|
||||
jobs = [
|
||||
("icon-pull-release.webp", lambda: anim.render()),
|
||||
("wm-anim-unbounded-dark.webp", lambda: anim_wm.render("unbounded", "dark")),
|
||||
("wm-anim-unbounded-light.webp", lambda: anim_wm.render("unbounded", "light")),
|
||||
("wm-sling-anim-unbounded-dark.webp", lambda: anim_wm2.render("unbounded", "dark")),
|
||||
("wm-sling-anim-unbounded-light-volt.webp", lambda: anim_wm2.render("unbounded", "light-volt")),
|
||||
("wm-font-anim-font-dark.webp", lambda: motion2.render("font", "dark")),
|
||||
("wm-font-anim-font-light.webp", lambda: motion2.render("font", "light")),
|
||||
("wm-final-anim-dark.webp", lambda: motion3.render("dark")),
|
||||
("wm-final-anim-light.webp", lambda: motion3.render("light")),
|
||||
]
|
||||
for name, job in jobs:
|
||||
job()
|
||||
print(f"{name} ({(out / name).stat().st_size} bytes)", flush=True)
|
||||
|
||||
# og.png: the logo on ink, 1200x630
|
||||
x0, y0, x1, y1 = F.static_box()
|
||||
w = (x1 - x0) / 0.62
|
||||
h = w * 630 / 1200
|
||||
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
svg = F.pose("dark").svg(box=(mx - w / 2, my - h / 2, mx + w / 2, my + h / 2), bg=INK, label="SLYNG")[0]
|
||||
svg = svg.replace("<svg ", '<svg width="1200" height="630" style="display:block" ', 1)
|
||||
with sync_playwright() as p:
|
||||
b = chromium(p)
|
||||
pg = b.new_page(viewport={"width": 1200, "height": 630})
|
||||
pg.set_content(f'<html><body style="margin:0">{svg}</body></html>')
|
||||
pg.locator("svg").screenshot(path=str(out / "og.png"))
|
||||
b.close()
|
||||
print(f"og.png ({(out / 'og.png').stat().st_size} bytes)")
|
||||
|
||||
|
||||
def manifest(out):
|
||||
names = sorted(p.name for p in out.iterdir() if p.is_file() and p.name != "MANIFEST")
|
||||
(out / "MANIFEST").write_text("".join(f"{sha256(out / n)} {n}\n" for n in names))
|
||||
return len(names)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("--out", default=str(HERE.parent.parent / "branding" / "img"))
|
||||
ap.add_argument("--stills", action="store_true", help="only the SVGs")
|
||||
a = ap.parse_args()
|
||||
fonts = pathlib.Path(os.environ.get("BRAND_FONTS") or pathlib.Path.home() / ".cache" / "slyng-brand-fonts")
|
||||
fetch_fonts(fonts)
|
||||
os.environ["BRAND_FONTS"] = str(fonts)
|
||||
sys.path.insert(0, str(HERE))
|
||||
out = pathlib.Path(a.out).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
made = stills(out)
|
||||
print(f"{len(made)} SVGs in {out}", flush=True)
|
||||
if not a.stills:
|
||||
motion(out)
|
||||
print(f"MANIFEST: {manifest(out)} files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Pull-and-release for the font-style Y, rendered as full panels.
|
||||
|
||||
Fixes for the glitchy dark version: every frame is opaque (the ground is baked
|
||||
in, so there are no semi-transparent edges to fringe on dark), frames are
|
||||
encoded losslessly (no per-frame compression shimmer on the white letters), and
|
||||
each image is the whole 1200x400 panel, so the stone leaves through the panel's
|
||||
real top edge instead of being cut off mid-air at an inner image boundary."""
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
from PIL import Image
|
||||
from playwright.sync_api import sync_playwright
|
||||
from kit import chromium
|
||||
import fontstyle as fs
|
||||
from sling import f
|
||||
|
||||
OUT = pathlib.Path(__file__).parent / "out"
|
||||
FPS, N = 30, 90
|
||||
PANEL_W, PANEL_H, WORD_W = 1200, 400, 900 # CSS px on the artboard
|
||||
SCALE = 1.5 # render density
|
||||
|
||||
|
||||
def ease_io(t):
|
||||
return 4 * t ** 3 if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2
|
||||
|
||||
|
||||
def panel_box(style):
|
||||
x0, y0, x1, y1 = fs.static_box(style)
|
||||
w = (x1 - x0) * PANEL_W / WORD_W
|
||||
h = w * PANEL_H / PANEL_W
|
||||
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
return (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
|
||||
|
||||
|
||||
def state(fr, style, top_exit):
|
||||
g = fs.GEOM[style]
|
||||
u = g["unit"]
|
||||
cx = g["cx"]
|
||||
lift = g["stone_lift"]
|
||||
y_rest = g["apex"][1]
|
||||
y_draw = y_rest - 0.12 * u
|
||||
y_tip = g["L"][1]
|
||||
if fr < 12:
|
||||
return dict()
|
||||
if fr < 30:
|
||||
k = ease_io((fr - 12) / 18)
|
||||
y = y_rest + (y_draw - y_rest) * k
|
||||
return dict(pouch=(cx, y), stone=(cx, y + lift), stone_scale=1 + 0.1 * k)
|
||||
if fr < 36:
|
||||
jx = 4.0 * u / 1024 * math.sin((fr - 30) * 2.6)
|
||||
return dict(pouch=(cx + jx, y_draw), stone=(cx + jx, y_draw + lift), stone_scale=1.1)
|
||||
if fr < 39:
|
||||
t = (fr - 35) / 4
|
||||
y = y_draw + (y_tip - y_draw) * t * t
|
||||
return dict(pouch=(cx, y), stone=(cx, y + lift), stone_scale=1.1)
|
||||
tau = (fr - 39) / FPS
|
||||
band_y = y_tip + 0.085 * u * math.exp(-6.5 * tau) * math.sin(2 * math.pi * 3.0 * tau)
|
||||
out = dict(pouch=(cx, band_y), stone=False)
|
||||
if fr < 43:
|
||||
k = (fr - 39 + 1) / 4
|
||||
out.update(stone=(cx, y_tip + lift + (top_exit - y_tip - lift) * k), stone_scale=1.1 - 0.5 * k)
|
||||
return out
|
||||
if fr < 66:
|
||||
return out
|
||||
if fr < 75:
|
||||
t = (fr - 66 + 1) / 9
|
||||
rest_stone = y_rest + lift
|
||||
sy = top_exit + (rest_stone - top_exit) * t * t
|
||||
py = min(band_y, sy - lift) if sy - lift < y_tip else band_y
|
||||
return dict(pouch=(cx, py), stone=(cx, sy))
|
||||
tau = (fr - 74) / FPS
|
||||
sy = (y_rest + lift) - 0.05 * u * math.exp(-7 * tau) * math.sin(2 * math.pi * 2.6 * tau)
|
||||
return dict(pouch=(cx, sy - lift), stone=(cx, sy))
|
||||
|
||||
|
||||
def render(style="font", tone="dark"):
|
||||
box = panel_box(style)
|
||||
top_exit = box[3] + fs.R_STONE * 1.4
|
||||
bg = fs.TONES[tone]["bg"]
|
||||
pw, ph = int(PANEL_W * SCALE), int(PANEL_H * SCALE)
|
||||
frames = []
|
||||
with sync_playwright() as p:
|
||||
b = chromium(p)
|
||||
pg = b.new_page(viewport={"width": pw, "height": ph})
|
||||
pg.set_content('<html><body style="margin:0"><div id="a"></div></body></html>')
|
||||
for fr in range(N):
|
||||
mk = fs.draw(style, tone, **state(fr, style, top_exit))
|
||||
svg, _ = mk.svg(box=box, bg=bg, label="SLYNG")
|
||||
svg = svg.replace("<svg ", f'<svg width="{pw}" height="{ph}" style="display:block" ', 1)
|
||||
pg.evaluate("s => { document.getElementById('a').innerHTML = s; }", svg)
|
||||
path = OUT / f"_m{fr:03d}.png"
|
||||
pg.locator("#a svg").screenshot(path=str(path))
|
||||
frames.append(Image.open(path).convert("RGB"))
|
||||
b.close()
|
||||
name = f"wm-font-anim-{style}-{tone}.webp"
|
||||
frames[0].save(OUT / name, save_all=True, append_images=frames[1:], duration=int(1000 / FPS),
|
||||
loop=0, lossless=True, method=6, quality=100)
|
||||
for fp in OUT.glob("_m*.png"):
|
||||
fp.unlink()
|
||||
return OUT / name
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
style = sys.argv[1] if len(sys.argv) > 1 else "font"
|
||||
for tone in sys.argv[2:] or ["dark", "light"]:
|
||||
out = render(style, tone)
|
||||
print(out, out.stat().st_size)
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Wordmark motion, back to the earlier feel, ending each loop in the logo pose.
|
||||
|
||||
Loop (30 fps, 90 frames = 3 s):
|
||||
0-14 hold the logo pose (stone on the vertex, bands along the arms), tremble at the end
|
||||
15-17 release: the band snaps up to straight across the tips
|
||||
18-21 the stone flies up and out of the panel, getting smaller
|
||||
18-53 the band rings out around straight, then rests empty
|
||||
54-62 a new stone drops in; the band catches it in a shallow V near the top
|
||||
63-69 it settles
|
||||
70-84 pull back: the pouch slides down the arms until the stone sits on the vertex
|
||||
85-89 hold (wraps into frame 0)
|
||||
|
||||
Same glitch fixes as before: opaque, lossless frames the size of the panel."""
|
||||
import math
|
||||
import pathlib
|
||||
import sys
|
||||
from PIL import Image
|
||||
from playwright.sync_api import sync_playwright
|
||||
from kit import chromium
|
||||
import final as F
|
||||
|
||||
OUT = pathlib.Path(__file__).parent / "out"
|
||||
FPS, N = 30, 90
|
||||
PANEL_W, PANEL_H, WORD_W = 1200, 400, 900
|
||||
SCALE = 1.5
|
||||
|
||||
G = F.G
|
||||
U = G["unit"]
|
||||
CX = G["cx"]
|
||||
Y_TIP = F.H
|
||||
SHALLOW_P = Y_TIP - 0.10 * U
|
||||
LIFT_SHALLOW = 0.45 * F.R_STONE
|
||||
LIFT_DRAWN = G["vertex"][1] - G["apex"][1] # stone sits below the band apex, on the vertex
|
||||
|
||||
|
||||
def ease_io(t):
|
||||
return 4 * t ** 3 if t < 0.5 else 1 - (-2 * t + 2) ** 3 / 2
|
||||
|
||||
|
||||
def panel_box():
|
||||
x0, y0, x1, y1 = F.static_box()
|
||||
w = (x1 - x0) * PANEL_W / WORD_W
|
||||
h = w * PANEL_H / PANEL_W
|
||||
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
return (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2)
|
||||
|
||||
|
||||
def drawn_mix(k):
|
||||
"""k=0: shallow (just reloaded), k=1: the logo pose."""
|
||||
py = SHALLOW_P + (G["apex"][1] - SHALLOW_P) * k
|
||||
lift = LIFT_SHALLOW + (LIFT_DRAWN - LIFT_SHALLOW) * k
|
||||
return py, py + lift
|
||||
|
||||
|
||||
def ring(fr, start, amp=0.085):
|
||||
tau = (fr - start) / FPS
|
||||
return Y_TIP + amp * U * math.exp(-6.5 * tau) * math.sin(2 * math.pi * 3.0 * tau)
|
||||
|
||||
|
||||
def state(fr, top_exit):
|
||||
if fr < 15 or fr >= 85:
|
||||
jx = 0.0
|
||||
if 10 <= fr < 15:
|
||||
jx = 4.0 * U / 1024 * math.sin((fr - 10) * 2.6)
|
||||
return dict(pouch=(CX + jx, G["apex"][1]), stone=(CX + jx, G["vertex"][1]))
|
||||
if fr < 18: # snap up, stone still seated
|
||||
t = (fr - 14) / 3.4
|
||||
py = G["apex"][1] + (Y_TIP - G["apex"][1]) * t * t
|
||||
return dict(pouch=(CX, py), stone=(CX, py + LIFT_DRAWN))
|
||||
band_y = ring(fr, 18)
|
||||
out = dict(pouch=(CX, band_y), stone=False)
|
||||
if fr < 22:
|
||||
k = (fr - 18 + 1) / 4
|
||||
out.update(stone=(CX, Y_TIP + (top_exit - Y_TIP) * k), stone_scale=1.0 - 0.45 * k)
|
||||
return out
|
||||
if fr < 54:
|
||||
return out
|
||||
if fr < 63: # reload: falls in, the band catches it
|
||||
t = (fr - 54 + 1) / 9
|
||||
rest = SHALLOW_P + LIFT_SHALLOW
|
||||
sy = top_exit + (rest - top_exit) * t * t
|
||||
py = min(band_y, sy - LIFT_SHALLOW)
|
||||
return dict(pouch=(CX, py), stone=(CX, sy))
|
||||
if fr < 70: # settle into the shallow V
|
||||
tau = (fr - 62) / FPS
|
||||
dip = 0.04 * U * math.exp(-7 * tau) * math.sin(2 * math.pi * 2.6 * tau)
|
||||
py = SHALLOW_P - dip
|
||||
return dict(pouch=(CX, py), stone=(CX, py + LIFT_SHALLOW))
|
||||
k = ease_io((fr - 70) / 15) # pull back into the logo pose
|
||||
py, sy = drawn_mix(k)
|
||||
return dict(pouch=(CX, py), stone=(CX, sy))
|
||||
|
||||
|
||||
def render(tone="dark"):
|
||||
box = panel_box()
|
||||
top_exit = box[3] + F.R_STONE * 1.5
|
||||
bg = F.TONES[tone]["bg"]
|
||||
pw, ph = int(PANEL_W * SCALE), int(PANEL_H * SCALE)
|
||||
frames = []
|
||||
with sync_playwright() as p:
|
||||
b = chromium(p)
|
||||
pg = b.new_page(viewport={"width": pw, "height": ph})
|
||||
pg.set_content('<html><body style="margin:0"><div id="a"></div></body></html>')
|
||||
for fr in range(N):
|
||||
mk = F.pose(tone, **state(fr, top_exit))
|
||||
svg, _ = mk.svg(box=box, bg=bg, label="SLYNG")
|
||||
svg = svg.replace("<svg ", f'<svg width="{pw}" height="{ph}" style="display:block" ', 1)
|
||||
pg.evaluate("s => { document.getElementById('a').innerHTML = s; }", svg)
|
||||
path = OUT / f"_f{fr:03d}.png"
|
||||
pg.locator("#a svg").screenshot(path=str(path))
|
||||
frames.append(Image.open(path).convert("RGB"))
|
||||
b.close()
|
||||
name = f"wm-final-anim-{tone}.webp"
|
||||
frames[0].save(OUT / name, save_all=True, append_images=frames[1:], duration=int(1000 / FPS),
|
||||
loop=0, lossless=True, method=6, quality=100)
|
||||
for fp in OUT.glob("_f*.png"):
|
||||
fp.unlink()
|
||||
return OUT / name
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for tone in sys.argv[1:] or ["dark", "light"]:
|
||||
out = render(tone)
|
||||
print(out, out.stat().st_size)
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# tools/brand/restore.sh: put branding/img/ back from the live site, every file checked against branding/img/MANIFEST.
|
||||
#
|
||||
# The brand page's images are made by tools/brand/make.py (Python, fontTools, Chrome) and land on gh-pages with the
|
||||
# seed workflow; the branch keeps only their MANIFEST. tools/build.sh runs this so a refresh from the branch brings
|
||||
# them along instead of dropping them. Files already here with the right sha256 are left alone. Anything missing
|
||||
# from the live site, or different from MANIFEST, stops the build, so a refresh never lands the page without them.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/../.."
|
||||
SITE=${SLYNG_SITE:-https://slyng.yolopreneur.lol}
|
||||
M=branding/img/MANIFEST
|
||||
[ -f "$M" ] || { echo "no $M: nothing to restore"; exit 0; }
|
||||
sum() { (sha256sum "$1" 2>/dev/null || shasum -a 256 "$1") | cut -d' ' -f1; }
|
||||
got=0
|
||||
kept=0
|
||||
while read -r want name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case $name in */* | .*) echo "bad name in $M: $name" >&2; exit 1 ;; esac
|
||||
f="branding/img/$name"
|
||||
if [ -f "$f" ] && [ "$(sum "$f")" = "$want" ]; then kept=$((kept + 1)); continue; fi
|
||||
curl -fsS --retry 3 --max-time 60 -o "$f.part" "$SITE/branding/img/$name" || {
|
||||
rm -f "$f.part"; echo "couldn't fetch $SITE/branding/img/$name: run tools/brand/make.py and land branding/img with the seed workflow" >&2; exit 1; }
|
||||
have=$(sum "$f.part")
|
||||
[ "$have" = "$want" ] || {
|
||||
rm -f "$f.part"; echo "$SITE/branding/img/$name has sha256 $have, $M says $want: land the new images with the seed workflow first" >&2; exit 1; }
|
||||
mv "$f.part" "$f"
|
||||
got=$((got + 1))
|
||||
done < "$M"
|
||||
echo "branding/img: $got fetched from $SITE, $kept already here"
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Slingshot-Y construction in font units (y up).
|
||||
|
||||
The Y is built from its centreline: two arms that would meet at (cx, yc),
|
||||
a handle down to `base`, arm width `wa`, handle width `w`. The inner crotch
|
||||
gets a round 'U' fillet (what makes it read as a slingshot fork), the outer
|
||||
junction an optional fillet, tips are cut flat at H or capped round, and the
|
||||
handle ends flat or round.
|
||||
"""
|
||||
import math
|
||||
|
||||
|
||||
def f(v):
|
||||
return f"{v:.1f}".rstrip("0").rstrip(".") if abs(v) < 1e7 else str(v)
|
||||
|
||||
|
||||
class P:
|
||||
"""Tiny path builder (font units, y up)."""
|
||||
|
||||
def __init__(self):
|
||||
self.d = []
|
||||
self.pt = None
|
||||
|
||||
def M(self, p):
|
||||
self.d.append(f"M{f(p[0])} {f(p[1])}")
|
||||
self.pt = p
|
||||
return self
|
||||
|
||||
def L(self, p):
|
||||
self.d.append(f"L{f(p[0])} {f(p[1])}")
|
||||
self.pt = p
|
||||
return self
|
||||
|
||||
def C(self, c1, c2, p):
|
||||
self.d.append(f"C{f(c1[0])} {f(c1[1])} {f(c2[0])} {f(c2[1])} {f(p[0])} {f(p[1])}")
|
||||
self.pt = p
|
||||
return self
|
||||
|
||||
def arc(self, c, r, a0, a1):
|
||||
"""Circular arc around c from angle a0 to a1 (radians, math sense)."""
|
||||
n = max(1, int(math.ceil(abs(a1 - a0) / (math.pi / 2) - 1e-9)))
|
||||
da = (a1 - a0) / n
|
||||
k = 4 / 3 * math.tan(da / 4)
|
||||
for i in range(n):
|
||||
t0 = a0 + i * da
|
||||
t1 = t0 + da
|
||||
p0 = (c[0] + r * math.cos(t0), c[1] + r * math.sin(t0))
|
||||
p3 = (c[0] + r * math.cos(t1), c[1] + r * math.sin(t1))
|
||||
c1 = (p0[0] - k * r * math.sin(t0), p0[1] + k * r * math.cos(t0))
|
||||
c2 = (p3[0] + k * r * math.sin(t1), p3[1] - k * r * math.cos(t1))
|
||||
if self.pt is None:
|
||||
self.M(p0)
|
||||
self.C(c1, c2, p3)
|
||||
return self
|
||||
|
||||
def Z(self):
|
||||
self.d.append("Z")
|
||||
return self
|
||||
|
||||
def s(self):
|
||||
return " ".join(self.d)
|
||||
|
||||
|
||||
def add(a, b, k=1.0):
|
||||
return (a[0] + k * b[0], a[1] + k * b[1])
|
||||
|
||||
|
||||
def ang(v):
|
||||
return math.atan2(v[1], v[0])
|
||||
|
||||
|
||||
class SlingY:
|
||||
def __init__(self, cx, H, base, w, wa, a, yc, r_u=0.0, r_o=0.0,
|
||||
tips="flat", bottom="flat", tip_over=0.0, flare=0.0):
|
||||
self.cx, self.H, self.base = cx, H, base
|
||||
self.w, self.wa, self.a, self.yc = w, wa, a, yc
|
||||
self.r_u, self.r_o = r_u, r_o
|
||||
self.tips, self.bottom = tips, bottom
|
||||
self.tip_over = tip_over
|
||||
self.flare = flare # handle gets wider toward the bottom by this much (each side)
|
||||
# arm angle from vertical
|
||||
self.Hc = H - (wa / 2 if tips == "round" else 0) + tip_over
|
||||
self.phi = math.atan2(a, self.Hc - yc)
|
||||
self.sp, self.cp = math.sin(self.phi), math.cos(self.phi)
|
||||
self.tn = math.tan(self.phi)
|
||||
|
||||
# x of left arm edges at height y (right arm mirrors)
|
||||
def x_out(self, y):
|
||||
return self.cx - self.a - (self.wa / 2) / self.cp + (self.Hc - y) * self.tn
|
||||
|
||||
def x_in(self, y):
|
||||
return self.cx - self.a + (self.wa / 2) / self.cp + (self.Hc - y) * self.tn
|
||||
|
||||
def centre(self, y):
|
||||
return self.cx - self.a + (self.Hc - y) * self.tn
|
||||
|
||||
def geometry(self):
|
||||
cx, w = self.cx, self.w
|
||||
sp, cp, tn = self.sp, self.cp, self.tn
|
||||
# inner V point on the centre line
|
||||
y_v = self.Hc - ((self.a - (self.wa / 2) / cp) / tn)
|
||||
# fillet in the V
|
||||
r = self.r_u
|
||||
t = r / tn if r > 0 else 0
|
||||
TL = (cx - t * sp, y_v + t * cp)
|
||||
TR = (cx + t * sp, y_v + t * cp)
|
||||
Cu = (cx, y_v + (r / sp if r > 0 else 0))
|
||||
# outer junction with the handle edge x = cx - w/2
|
||||
y_j = self.Hc - ((self.a + (self.wa / 2) / cp - w / 2) / tn)
|
||||
return dict(y_v=y_v, TL=TL, TR=TR, Cu=Cu, y_j=y_j,
|
||||
u_bottom=Cu[1] - r if r > 0 else y_v)
|
||||
|
||||
def path(self):
|
||||
g = self.geometry()
|
||||
cx, w, H, base = self.cx, self.w, self.H, self.base
|
||||
sp, cp = self.sp, self.cp
|
||||
p = P()
|
||||
fl = self.flare
|
||||
# --- handle bottom, left side
|
||||
if self.bottom == "round":
|
||||
rb = w / 2 + fl
|
||||
p.M((cx + rb, base + rb))
|
||||
p.arc((cx, base + rb), rb, 0, -math.pi) # right -> bottom -> left
|
||||
p.L((cx - w / 2, base + rb + 0.0))
|
||||
else:
|
||||
p.M((cx + w / 2 + fl, base))
|
||||
p.L((cx - w / 2 - fl, base))
|
||||
# --- up the left handle edge to the outer junction (with fillet)
|
||||
J = (cx - w / 2, g["y_j"])
|
||||
if self.r_o > 0:
|
||||
ro = self.r_o
|
||||
half = (math.pi - self.phi) / 2
|
||||
tt = ro / math.tan(half)
|
||||
up_arm = (-sp, cp)
|
||||
Ts = (J[0], J[1] - tt) # on handle edge
|
||||
Ta = add(J, up_arm, tt) # on arm edge
|
||||
bis = (-sp + 0, cp - 1)
|
||||
bl = math.hypot(*bis)
|
||||
bis = (bis[0] / bl, bis[1] / bl)
|
||||
Co = add(J, bis, ro / math.sin(half))
|
||||
p.L(Ts)
|
||||
a0 = ang((Ts[0] - Co[0], Ts[1] - Co[1]))
|
||||
a1 = ang((Ta[0] - Co[0], Ta[1] - Co[1]))
|
||||
# concave corner on a clockwise outline: counter-clockwise arc
|
||||
if a1 < a0:
|
||||
a1 += 2 * math.pi
|
||||
p.arc(Co, ro, a0, a1)
|
||||
else:
|
||||
p.L(J)
|
||||
# --- up the outer left arm edge to the tip
|
||||
if self.tips == "round":
|
||||
E = (self.centre(self.Hc), self.Hc)
|
||||
n_out = (-cp, -sp)
|
||||
Po = add(E, n_out, self.wa / 2)
|
||||
p.L(Po)
|
||||
a0 = ang(n_out)
|
||||
p.arc(E, self.wa / 2, a0, a0 - math.pi) # over the top to the inner side
|
||||
else:
|
||||
p.L((self.x_out(H), H))
|
||||
p.L((self.x_in(H), H))
|
||||
# --- down the inner left edge into the U, across, up the inner right edge
|
||||
if self.r_u > 0:
|
||||
p.L(g["TL"])
|
||||
Cu = g["Cu"]
|
||||
# TL sits lower-left of the fillet centre, TR lower-right: go
|
||||
# counter-clockwise through the bottom
|
||||
a0 = ang((g["TL"][0] - Cu[0], g["TL"][1] - Cu[1]))
|
||||
a1 = ang((g["TR"][0] - Cu[0], g["TR"][1] - Cu[1]))
|
||||
if a1 < a0:
|
||||
a1 += 2 * math.pi # counter-clockwise from upper-left through bottom to upper-right
|
||||
p.arc(Cu, self.r_u, a0, a1)
|
||||
else:
|
||||
p.L((cx, g["y_v"]))
|
||||
mx = lambda q: (2 * cx - q[0], q[1])
|
||||
if self.tips == "round":
|
||||
E = (self.centre(self.Hc), self.Hc)
|
||||
n_in = (cp, sp)
|
||||
Pi = add(E, n_in, self.wa / 2)
|
||||
p.L(mx(Pi))
|
||||
# right tip cap: from inner side over the top to the outer side
|
||||
Er = mx(E)
|
||||
a0 = ang((-n_in[0], n_in[1])) # inner normal of the right arm points left-up
|
||||
p.arc(Er, self.wa / 2, a0, a0 - math.pi)
|
||||
else:
|
||||
p.L(mx((self.x_in(H), H)))
|
||||
p.L(mx((self.x_out(H), H)))
|
||||
# down the right outer edge to the junction
|
||||
if self.r_o > 0:
|
||||
ro = self.r_o
|
||||
half = (math.pi - self.phi) / 2
|
||||
tt = ro / math.tan(half)
|
||||
Ta = mx(add(J, (-sp, cp), tt))
|
||||
Ts = mx((J[0], J[1] - tt))
|
||||
bis = (-sp, cp - 1)
|
||||
bl = math.hypot(*bis)
|
||||
bis = (bis[0] / bl, bis[1] / bl)
|
||||
Co = mx(add(J, bis, ro / math.sin(half)))
|
||||
p.L(Ta)
|
||||
a0 = ang((Ta[0] - Co[0], Ta[1] - Co[1]))
|
||||
a1 = ang((Ts[0] - Co[0], Ts[1] - Co[1]))
|
||||
if a1 < a0:
|
||||
a1 += 2 * math.pi
|
||||
p.arc(Co, ro, a0, a1)
|
||||
else:
|
||||
p.L(mx(J))
|
||||
# down the right handle edge
|
||||
if self.bottom == "round":
|
||||
p.L((cx + w / 2, base + w / 2 + fl))
|
||||
else:
|
||||
p.L((cx + w / 2 + fl, base))
|
||||
p.Z()
|
||||
return p.s()
|
||||
|
||||
# anchor points for the band: on the inner edge, `drop` below the top
|
||||
def anchors(self, drop=0.0, inset=0.0):
|
||||
y = self.Hc - drop if self.tips == "round" else self.H - drop
|
||||
if self.tips == "round" and drop == 0:
|
||||
E = (self.centre(self.Hc), self.Hc)
|
||||
L = (E[0] + inset, E[1])
|
||||
else:
|
||||
L = (self.x_in(y) - inset, y)
|
||||
R = (2 * self.cx - L[0], L[1])
|
||||
return L, R
|
||||
|
||||
|
||||
def svg_doc(body, vb, bg=None, w=None, h=None):
|
||||
x, y, W, H = vb
|
||||
rect = f'<rect x="{f(x)}" y="{f(y)}" width="{f(W)}" height="{f(H)}" fill="{bg}"/>' if bg else ""
|
||||
size = f' width="{w}" height="{h}"' if w else ""
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{f(x)} {f(y)} {f(W)} {f(H)}"{size}>'
|
||||
f"{rect}{body}</svg>")
|
||||
|
||||
|
||||
def make_y(cx, H, w, wa, outer_half, y_j, base=0, r_u=0, r_o=0, tips="flat",
|
||||
bottom="flat", tip_over=0.0, flare=0.0):
|
||||
"""Slingshot Y from intuitive targets: arms reach `outer_half` from the
|
||||
centre at the top, and their outer edges meet the handle (width w) at y_j."""
|
||||
def build(phi):
|
||||
cp = math.cos(phi)
|
||||
a = outer_half - (wa / 2 if tips == "round" else (wa / 2) / cp)
|
||||
Hc = H - (wa / 2 if tips == "round" else 0) + tip_over
|
||||
yc = Hc - a / math.tan(phi)
|
||||
return SlingY(cx, H, base, w, wa, a, yc, r_u=r_u, r_o=r_o, tips=tips,
|
||||
bottom=bottom, tip_over=tip_over, flare=flare)
|
||||
|
||||
lo, hi = math.radians(1), math.radians(80)
|
||||
for _ in range(100):
|
||||
mid = (lo + hi) / 2
|
||||
y = build(mid)
|
||||
err = y.x_out(y_j) - (cx - w / 2)
|
||||
# larger phi -> edge moves right faster going down -> x_out(y_j) larger
|
||||
if err > 0:
|
||||
hi = mid
|
||||
else:
|
||||
lo = mid
|
||||
return build((lo + hi) / 2)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""The icon's slingshot rebuilt as a letter: same shape language (round tips,
|
||||
round handle, U crotch, ink-outlined band and stone), weights matched to the
|
||||
typeface so it sits in the word as the Y."""
|
||||
import math
|
||||
import direction as d
|
||||
from kit import INK, WHITE, VOLT, BONE, Mark, word
|
||||
from sling import make_y, f
|
||||
|
||||
FIT = {
|
||||
# outer half-width, handle and arm weights as a fraction of the font's stem
|
||||
"unbounded": dict(outer=400, w_k=0.93, wa_k=0.84, over=0.03, gl=0.10, gr=0.12),
|
||||
"lexend": dict(outer=356, w_k=0.93, wa_k=0.84, over=0.03, gl=0.12, gr=0.15),
|
||||
}
|
||||
TONES = {
|
||||
"dark": dict(letters=WHITE, frame=WHITE, band=VOLT, stone=VOLT, knock=INK),
|
||||
"light": dict(letters=INK, frame=INK, band=INK, stone=INK, knock=BONE),
|
||||
"volt": dict(letters=INK, frame=INK, band=INK, stone=INK, knock=VOLT),
|
||||
}
|
||||
|
||||
|
||||
class SlingLetter:
|
||||
def __init__(self, kind, cx, outer=None):
|
||||
p = FIT[kind]
|
||||
face = d.WORDMARKS[kind]["face"]
|
||||
H, stem = face.cap, face.stem("L")[0]
|
||||
over = p["over"] * H
|
||||
self.top, self.base = H + over, -over
|
||||
hf = self.top - self.base
|
||||
self.hf = hf
|
||||
self.cx = cx
|
||||
outer = outer or p["outer"]
|
||||
self.Y = make_y(cx, self.top, stem * p["w_k"], stem * p["wa_k"], outer, self.base + 0.40 * hf,
|
||||
base=self.base, r_u=0.107 * hf, r_o=0.057 * hf, tips="round", bottom="round")
|
||||
self.L, self.R = self.Y.anchors()
|
||||
self.ub = self.Y.geometry()["u_bottom"]
|
||||
self.r_stone = 0.093 * hf
|
||||
self.band_w = 0.064 * hf
|
||||
self.kw = 0.023 * hf
|
||||
self.p_rest = (cx, self.ub + self.r_stone + 0.029 * hf)
|
||||
self.tip_y = self.L[1]
|
||||
self.outer = outer
|
||||
|
||||
def box(self):
|
||||
return (self.cx - self.outer, self.base, self.cx + self.outer, self.top)
|
||||
|
||||
def draw(self, tone="dark", pouch=None, stone=None, stone_scale=1.0, extra_stone=None, **_):
|
||||
c = TONES[tone]
|
||||
pouch = self.p_rest if pouch is None else pouch
|
||||
if stone is None:
|
||||
stone = pouch
|
||||
frame = f'<path d="{self.Y.path()}" fill="{c["frame"]}"/>'
|
||||
dd = f"M{f(self.L[0])} {f(self.L[1])} L{f(pouch[0])} {f(pouch[1])} L{f(self.R[0])} {f(self.R[1])}"
|
||||
band = (f'<path d="{dd}" fill="none" stroke="{c["knock"]}" stroke-width="{f(self.band_w + 2 * self.kw)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
f'<path d="{dd}" fill="none" stroke="{c["band"]}" stroke-width="{f(self.band_w)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>')
|
||||
|
||||
def st(x, y, sc):
|
||||
r = self.r_stone * sc
|
||||
return (f'<circle cx="{f(x)}" cy="{f(y)}" r="{f(r + self.kw)}" fill="{c["knock"]}"/>'
|
||||
f'<circle cx="{f(x)}" cy="{f(y)}" r="{f(r)}" fill="{c["stone"]}"/>')
|
||||
|
||||
s = frame + band
|
||||
if stone is not False:
|
||||
s += st(stone[0], stone[1], stone_scale)
|
||||
if extra_stone:
|
||||
s += st(*extra_stone)
|
||||
return s
|
||||
|
||||
|
||||
def wordmark(kind="unbounded", tone="dark", outer=None, state=None):
|
||||
p = FIT[kind]
|
||||
face = d.WORDMARKS[kind]["face"]
|
||||
H = face.cap
|
||||
c = TONES[tone]
|
||||
mk = Mark()
|
||||
pos, _ = word(mk, face, "SL", c["letters"])
|
||||
lb = face.bounds("L")
|
||||
outer = outer or p["outer"]
|
||||
cx = pos[1][1] + lb[2] + p["gl"] * H + outer
|
||||
sl = SlingLetter(kind, cx, outer)
|
||||
mk.add(sl.draw(tone, **(state or {})), sl.box())
|
||||
nb = face.bounds("N")
|
||||
word(mk, face, "NG", c["letters"], x0=cx + outer + p["gr"] * H - nb[0])
|
||||
return mk, sl
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the exact clean slingshot as the Y
|
||||
BARE = {
|
||||
"dark": dict(letters=WHITE, frame=WHITE, band=VOLT, stone=VOLT, knock=INK),
|
||||
"light-volt": dict(letters=INK, frame=INK, band=VOLT, stone=VOLT, knock=INK),
|
||||
"light-mono": dict(letters=INK, frame=INK, band=INK, stone=INK, knock=BONE),
|
||||
}
|
||||
GAPS = {"unbounded": (0.10, 0.12), "lexend": (0.11, 0.14)}
|
||||
|
||||
|
||||
def bare_draw(tone="dark", pouch=None, stone=None, stone_scale=1.0, extra_stone=None, **_):
|
||||
"""The icon's slingshot without its square, in icon units (0..1024, y up)."""
|
||||
c = BARE[tone]
|
||||
kw = d.S * 0.016
|
||||
pouch = d.P_REST if pouch is None else pouch
|
||||
if stone is None:
|
||||
stone = pouch
|
||||
frame = f'<path transform="translate(0 {f(d.BASE)})" d="{d.Y.path()}" fill="{c["frame"]}"/>'
|
||||
dd = f"M{f(d.L[0])} {f(d.L[1])} L{f(pouch[0])} {f(pouch[1])} L{f(d.R[0])} {f(d.R[1])}"
|
||||
s = (frame +
|
||||
f'<path d="{dd}" fill="none" stroke="{c["knock"]}" stroke-width="{f(d.BAND_W + 2 * kw)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
f'<path d="{dd}" fill="none" stroke="{c["band"]}" stroke-width="{f(d.BAND_W)}" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>')
|
||||
|
||||
def st(x, y, sc):
|
||||
r = d.R_STONE * sc
|
||||
return (f'<circle cx="{f(x)}" cy="{f(y)}" r="{f(r + kw)}" fill="{c["knock"]}"/>'
|
||||
f'<circle cx="{f(x)}" cy="{f(y)}" r="{f(r)}" fill="{c["stone"]}"/>')
|
||||
|
||||
if stone is not False:
|
||||
s += st(stone[0], stone[1], stone_scale)
|
||||
if extra_stone:
|
||||
s += st(*extra_stone)
|
||||
return s
|
||||
|
||||
|
||||
BARE_BOX = (d.CX - d.S * 0.30, d.BASE, d.CX + d.S * 0.30, d.BASE + d.H_FRAME)
|
||||
|
||||
|
||||
def bare_wordmark(kind="unbounded", tone="dark", k=1.12, state=None):
|
||||
face = d.WORDMARKS[kind]["face"]
|
||||
H = face.cap
|
||||
c = BARE[tone]
|
||||
gl, gr = GAPS[kind]
|
||||
mk = Mark()
|
||||
pos, _ = word(mk, face, "SL", c["letters"])
|
||||
lb = face.bounds("L")
|
||||
bx0, by0, bx1, by1 = BARE_BOX
|
||||
s = k * H / (by1 - by0)
|
||||
tx = pos[1][1] + lb[2] + gl * H - bx0 * s
|
||||
ty = H / 2 - ((by0 + by1) / 2) * s
|
||||
mk.add(f'<g transform="translate({f(tx)} {f(ty)}) scale({s:.5f})">{bare_draw(tone, **(state or {}))}</g>',
|
||||
(tx + bx0 * s, ty + by0 * s, tx + bx1 * s, ty + by1 * s))
|
||||
nb = face.bounds("N")
|
||||
word(mk, face, "NG", c["letters"], x0=tx + bx1 * s + gr * H - nb[0])
|
||||
return mk
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/bin/sh
|
||||
# tools/build.sh: SLYNG's made files, made in place: the tests, branding/img/ (brought back from the live site and
|
||||
# checked against its MANIFEST, see tools/brand/restore.sh), then shots/desktop.png and shots/og.png.
|
||||
#
|
||||
# Time's refresh workflow runs this on the runner (teahubxyz/time, Actions -> refresh, slug slyng); it runs anywhere
|
||||
# else too. Needs node 22+, curl, and Chrome or Chromium: set CHROME=/path/to/chrome if it isn't found, and
|
||||
# CHROME_FLAGS=--no-sandbox when running as root. The fuller check is tests/smoke.py (Playwright), run by hand.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
node --test tests/*.test.js
|
||||
sh tools/brand/restore.sh
|
||||
|
||||
CHROME=${CHROME:-}
|
||||
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$(command -v chromium || true)" \
|
||||
"$(command -v chromium-browser || true)" "$(command -v google-chrome || true)" "$(command -v google-chrome-stable || true)"; do
|
||||
if [ -z "$CHROME" ] && [ -n "$c" ] && [ -x "$c" ]; then CHROME=$c; fi
|
||||
done
|
||||
[ -n "$CHROME" ] || { echo "no Chrome or Chromium found; set CHROME" >&2; exit 1; }
|
||||
|
||||
PORT=${PORT:-8793}
|
||||
node tools/serve.mjs "$PORT" . >/dev/null 2>&1 </dev/null &
|
||||
SRV=$!
|
||||
trap 'kill $SRV 2>/dev/null || true' EXIT INT TERM
|
||||
n=0
|
||||
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/"; do
|
||||
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
|
||||
done
|
||||
|
||||
shot() { # shot OUT.png [chrome args] URL
|
||||
out=$1; shift
|
||||
prof=$(mktemp -d)
|
||||
rm -f "$out"
|
||||
# shellcheck disable=SC2086
|
||||
"$CHROME" --headless=new --hide-scrollbars --no-first-run --no-default-browser-check --disable-background-networking \
|
||||
--disable-component-update --user-data-dir="$prof" --virtual-time-budget=6000 --screenshot="$out" \
|
||||
${CHROME_FLAGS:-} "$@" >"$prof.log" 2>&1 &
|
||||
pid=$!
|
||||
n=0
|
||||
while kill -0 "$pid" 2>/dev/null && [ ! -s "$out" ]; do # some Chromes write the shot and never exit
|
||||
n=$((n + 1)); [ "$n" -lt 120 ] || break; sleep 0.5
|
||||
done
|
||||
sleep 1
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
[ -s "$out" ] || { echo "Chrome made no screenshot for $out:" >&2; tail -5 "$prof.log" >&2; exit 1; }
|
||||
rm -rf "$prof" "$prof.log"
|
||||
}
|
||||
mkdir -p shots
|
||||
shot "$PWD/shots/desktop.png" --window-size=1440,900 "http://127.0.0.1:$PORT/"
|
||||
shot "$PWD/shots/og.png" --window-size=1200,630 "http://127.0.0.1:$PORT/tools/og.html"
|
||||
echo "built: tests passed, branding/img restored, shots/desktop.png and shots/og.png"
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# tools/bundle.sh [OUT]: OUT/slyng.tgz (slyng/ at the top) and its sha256, for Time's seed workflow
|
||||
# (teahubxyz/time, Actions -> seed), when the bundle can reach the runner (a Boring blob download link).
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
OUT=${1:-/tmp/slyng-dist}
|
||||
export COPYFILE_DISABLE=1
|
||||
rm -rf "$OUT/slyng" "$OUT/slyng.tgz"
|
||||
mkdir -p "$OUT/slyng"
|
||||
for f in *; do [ "$f" = "$(basename "$OUT")" ] || cp -R "$f" "$OUT/slyng/"; done
|
||||
find "$OUT/slyng" \( -name .DS_Store -o -name __pycache__ \) -prune -exec rm -rf {} +
|
||||
tar -czf "$OUT/slyng.tgz" -C "$OUT" slyng
|
||||
sum=$( (shasum -a 256 "$OUT/slyng.tgz" 2>/dev/null || sha256sum "$OUT/slyng.tgz") | cut -d' ' -f1)
|
||||
echo "$sum" > "$OUT/slyng.tgz.sha256"
|
||||
echo "bundle $OUT/slyng.tgz: $(wc -c < "$OUT/slyng.tgz" | tr -d ' ') bytes, sha256 $sum"
|
||||
@@ -0,0 +1,286 @@
|
||||
// A tiny nostr network on localhost, for the tests: a relay (NIP-01, optional NIP-42 AUTH), a lightning address
|
||||
// server that hands out zap invoices and publishes receipts when they're "paid", and a Nostr Wallet Connect wallet.
|
||||
// No dependencies; the WebSocket server is the bare RFC 6455 handshake and frames.
|
||||
//
|
||||
// node tools/devnet.mjs [--auth] prints one JSON line with the relay, servers and test keys, then serves
|
||||
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { finalizeEvent, verifyEvent, now, npubEncode } from '../lib/nostr.js';
|
||||
import { generatePrivateKey, hex, getPublicKey, nip04Decrypt, nip04Encrypt, nip44ConversationKey, nip44Decrypt, nip44Encrypt, unhex } from '../lib/crypto.js';
|
||||
import { invoiceMsats } from '../lib/zap.js';
|
||||
|
||||
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
function frame(text, op = 0x1) {
|
||||
const data = Buffer.from(text);
|
||||
let head;
|
||||
if (data.length < 126) head = Buffer.from([0x80 | op, data.length]);
|
||||
else if (data.length < 65536) { head = Buffer.alloc(4); head[0] = 0x80 | op; head[1] = 126; head.writeUInt16BE(data.length, 2); }
|
||||
else { head = Buffer.alloc(10); head[0] = 0x80 | op; head[1] = 127; head.writeBigUInt64BE(BigInt(data.length), 2); }
|
||||
return Buffer.concat([head, data]);
|
||||
}
|
||||
|
||||
function acceptWebSockets(server, onConnection) {
|
||||
server.sockets = new Set();
|
||||
server.on('upgrade', (req, socket) => {
|
||||
server.sockets.add(socket);
|
||||
socket.on('close', () => server.sockets.delete(socket));
|
||||
const key = req.headers['sec-websocket-key'];
|
||||
if (!key) { socket.destroy(); return; }
|
||||
const accept = crypto.createHash('sha1').update(key + GUID).digest('base64');
|
||||
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
||||
let buf = Buffer.alloc(0);
|
||||
let parts = [];
|
||||
let open = true;
|
||||
const conn = {
|
||||
send: (text) => { if (open) socket.write(frame(text)); },
|
||||
close: () => { if (open) { open = false; try { socket.end(Buffer.from([0x88, 0])); } catch {} } },
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
};
|
||||
socket.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
for (;;) {
|
||||
if (buf.length < 2) return;
|
||||
const fin = buf[0] & 0x80, op = buf[0] & 0x0f, masked = buf[1] & 0x80;
|
||||
let len = buf[1] & 0x7f, off = 2;
|
||||
if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4; }
|
||||
else if (len === 127) { if (buf.length < 10) return; len = Number(buf.readBigUInt64BE(2)); off = 10; }
|
||||
const m = masked ? 4 : 0;
|
||||
if (buf.length < off + m + len) return;
|
||||
const payload = Buffer.from(buf.subarray(off + m, off + m + len));
|
||||
if (masked) { const mask = buf.subarray(off, off + 4); for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3]; }
|
||||
buf = buf.subarray(off + m + len);
|
||||
if (op === 0x8) { conn.close(); socket.destroy(); return; }
|
||||
if (op === 0x9) { socket.write(frame(payload.toString(), 0xa)); continue; }
|
||||
if (op === 0x1 || op === 0x0) {
|
||||
parts.push(payload);
|
||||
if (fin) { const text = Buffer.concat(parts).toString('utf8'); parts = []; conn.onmessage?.(text); }
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('close', () => { open = false; conn.onclose?.(); });
|
||||
socket.on('error', () => { open = false; });
|
||||
onConnection(conn, req);
|
||||
});
|
||||
}
|
||||
|
||||
export function matches(f, e) {
|
||||
if (f.ids && !f.ids.includes(e.id)) return false;
|
||||
if (f.authors && !f.authors.includes(e.pubkey)) return false;
|
||||
if (f.kinds && !f.kinds.includes(e.kind)) return false;
|
||||
if (f.since && e.created_at < f.since) return false;
|
||||
if (f.until && e.created_at > f.until) return false;
|
||||
for (const [k, vals] of Object.entries(f)) {
|
||||
if (k.length === 2 && k[0] === '#' && Array.isArray(vals)) {
|
||||
if (!e.tags.some((t) => t[0] === k[1] && vals.includes(t[1]))) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const isReplaceable = (k) => k === 0 || k === 3 || (k >= 10000 && k < 20000);
|
||||
const isEphemeral = (k) => k >= 20000 && k < 30000;
|
||||
|
||||
export async function startDevnet({ auth = false, port = 0, lnPort = 0 } = {}) {
|
||||
const events = [];
|
||||
const subs = new Set();
|
||||
const lnKey = generatePrivateKey();
|
||||
const lnPub = hex(getPublicKey(lnKey));
|
||||
const walletKey = generatePrivateKey();
|
||||
const walletPub = hex(getPublicKey(walletKey));
|
||||
const walletClient = generatePrivateKey();
|
||||
const invoices = new Map();
|
||||
const stats = { published: 0, authed: 0, paid: 0, rejected: 0 };
|
||||
|
||||
function store(e) {
|
||||
if (isEphemeral(e.kind)) return;
|
||||
if (isReplaceable(e.kind)) {
|
||||
const i = events.findIndex((x) => x.pubkey === e.pubkey && x.kind === e.kind);
|
||||
if (i >= 0) { if (events[i].created_at > e.created_at) return; events.splice(i, 1); }
|
||||
}
|
||||
if (!events.some((x) => x.id === e.id)) events.push(e);
|
||||
}
|
||||
|
||||
function broadcast(e) {
|
||||
for (const s of subs) if (s.filters.some((f) => matches(f, e)) && s.allowed(e)) s.conn.send(JSON.stringify(['EVENT', s.id, e]));
|
||||
}
|
||||
|
||||
function accept(e) {
|
||||
stats.published += 1;
|
||||
store(e);
|
||||
broadcast(e);
|
||||
walletBot(e);
|
||||
}
|
||||
|
||||
// ---- the relay
|
||||
const relayServer = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/nostr+json', 'access-control-allow-origin': '*' });
|
||||
res.end(JSON.stringify({ name: 'slyng devnet', supported_nips: [1, 11, 42] }));
|
||||
});
|
||||
acceptWebSockets(relayServer, (conn) => {
|
||||
const challenge = crypto.randomBytes(8).toString('hex');
|
||||
let authedAs = null;
|
||||
const mine = new Map();
|
||||
const private1059 = (e) => !auth || e.kind !== 1059 || (authedAs && e.tags.some((t) => t[0] === 'p' && t[1] === authedAs));
|
||||
if (auth) conn.send(JSON.stringify(['AUTH', challenge]));
|
||||
conn.onmessage = (raw) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
const [type, a, ...rest] = msg;
|
||||
if (type === 'EVENT') {
|
||||
const e = a;
|
||||
if (!verifyEvent(e)) { stats.rejected += 1; conn.send(JSON.stringify(['OK', e?.id || '', false, 'invalid: bad signature'])); return; }
|
||||
conn.send(JSON.stringify(['OK', e.id, true, '']));
|
||||
accept(e);
|
||||
} else if (type === 'REQ') {
|
||||
const filters = rest.filter((f) => f && typeof f === 'object');
|
||||
if (auth && !authedAs && filters.some((f) => f.kinds?.includes(1059))) {
|
||||
conn.send(JSON.stringify(['CLOSED', a, 'auth-required: private notes need AUTH']));
|
||||
return;
|
||||
}
|
||||
const s = { id: a, filters, conn, allowed: private1059 };
|
||||
mine.set(a, s);
|
||||
subs.add(s);
|
||||
const sent = new Set();
|
||||
for (const f of filters) {
|
||||
const hits = events.filter((e) => matches(f, e) && private1059(e)).sort((x, y) => y.created_at - x.created_at).slice(0, f.limit || 500);
|
||||
for (const e of hits) if (!sent.has(e.id)) { sent.add(e.id); conn.send(JSON.stringify(['EVENT', a, e])); }
|
||||
}
|
||||
conn.send(JSON.stringify(['EOSE', a]));
|
||||
} else if (type === 'CLOSE') {
|
||||
const s = mine.get(a);
|
||||
if (s) { subs.delete(s); mine.delete(a); }
|
||||
} else if (type === 'AUTH') {
|
||||
const e = a;
|
||||
const ok = verifyEvent(e) && e.kind === 22242 && e.tags.some((t) => t[0] === 'challenge' && t[1] === challenge)
|
||||
&& Math.abs(e.created_at - now()) < 600;
|
||||
if (ok) { authedAs = e.pubkey; stats.authed += 1; }
|
||||
conn.send(JSON.stringify(['OK', e?.id || '', ok, ok ? '' : 'auth-required: bad auth']));
|
||||
}
|
||||
};
|
||||
conn.onclose = () => { for (const s of mine.values()) subs.delete(s); mine.clear(); };
|
||||
});
|
||||
|
||||
await new Promise((r) => relayServer.listen(port, '127.0.0.1', r));
|
||||
const relayUrl = `ws://127.0.0.1:${relayServer.address().port}`;
|
||||
|
||||
// ---- the lightning address server (LUD-06 / LUD-16 with NIP-57 zaps)
|
||||
function settle(pr) {
|
||||
const inv = invoices.get(pr);
|
||||
if (!inv) return null;
|
||||
if (!inv.paid) {
|
||||
inv.paid = true;
|
||||
stats.paid += 1;
|
||||
if (inv.zapRequest) {
|
||||
const zr = inv.zapRequest;
|
||||
const tags = [['p', zr.tags.find((t) => t[0] === 'p')[1]], ['P', zr.pubkey], ['bolt11', pr], ['description', JSON.stringify(zr)], ['preimage', inv.preimage]];
|
||||
const e = zr.tags.find((t) => t[0] === 'e');
|
||||
if (e) tags.push(['e', e[1]]);
|
||||
accept(finalizeEvent({ kind: 9735, created_at: now(), tags, content: '' }, lnKey));
|
||||
}
|
||||
}
|
||||
return inv.preimage;
|
||||
}
|
||||
|
||||
const lnServer = http.createServer((req, res) => {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
const json = (code, body) => {
|
||||
res.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' });
|
||||
res.end(JSON.stringify(body));
|
||||
};
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204, { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }); res.end(); return; }
|
||||
let m = /^\/\.well-known\/lnurlp\/([a-z0-9._-]+)$/.exec(u.pathname);
|
||||
if (m) {
|
||||
json(200, {
|
||||
tag: 'payRequest', callback: `${lnBase}/lnurlp/callback/${m[1]}`, minSendable: 1000, maxSendable: 100_000_000,
|
||||
metadata: JSON.stringify([['text/plain', `sats for ${m[1]}`]]), allowsNostr: true, nostrPubkey: lnPub, commentAllowed: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
m = /^\/lnurlp\/callback\/([a-z0-9._-]+)$/.exec(u.pathname);
|
||||
if (m) {
|
||||
const amount = Number(u.searchParams.get('amount'));
|
||||
if (!amount || amount % 1000) { json(200, { status: 'ERROR', reason: 'amount must be whole sats' }); return; }
|
||||
let zapRequest = null;
|
||||
const raw = u.searchParams.get('nostr');
|
||||
if (raw) {
|
||||
try { zapRequest = JSON.parse(raw); } catch {}
|
||||
const amt = zapRequest?.tags?.find((t) => t[0] === 'amount')?.[1];
|
||||
if (!zapRequest || zapRequest.kind !== 9734 || !verifyEvent(zapRequest) || (amt && Number(amt) !== amount)) {
|
||||
json(200, { status: 'ERROR', reason: 'bad zap request' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const tail = Array.from(crypto.randomBytes(40), (b) => 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'[b & 31]).join('');
|
||||
const pr = `lnbc${amount / 100}n1p${tail}`;
|
||||
invoices.set(pr, { amount, zapRequest, paid: false, preimage: crypto.randomBytes(32).toString('hex'), name: m[1] });
|
||||
json(200, { pr, routes: [] });
|
||||
return;
|
||||
}
|
||||
if (u.pathname === '/settle') {
|
||||
const preimage = settle(u.searchParams.get('pr'));
|
||||
json(preimage ? 200 : 404, preimage ? { preimage } : { error: 'no such invoice' });
|
||||
return;
|
||||
}
|
||||
if (u.pathname === '/stats') { json(200, { ...stats, events: events.length }); return; }
|
||||
json(404, { error: 'not found' });
|
||||
});
|
||||
await new Promise((r) => lnServer.listen(lnPort, '127.0.0.1', r));
|
||||
const lnHost = `127.0.0.1:${lnServer.address().port}`;
|
||||
const lnBase = `http://${lnHost}`;
|
||||
|
||||
// ---- the Nostr Wallet Connect wallet: pays our own invoices, answers in NIP-44 or NIP-04 as asked
|
||||
async function walletBot(e) {
|
||||
if (e.kind !== 23194 || !e.tags.some((t) => t[0] === 'p' && t[1] === walletPub)) return;
|
||||
const nip44 = e.tags.some((t) => t[0] === 'encryption' && t[1] === 'nip44_v2');
|
||||
const ck = nip44ConversationKey(walletKey, unhex(e.pubkey));
|
||||
let req;
|
||||
try {
|
||||
req = JSON.parse(nip44 ? nip44Decrypt(e.content, ck) : await nip04Decrypt(e.content, walletKey, unhex(e.pubkey)));
|
||||
} catch { return; }
|
||||
let body;
|
||||
if (req.method === 'pay_invoice') {
|
||||
const preimage = settle(req.params?.invoice);
|
||||
body = preimage ? { result_type: 'pay_invoice', result: { preimage } } : { result_type: 'pay_invoice', error: { code: 'PAYMENT_FAILED', message: 'no route to that invoice' } };
|
||||
} else {
|
||||
body = { result_type: req.method, error: { code: 'NOT_IMPLEMENTED', message: 'not here' } };
|
||||
}
|
||||
const text = JSON.stringify(body);
|
||||
const content = nip44 ? nip44Encrypt(text, ck) : await nip04Encrypt(text, walletKey, unhex(e.pubkey));
|
||||
const tags = [['p', e.pubkey], ['e', e.id]];
|
||||
if (nip44) tags.push(['encryption', 'nip44_v2']);
|
||||
accept(finalizeEvent({ kind: 23195, created_at: now(), tags, content }, walletKey));
|
||||
}
|
||||
accept(finalizeEvent({ kind: 13194, created_at: now(), tags: [['encryption', 'nip44_v2 nip04']], content: 'pay_invoice' }, walletKey));
|
||||
|
||||
// ---- people: alice follows bob (who takes sats) and carol (notes only)
|
||||
const people = {};
|
||||
for (const [name, lud16] of [['alice', ''], ['bob', `bob@${lnHost}`], ['carol', '']]) {
|
||||
const sk = generatePrivateKey();
|
||||
people[name] = { sk: hex(sk), pk: hex(getPublicKey(sk)), npub: npubEncode(hex(getPublicKey(sk))) };
|
||||
accept(finalizeEvent({ kind: 0, content: JSON.stringify({ name, ...(lud16 ? { lud16 } : {}) }), tags: [] }, sk));
|
||||
accept(finalizeEvent({ kind: 10050, content: '', tags: [['relay', relayUrl]] }, sk));
|
||||
}
|
||||
accept(finalizeEvent({ kind: 3, content: '', tags: [['p', people.bob.pk], ['p', people.carol.pk]] }, unhex(people.alice.sk)));
|
||||
|
||||
const nwc = `nostr+walletconnect://${walletPub}?relay=${encodeURIComponent(relayUrl)}&secret=${hex(walletClient)}`;
|
||||
return {
|
||||
relay: relayUrl, ln: lnBase, nwc, people, stats, events,
|
||||
close: () => {
|
||||
for (const sock of relayServer.sockets) sock.destroy();
|
||||
relayServer.closeAllConnections?.();
|
||||
lnServer.closeAllConnections?.();
|
||||
return Promise.all([new Promise((r) => relayServer.close(r)), new Promise((r) => lnServer.close(r))]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
const net = await startDevnet({ auth: process.argv.includes('--auth') });
|
||||
const { close, stats, events, ...out } = net;
|
||||
console.log(JSON.stringify(out));
|
||||
process.on('SIGTERM', () => process.exit(0));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SLYNG share card</title>
|
||||
<style>
|
||||
@font-face { font-family: "Slyng Condensed"; src: url("../fonts/slyng-condensed-bold.woff") format("woff"); font-weight: 700; }
|
||||
@font-face { font-family: "Slyng Text"; src: url("../fonts/slyng-text-regular.woff") format("woff"); font-weight: 400; }
|
||||
html, body { margin: 0; width: 1200px; height: 630px; overflow: hidden; background: #D8E8F0; color: #15243B; }
|
||||
.card { position: relative; width: 1200px; height: 630px; box-sizing: border-box; padding: 70px 84px; }
|
||||
.word { font: 700 300px/0.82 "Slyng Condensed", sans-serif; letter-spacing: 0.02em; display: flex; align-items: flex-end; margin: 26px 0 0; }
|
||||
.word svg { height: 0.74em; width: auto; margin: 0 0.035em; overflow: visible; }
|
||||
.motto { font: 400 44px/1.2 "Slyng Text", sans-serif; margin: 46px 0 0; }
|
||||
.url { position: absolute; right: 84px; bottom: 62px; font: 400 28px "Slyng Text", sans-serif; color: #4A5B73; }
|
||||
.arc { position: absolute; right: 70px; top: 54px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<svg class="arc" width="330" height="170" viewBox="0 0 330 170" aria-hidden="true">
|
||||
<path d="M10 160 Q170 -40 300 70" fill="none" stroke="#15243B" stroke-opacity="0.5" stroke-width="5" stroke-dasharray="1 18" stroke-linecap="round"/>
|
||||
<circle cx="300" cy="70" r="26" fill="#FBFCFD" stroke="#E2412E" stroke-width="6"/>
|
||||
</svg>
|
||||
<p class="word">SL<svg viewBox="0 0 64 72"><path d="M32 69V40M10 6C10.5 22 20 35 32 40C44 35 53.5 22 54 6" fill="none" stroke="#8A5A35" stroke-width="9.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 6L32 24L54 6" fill="none" stroke="#E2412E" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/><circle cx="32" cy="24" r="7" fill="#F0A532" stroke="#A86814" stroke-width="1.5"/></svg>NG</p>
|
||||
<p class="motto">Sling sats and notes on nostr.</p>
|
||||
<p class="url">slyng.yolopreneur.lol</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
// tools/serve.mjs [port] [folder]: serve the page on 127.0.0.1, for the build's screenshots and for trying it locally.
|
||||
// Static files only, nothing outside the folder; no dependencies beyond node.
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { extname, join, resolve, sep } from 'node:path';
|
||||
|
||||
const port = Number(process.argv[2] || 8000);
|
||||
const root = resolve(process.argv[3] || '.');
|
||||
const TYPES = {
|
||||
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
||||
'.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.md': 'text/markdown; charset=utf-8',
|
||||
};
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
let file = resolve(join(root, decodeURIComponent(new URL(req.url, 'http://localhost').pathname)));
|
||||
if (file !== root && !file.startsWith(root + sep)) throw new Error('outside the folder');
|
||||
if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
|
||||
const body = await readFile(file);
|
||||
res.writeHead(200, { 'content-type': TYPES[extname(file)] || 'application/octet-stream', 'cache-control': 'no-store' });
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
} catch {
|
||||
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
||||
res.end('not found\n');
|
||||
}
|
||||
}).listen(port, '127.0.0.1', () => console.log(`serving ${root} at http://127.0.0.1:${port}/`));
|
||||
Reference in new issue
Block a user