NIP-46 now supports both connect directions on the editor and the board:
- bunker:// (existing): paste a link the signer app generated.
- nostrconnect:// (new): the client generates the link, renders it as a
QR (+ a copyable link for one-device use), listens on a rendezvous
relay (relay.nsec.app), and the signer app scans it and connects back —
proven by echoing the client's random secret. The discovered signer
pubkey then drives the durable connection (shared adopt path).
Both paths stay session-only with an ephemeral local key; the QR uses
the same hand-rolled NIP-44 client (NIP-04 decrypt fallback). qrcode-
generator is now loaded on the board too. 3 new localized strings
(scan / waiting / copy-link) across all 14 languages in NOSTR_UI and
BOARD_UI; i18n parity passes.
Also per feedback: the board's "Safety & privacy" link moves out of the
topbar (where it ran together with "Back to VoxVera") into a centered
footer link matching the home page, and both pages now link /safety.html
(works on the static host and Vercel alike).
Tests: editor and board each connect via a fake signer that "scans" the
nostrconnect QR (reads the on-screen URI, echoes the secret) — all 6
engines. Full suite 372 passing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The board's connect gate now offers "Remote signer (NIP-46)" alongside
NIP-07 / nsec / create-key. Pasting a bunker:// link establishes the
viewer's pubkey (for the web-of-trust filter and display) and keeps the
connection alive so getBoardSigner can sign reports and deletions
remotely this session — its whole point being that the secret never
leaves the signer app. After a reload the board reconnects read-only from
the remembered pubkey (session-only, like the editor).
The minimal NIP-46 client (parse bunker URI, NIP-44 codec with NIP-04
decrypt fallback, raw-WebSocket request/response) is ported into board.js,
which is an independent IIFE. Reuses the editor's translations for the 3
new BOARD_UI strings across all 14 languages (i18n parity passes).
Test: connect the board via a fake bunker signer doing real NIP-44
crypto, confirm it reveals as that identity, and that a report is signed
through the remote signer (kind-1984 with the signer's pubkey). All 6
engines. Full suite 360 passing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Replaces the raw "Export event JSON" button with Import design / Export
design in the editor. A design file is content + relays only —
deliberately identity-free (no npub, naddr, event id, or signature, all
of which are derived at publish), so it can be handed to anyone and
published under their own key.
Format: { type: "voxvera_flyer_design", version: 1, flyer: <payload>,
relays: [] }. Export serializes the current form as-is (draft-friendly).
Import reads the picked file (capped at 256 KB), accepts the wrapper
format or a bare voxvera_flyer payload, then shows a confirm modal before
overwriting the editor; on confirm it fills the form and restores relays.
Imported content stays untrusted: the renderer escapes every field and
strict validation still runs at publish.
Adds 8 localized strings across all 14 languages (i18n parity passes).
Tests: export round-trip (identity-free assertions), import-then-confirm,
cancel-leaves-unchanged, and invalid-file rejection, across all 6
engines. Full suite 336 passing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The editor can now sign flyers via a NIP-46 remote signer: paste a
bunker:// link and the user's secret stays in their signer app
(nsec.app, Amber, …) while the browser holds only an ephemeral local
key. The vendored nostr-tools bundle has no nip46 module, so the client
is hand-rolled over a raw WebSocket — kind-24133 JSON-RPC encrypted with
NIP-44 (with a NIP-04 decrypt fallback), running connect →
get_public_key → sign_event. Connections are session-only; the bunker
token is never written to disk, so a reload returns to anonymous.
Switching to/from any other identity mode disconnects the signer, and a
successful connection mirrors the user pubkey to the board's connected
key. Adds 9 localized strings across all 14 languages (i18n parity test
passes).
Tested with a fake relay/signer doing real NIP-44 crypto: full connect →
get_public_key → sign_event → publish round-trip, across all 6 engines.
Full suite 312 passing.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The tear-off tabs are labeled with the flyer's title, so swap the default
landing content's title and headline in every language: the title is now
"OPERATION VOX VERA" (the project name, shown on all ten tabs and as the top
line) and the headline is the dramatic "TOP SECRET" below it. Applied across all
14 languages in locales.js (e.g. de title "OPERATION VOX VERA" / headline
"STRENG GEHEIM").
Updated the e2e assertions (default title is now the project name; the German-
distinctive string is now the headline) and the spec payload example. 258 e2e
across all six engines.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Three mobile/responsive fixes (diagnosed at phone widths):
- Flyer no longer side-scrolls/clips on phones. In viewer mode .flyer-preview is
a flexbox, so the sheet (a flex item) was shrunk to the viewport, making the
fit math compute scale = 1 and the full-width content clip. Add flex: 0 0 auto
so the sheet keeps its true print width and the transform scales it to fully
fit the screen.
- Bulletin board collapses to a card per flyer on ≤640px: title + the ⋯ menu
stay visible; the secondary columns (link, posted-by, language, date, event
id) move behind a per-row "Details" disclosure (labeled via data-label). New
details_label string × 14 languages.
- Fix a real (not mobile-only) bug: the editor's nsec-import box and
"locked identity" row showed on every load because .identity-import's
display:flex overrode the hidden attribute. Add .identity-import[hidden]
{ display:none } (same class of bug we hit on the board identity chip).
Adds e2e: flyer fits width with no horizontal overflow, identity controls hidden
until chosen, and the board's narrow-screen card + Details toggle. The desktop
"sortable columns" test pins a desktop viewport (the header is hidden in card
mode). 258 e2e across all six engines; docs updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Each of the ten tear-off tabs now reads, top to bottom:
- the flyer's TITLE, auto-sized (fitTabTitles) to be as large as fits the ~1in
tab — big for short titles, shrinking instead of overflowing for long ones —
so every tab is clearly labeled;
- a smaller, clickable "Open & reprint this flyer" link (the <a href>, so it
stays live in a printed PDF) in place of the old "Click to open this poster."
/ "Share and reprint." (clicking, not "scan," is what a PDF link does);
- the tear-off QR; and
- a "Reopen at VoxVera.org — Event ID:" line (was just "Event ID:") so people
know the id is for re-finding/reprinting the flyer at the site.
New tab_link / tab_event_id strings in all 14 languages (VoxVera.org stays the
Latin brand; the rest localizes). e2e covers the title, the link text + poster
href, and the event-id label. 240 e2e across all six engines.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The inline Edit / Re-broadcast / Delete (and Block) buttons wrapped messily in
the actions column. Replace them with a single ⋯ button per row that opens a
small popout menu with the relevant actions. Closes on outside click, Escape,
or selecting an item; the actions and their data-act wiring are unchanged.
Adds the menu markup/toggle in board.js, an "Actions" aria-label string × 14
languages, and menu/kebab styles. e2e updated to open the menu before acting.
234 e2e across all six engines.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add inline management to the board for the connected viewer:
- On your own flyers: Edit (links to /?edit=1#<naddr>, which the main client
now honors by loading the flyer straight into the editor), Re-broadcast
(re-sends the already-signed event to all relays — no signing), and Delete
(replaceable tombstone + NIP-09, signed via the device key or NIP-07; a
pasted-nsec connection is pointed to the editor where the key is available).
- On flyers from others: Block — a per-device blocklist of author pubkeys
(voxvera_blocked_pubkeys) that always hides them, with a "Clear blocked"
control. This is roadmap #1's local blocklist direction.
The board hides tombstoned flyers (newest-version-wins so a delete can't be
undone by an older copy). New non-sortable actions column; a board delete-
confirm modal; 12 new strings × 14 languages. Adds e2e for block (hide/persist/
clear), own-vs-other actions, and board delete (tombstone + NIP-09 published),
plus a ?edit=1 deep link. 234 e2e across all six engines; docs updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Authors can now manage a published flyer from the editor when it's loaded from
an event (author-only — the active signing key must match the flyer's author):
- Edit: change fields and publish again; the same d-tag replaces in place.
- Re-publish (keep-alive): re-sign + re-broadcast the current content so relays
that prune app-data don't age the flyer out. Partially addresses roadmap #4.
- Delete: belt-and-suspenders across relay behaviors —
1) a replaceable TOMBSTONE (same kind+d, payload deleted:true + a ["deleted"]
tag) that overwrites the flyer's content, so the board/viewer hide it even
on relays that ignore deletions, and
2) a NIP-09 deletion request (kind 5) for the addressable a-tag + event id.
Confirmed via a modal; framed as best-effort, not a guarantee.
Tombstone recognition: the board's parseFlyers keeps the newest event per
(pubkey, d) and skips it when that newest version is a tombstone (so a delete
can't be reversed by an older copy arriving later); the viewer renders a
localized "This flyer was removed." state.
Adds the loaded-flyer actions UI + a delete-confirm modal, 11 new strings ×
14 languages, a Python schema test for the tombstone payload, and e2e for the
delete flow, board hiding, and the viewer removed state. 216 e2e across all six
engines; docs (spec/roadmap/README) updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Link a small inline SVG (site/favicon.svg, a red tile with a white "V") from
both index.html and board.html via a root-absolute href, so it resolves from
"/" and from "/board/" under Vercel cleanUrls. With an icon link present,
browsers stop auto-requesting (and 404ing on) /favicon.ico. No binary asset and
no external dependency, consistent with the self-contained client.
e2e asserts both pages declare the favicon and that /favicon.svg is served as
valid SVG. Closes the last code-finishable item in the roadmap's smaller list.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Each flyer row now shows its event ID (shortened, full value in the title
attribute) with a Copy button that copies the full 64-char id to the clipboard
(flips to "Copied", falls back to selecting the text if the clipboard is
blocked). The column is sortable like the others. Copy is handled by a single
delegated listener on the tbody, since rows re-render on sort.
Localized the new column header (col_event_id) across all 14 languages; Copy/
Copied already existed in the board strings. Updated the column e2e test (now 6
columns) and added a test for the id cell + copy target. Docs updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Let authors publish a flyer under a stable, identifiable key when they want
one, while keeping anonymous-by-default. The editor now signs under one of
three identities, routed through activeIdentity()/signActiveEvent():
- Anonymous (default, unchanged): the per-device key, signed locally.
- NIP-07 extension: signs via window.nostr.signEvent; the secret never
enters the page.
- Imported nsec: decoded to a key used to sign. By default the secret lives
only in memory for the session. "Remember on this device" stores it
encrypted with a PIN (PBKDF2 SHA-256, 600k iterations, random salt ->
AES-GCM-256); the plaintext secret is never written to storage. On reload a
remembered key is locked until the PIN is entered; a session-only key falls
back to anonymous.
The signer pill, author npub, naddr, and poster URL all follow the active
identity. New identity controls are localized across all 14 languages.
Security note (also in docs): a short numeric PIN is brute-forceable offline
if the encrypted blob leaks, so it guards casual snooping, not a targeted
attacker — the NIP-07 path is stronger, and NIP-46 is a documented follow-up.
The minimum PIN is 4 digits but longer is allowed and encouraged.
Also updates README, the client spec (new "Publishing identity" section),
the roadmap (#1/#2 marked in progress with phases shipped), and the e2e
README. Covered by the cross-engine Playwright suite (default-anonymous,
NIP-07 connect + publish, session-only nsec without persistence, invalid
nsec, PIN encrypt -> lock on reload -> wrong/right PIN -> unlock, and forget).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add docs/roadmap.md capturing product/threat-model gaps that are not code
bugs, in priority order: (1) bulletin board has no moderation/abuse story,
(2) anonymous keys give no authenticity, (3) client distribution is
centralized on one domain, (4) relay permanence is overstated, (5) minimal
content-safety guardrails, plus smaller items. Each entry notes the problem,
current mitigation, why it matters, and possible directions.
Link it from the README and the spec's bulletin board section.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A small screen-only link below the flyer preview (outside the printable
sheet, hidden in print) opens board.html: a spreadsheet-style list of the
most recent VoxVera flyers found on the default relays.
- Columns: title, link (the poster URL shown as "link"), posting npub,
language, and posted date; every column is sortable (default date
descending). Replaceable events are de-duplicated per author+d identifier,
keeping the latest.
- board.js is independent of nostr-client.js (which exports nothing): its UI
strings live in BOARD_UI for all 14 languages, and it reuses the vendored
nostr-tools and locales.js (language names + RTL direction).
- The board has its own language selector that reads/writes the same
voxvera_nostr_lang localStorage key as the main client, so the choice
carries across pages; switching it re-localizes the chrome, headers,
language labels, and date formatting and flips RTL.
- Untrusted relay content is escaped, and link URLs are rejected unless
http(s) so javascript:/data: never render as a link.
Docs updated (README, spec, AGENTS). Playwright: add tests for the
out-of-sheet print-hidden link, the localized sortable columns, and the
board language selector (incl. RTL + persistence). 72 tests pass across
Chromium, Firefox, WebKit, and Android/iOS mobile emulations.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Language inference:
- Walk navigator.languages (the full ordered preference list), not just
navigator.language, and pick the first base language we support. A user who
prefers e.g. Dutch then German now gets German instead of the English
fallback. De-duplicates the default-language resolution into setDefaultText.
Paper size (US Letter + A4):
- Add A4 (210x297mm) alongside US Letter (8.5x11in). Sheet dimensions move to
CSS custom properties (--sheet-w/--sheet-h) toggled by a paper-letter/paper-a4
class on <html>; the tear-off column and QR codes stay fixed physical sizes
and the content column flexes.
- @page size is injected by JS (id voxvera-page-size) since @page cannot read
CSS variables across engines; the print block keeps margin:0 and a Letter
default for the pre-JS state.
- Default paper is chosen from the browser locale's region subtag
(en-US -> Letter, en-GB/de-DE -> A4) — "by location" without geolocation,
falling back to Letter when no region is present. A manual override selector
(topbar + viewer controls) persists in localStorage and wins over the default.
- Paper size is a local print/view preference and is never written into the
Nostr event, so a flyer reprints on either size unchanged.
- Add paper_size/paper_letter/paper_a4 labels for all 14 languages.
Docs + tests:
- Spec gains a "Print paper size" section; AGENTS.md records the
not-in-payload invariant; README adds a feature bullet; the field-limits
cheat sheet gains Letter/A4 columns (re-measured per paper size).
- Playwright: add tests for navigator.languages inference, region-based paper
default (A4 and Letter), and override persistence. 54 tests pass across
Chromium, Firefox, WebKit, and Android/iOS mobile emulations.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The /nostr/ path was a leftover from when the site also hosted a Tor
client. The app is Nostr-only now, so move the client to the site root
and shorten poster URLs to https://voxvera.org/#naddr1... (drops both
/nostr/ and the ?addr= query key, for a cleaner/smaller tear-off QR).
- Move site/nostr/{nostr-client.js,css,locales.js,vendor} to site/.
- site/index.html is now the client; site/nostr/index.html is a redirect
stub that forwards /nostr/ to / while preserving the query string and
#naddr fragment, so older poster URLs and printed QR codes still
resolve.
- Carry the naddr in the URL fragment (#) instead of ?addr=; the viewer
reads query, fragment, or the full URL, so old links keep working.
- Update README, AGENTS.md, and the spec to the new paths.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Tear-off tabs: single hyperlinked statement (no printed URL), with
"Share and reprint." on its own line; localize "Click to open this
poster." across all 14 languages.
- Show the full event ID, bottom-anchored on each tab, split into two
32-char lines at max readable monospace size.
- Shorten the poster URL by encoding the naddr with a single relay hint
instead of the full relay list (~233 -> ~171 chars) for an easier-to-
scan QR; still a standard naddr, older URLs resolve unchanged.
- Add per-script letter-spacing policy (ls-full/ls-cjk/ls-none) to fit
more text per line where safe, and turn tracking off for Arabic,
Persian, and Hindi (fixes broken cursive joins / combining marks).
- Move the "field too long" warning inline under the offending field
instead of a single bottom status line.
Co-Authored-By: Claude Opus 4.8 <[email protected]>