Compare commits

...
Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 d58525a5f8 slyng: the brand page, slyng.yolopreneur.lol/branding/
branding/index.html and brand.css: the logo (the name in Unbounded ExtraBold, a volt stone on the vertex of
the Y, the bands up the inside of its arms), how to use it, the icon, files, colour, type, motion, and every
draft from the six rounds that found it, each on the ground it was drawn for. No script, its own CSP.

branding/img/ is made by tools/brand/make.py and lands with the seed workflow; the branch keeps its MANIFEST,
and tools/build.sh now runs tools/brand/restore.sh so a refresh brings the images back from the live site
(checked by sha256) instead of dropping them. README says how; the site's footer links the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbSujc1VYmyjFzusaH6og3
2026-10-16 14:37:16 +00:00
teahubxyzandClaude Opus 5.5 4bbc090073 slyng: tools/brand, rounds two to six and their motion (3 of 3)
anim.py (the icon's pull-and-release and its storyboard), anim_wm.py (the icon as the Y), slingy.py and
anim_wm2.py (the bare slingshot as the Y), fontstyle.py and motion2.py (Unbounded's own Y and the rounded one),
final.py and motion3.py (the logo and its motion). Kept as they were drawn, so make.py gives the same files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbSujc1VYmyjFzusaH6og3
2026-10-16 14:20:57 +00:00
teahubxyzandClaude Opus 5.5 3edd6bd2d2 slyng: tools/brand, rounds one and two (2 of 3)
concepts.py draws the eight first concepts; direction.py the icon in Zap's colours, its lockups, and the
geometry the icon's pull-and-release is drawn from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbSujc1VYmyjFzusaH6og3
2026-10-16 14:18:29 +00:00
teahubxyzandClaude Opus 5.5 ed07ffdcf9 slyng: tools/brand, the generator for the brand page's images (1 of 3)
fontkit, sling and kit (the shared drawing code), make.py (fetches the fonts from google/fonts at a fixed
commit, checked by sha256, and makes every image into branding/img/ with a MANIFEST), and restore.sh (brings
branding/img back from the live site on a refresh, checked against the MANIFEST). Nothing calls these yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbSujc1VYmyjFzusaH6og3
2026-10-16 14:16:35 +00:00
teahubxyzandClaude Opus 5.5 e6a1c65f22 ventures/slyng: Slyng Text Regular
Cut from TeX Gyre Adventor Regular under the GUST Font License; fonts/MANIFEST-SLYNG.txt says what changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 11:01:29 +00:00
teahubxyzandClaude Opus 5.5 badcf9cc3e ventures/slyng: Slyng Text Bold
Cut from TeX Gyre Adventor Bold under the GUST Font License; fonts/MANIFEST-SLYNG.txt says what changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 10:56:29 +00:00
teahubxyzandClaude Opus 5.5 b61f0dfc01 ventures/slyng: Slyng Condensed Bold
Cut from TeX Gyre Heros Cn Bold under the GUST Font License; fonts/MANIFEST-SLYNG.txt says what changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 10:51:32 +00:00
teahubxyzandClaude Opus 5.5 12d82f157b ventures/slyng: the tests and the devnet
tests/core.test.js: the crypto against the BIP-340, NIP-44, NIP-19 and LUD-01 vectors, the slingshot geometry, and
a whole sling against tools/devnet.mjs (a local relay with AUTH, a lightning address server, an NWC wallet).
tests/smoke.py is the browser check, run by hand with Playwright.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 10:45:05 +00:00
teahubxyzandClaude Opus 5.5 3ddc292dfc ventures/slyng: README, the skill for agents, the font license, and the build tools
tools/build.sh runs on the refresh runner: the tests, then the two screenshots, served by tools/serve.mjs (node
only, so the build needs node, curl and Chrome and nothing else). tools/og.html is the share card it shoots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 10:42:18 +00:00
teahubxyzandClaude Opus 5.5 0a0e8299ac ventures/slyng: the page, its styles, and the files for agents
index.html: the slingshot as the first screen, then the story (the Y is the fork), how it works, the
agent link, plans, the fine print. skill.md and llms.txt for agents; README for whoever works on it next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 10:33:59 +00:00
teahubxyzandClaude Opus 5.5 9402494f09 ventures/slyng: the slingshot and the app
sling.js: the pull as a polar coordinate (angle picks who, stretch picks how much), rings for each step,
mouse, touch and keyboard, catch-it-back flight. app.js: demo mode, sign-in (extension, new key, nsec),
wallet (WebLN, NWC), slinging zaps and private notes, the Caught and Slung log, links an agent made.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 10:31:33 +00:00
teahubxyzandClaude Opus 5.5 5cdfb4738a ventures/slyng: nostr, zaps, wallets and private notes
nostr.js: bech32/NIP-19, events, signers (page key or NIP-07), a relay pool with NIP-42 AUTH, NIP-05.
zap.js: lightning addresses, LNURL-pay, NIP-57 zap requests and receipts, the exact-amount invoice check.
wallet.js: WebLN and Nostr Wallet Connect (NIP-47). dm.js: NIP-17 notes, sealed and gift-wrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 10:28:14 +00:00
teahubxyzandClaude Opus 5.5 6c09419a07 ventures/slyng: the crypto core (SHA-256, HMAC, HKDF, BIP-340, ChaCha20, NIP-44, NIP-04)
SLYNG, a new yolo venture: sling sats and notes on nostr. This branch carries
ventures/slyng/ for Time's refresh workflow to land on gh-pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ww3hgdnVzTeB85eSsuPLuF
2026-10-16 10:26:12 +00:00
44 changed files with 7038 additions and 0 deletions

No files matched your search

+101
View File
@@ -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=…&note=…`); 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.
+888
View File
@@ -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();
+203
View File
@@ -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; }
}
+59
View File
@@ -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
+367
View File
@@ -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&#x27;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&#x27;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&#x27;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&#x27;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>
+1
View File
@@ -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.
+13
View File
@@ -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.
+252
View File
@@ -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&amp;title=Early+access%3A+SLYNG&amp;venture=slyng&amp;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&amp;sats=2100&amp;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&amp;title=Early+access%3A+SLYNG&amp;venture=slyng&amp;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>
+497
View File
@@ -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));
}
+54
View File
@@ -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;
}
}
+488
View File
@@ -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 };
}
+105
View File
@@ -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 };
}
}
+124
View File
@@ -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;
}
}
+10
View File
@@ -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>&note=<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
+7
View File
@@ -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" }
}
+67
View File
@@ -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>&note=<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&note=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
+413
View File
@@ -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);
}
}
+303
View File
@@ -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; }
}
+238
View File
@@ -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/);
});
+228
View File
@@ -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&note=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()
+102
View File
@@ -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())
+39
View File
@@ -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))
+66
View File
@@ -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))
+360
View File
@@ -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
+210
View File
@@ -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
+106
View File
@@ -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
+146
View File
@@ -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])
+128
View File
@@ -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
+105
View File
@@ -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)
+260
View File
@@ -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()
+107
View File
@@ -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)
+124
View File
@@ -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)
+29
View File
@@ -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"
+255
View File
@@ -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)
+143
View File
@@ -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
+52
View File
@@ -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"
+15
View File
@@ -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"
+286
View File
@@ -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));
}
+29
View File
@@ -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>
+28
View File
@@ -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}/`));