Compose bar gets a 😀 button. Click → emoji-picker-element (the
canonical web component, ~140 KB) lazy-loads on first open and stays
cached after. Pick → inserts at cursor position, focus returns to
input. Click-outside closes the popover.
Bonus: emoji-only messages render iMessage-style — no bubble, larger
text. Uses Intl.Segmenter to count grapheme clusters so 👨👩👧 reads
as 1 not 5 code points.
• 1 emoji → text-5xl
• 2-3 → text-4xl
• 4-6 → text-3xl
• 7+ or any letters/digits/punct → normal bubble
Bundle: emoji picker chunked separately via dynamic import (38 KB
gzipped). Initial Messages-page JS only nudged ~159→162 KB.
Native emoji input (macOS ⌃⌘Space, iOS keyboard, Android long-press)
still works — the picker is just for discoverability.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Now installable on iOS (Safari → Share → Add to Home Screen) and
Android/desktop Chrome (install prompt or Settings → Install app).
Launches in standalone mode with a dark theme color matching the
lock-icon palette.
Stack:
• vite-plugin-pwa with workbox in generateSW mode, registerType
'autoUpdate' — new SW activates on next page load, no upgrade prompt
(chat needs to stay fresh).
• @vite-pwa/assets-generator for icon variants from a single SVG.
Source kez-icon.svg = dark squircle (#111827) + amber key glyph,
drawn inside the 80% maskable safe zone.
Caching:
• Precaches the SPA shell (~635 KB inc. the zstd WASM, well under
the 5 MB per-file cap).
• runtimeCaching 'NetworkOnly' for /v1/* — never cache authenticated
chat data; every poll must hit the network.
• navigateFallback to index.html so /messages, /claims, /dashboard
survive a refresh while offline. The /v1/, /internal/, /.well-known/
paths are explicitly denylisted from this fallback.
Meta tags (index.html):
• <link rel="manifest"> + theme-color for Android Chrome.
• apple-touch-icon-180x180 + apple-mobile-web-app-* meta for iOS,
including status-bar-style=black-translucent so the dark header
flows into the notch area in standalone.
• viewport-fit=cover so safe-area-inset works on notched devices.
Generated artifacts committed under web/public/:
kez-icon.svg, pwa-{64,192,512}.png, maskable-icon-512x512.png,
apple-touch-icon-180x180.png, favicon.ico.
Verified live: /manifest.webmanifest serves application/manifest+json,
/sw.js serves text/javascript, all icons return 200.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Most users won't manually craft a NIP-78 kind-30078 event with a d=kez
tag — that needed a nostr client most folks don't have. So verifiers
now look in all three sensible spots and the user picks whichever is
easiest to publish:
1. Kind 0 (profile metadata) — kez fence in the `about` field
2. Kind 1 (text note) — kez fence in the post body
3. Kind 30078 (NIP-78) — envelope as event content (advanced)
Web (kez-chat/web):
• New verifier implementation (replaces the v0.1 stub). Adds nostr-
tools (~108 KB) under dynamic import so it lands in its own chunk
— initial JS only grew 128→130 KB.
• SimplePool.querySync against five public relays (Damus, nos.lol,
primal, snort, nostr.wine), 4s timeout, kinds [0,1,30078] in one
REQ. Returns ✓ on first match, with an evidence_url to njump.me.
• AddClaim instructions for nostr rewritten — "pick whichever is
easiest" with concrete steps for each.
Rust (kez-channels):
• Filter now includes kinds [0, 1, 30078], limit bumped to 200.
• extract_proof_body() pulls the right candidate out of each event:
- kind 0 → JSON-decode content, return `about`
- kind 1 / 30078 → return content as-is
• 4 new unit tests (extract_proof_body for each kind incl. malformed
profile) + 2 new integration tests:
- verifies_proof_from_profile_about_field
- verifies_proof_from_kind_1_post
• Updated existing integration tests for the new filter shape.
All 11 unit + 7 integration nostr tests pass. Live at https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Add @bokuweb/zstd-wasm; replace the kez:zd1: deflate-raw placeholder
with spec-compliant kez:z1: zstd(JSON envelope) compact form.
- Dynamic import keeps the WASM (~348 KB) in its own Vite chunk so the
initial bundle only grows from 113 KB to 116 KB; the WASM is fetched
the first time a user picks compact format.
- AddClaim.svelte: 3-way format toggle (compact / markdown / JSON).
DNS defaults to compact since TXT records want the shortest payload.
- Drop the v0.1 apology in DNS instructions — kez:z1: is the spec form
and verifiers can decompress it directly.
- Cross-impl interop verified: browser-generated kez:z1: decompresses
cleanly in the Rust CLI and the Node port, byte-for-byte modulo
JSON key-order whitespace.
Deployed live to https://kez.lat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First real UI for kez-chat. Served by the chat-server as static
files; uses the same HTTP API a native client would (dogfoods the
contract).
Stack: Svelte 5 + TypeScript + Vite + Tailwind 4 + @noble/curves +
@scure/base + canonicalize + idb-keyval + svelte-spa-router.
Bundle: 113 KB JS / 14 KB CSS (gzip: 42 KB / 4 KB).
Pages (all behind hash routing):
/ Landing — sign up or restore from seed
/create Account creation flow:
1. Pick handle, set passphrase
2. Show seed for paper backup, require ack
3. Confirm
4. POST /v1/register, save passphrase-encrypted seed
to IndexedDB
/restore Stub for restore-from-seed (v0.2: needs
GET /v1/by-primary endpoint on the server)
/unlock Enter passphrase to derive the AES-GCM key,
decrypt the seed, populate session state
/dashboard Show handle, primary, registered_at, sigchain URL
/claims List locally-cached claims (with publication status)
/claims/add Add-a-claim wizard:
1. Pick channel (github/dns/web/nostr/bluesky/ap)
2. Enter identifier
3. SignedClaimEnvelope built + signed in-browser
using Ed25519 + JCS, matching the spec exactly
4. Show channel-appropriate publish instructions +
copyable markdown or JSON artifact
5. User marks it published (purely a local note —
actual verification is the verifier's job)
Crypto / KEZ helpers (src/lib/kez.ts):
- generateIdentity / identityFromSeed (32-byte Ed25519)
- canonicalBytes (RFC 8785 JCS via the `canonicalize` package — same
one our Node port uses; produces byte-identical output to Rust)
- signClaim, signRegistration (build envelopes; sign with
ed25519-sha512-jcs; same alg / key / sig shape as kez-core)
- toPrettyJson, toMarkdown (the same wire encodings the CLI emits)
Key storage (src/lib/identity-store.ts):
- IndexedDB via idb-keyval
- Seed encrypted under user passphrase: PBKDF2-SHA256
(600,000 iterations, OWASP 2024 guidance) → AES-GCM-256
- Documented limitation: browsers don't have an OS-keychain
equivalent. Native clients (future CLI/Tauri) will use the OS
keychain for better protection.
Bundle includes:
- Workaround for TS 5.6+ Uint8Array<ArrayBufferLike> vs ArrayBuffer
strictness (small asBuffer() helper that copies into a plain
ArrayBuffer for WebCrypto + Response calls).
Dockerfile updated: now multi-stage with a Node `webbuild` stage
that runs `npm run build` before the Rust binary stage. SPA dist
is copied into the runtime image at /app/web; chat-server's
KEZ_CHAT_WEB_DIR points at it so the SPA is served at /.
What works against the LIVE deployment right now (https://kez.lat):
- Open https://kez.lat → SPA loads (113 KB JS, 14 KB CSS)
- Create account → key gen happens in browser, seed shown for
backup, encrypted under passphrase, POSTed to /v1/register
- Dashboard → shows registered handle + primary + sigchain URL
- Claims wizard → sign for any of the 6 channels, get publish
instructions + the right wire format to copy
- Lock / unlock — passphrase-derived AES-GCM, no roundtrips
What's still TODO (v0.2):
- Restore-from-seed: needs GET /v1/by-primary on the server so the
SPA can discover the handle from a seed
- Actual NATS chat: needs server's auth callout (currently 501) +
nats.ws client (browser side; package is in deps but not used yet)
- Sigchain integration: append `add` event when user publishes a
claim, upload to sig-server (needs sig.kez.lat tunnel)
- Verification: in-browser channel fetches (some channels are
CORS-friendly, others need a server-side proxy)
- Compact (kez:z1:) form: the spec uses zstd, browsers don't have
native zstd CompressionStream support yet. Workaround in code
uses deflate-raw with a `kez:zd1:` prefix to make it obvious the
output isn't spec-compliant; replace with @bokuweb/zstd-wasm or
similar when we need true compact form in the SPA.