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]>
A flyer published via NIP-07 could get a poster naddr encoding the anonymous
device key instead of the signer: signActiveEvent signed through window.nostr
(the real key) while activeIdentity() — used to build the naddr — fell back to
the anon key whenever nip07Pubkey hadn't loaded yet (e.g. just after a reload
restored nip07 mode). The resulting poster URL pointed at the wrong author and
could never be loaded back, even though the flyer appeared on the board (which
finds events by tag, not address).
signAndPublish now resolves the concrete signing pubkey first (fetching the
NIP-07 key if needed), builds the naddr from that exact key, and refuses to
publish if the signed event's pubkey does not match the naddr — so the signer
and the poster address can no longer diverge.
Note: this prevents new broken flyers; an already-published flyer with a
mismatched naddr must be re-published to get a correct poster URL.
Test: a NIP-07 publish issued while getPublicKey is still pending now yields a
naddr whose pubkey matches the signer (fails on the old code). 192 e2e across
all six engines.
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]>
A flyer could appear on the bulletin board yet fail to open in the viewer.
Two causes, both fixed so "if the board shows it, the viewer opens it":
- Over-strict view validation. The viewer ran the *authoring* validatePayload
on fetched events (in payloadFromEvent and normalizePayload) and threw on
length caps, a raw-HTML regex that false-positives on plain text like
"value < x >", version, and URL scheme — refusing to render. The renderer
already HTML-escapes every field, so the view path is now lenient: it renders
what was published and only neutralizes unsafe URL schemes (javascript:/data:
become no-link instead of throwing). Strict validation still gates authoring
via buildPayloadFromForm, so we never publish junk. The mandatory t=flyer tag
is dropped (t=voxvera + a voxvera_flyer payload is enough), matching the board.
- Fragile fetch. fetchReplaceable relied on the relay honoring an addressable
"#d" tag filter; some relays don't, yet still return the event for a broader
query (which is how the board finds it). It now queries by author+kind and
matches the d-tag in-page, with a longer timeout.
Tests: a fetched flyer with HTML-ish text + a 6k body + a javascript: URL now
renders with the URL neutralized; and a flyer served by a relay that refuses
"#d" filters still resolves. 180 e2e across all six engines.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add a "Shipped so far" summary (board login gate, web-of-trust filter, editor
publishing identity with PIN-encrypted nsec) and refresh the review footer:
the trust model is now implemented, so #3 (centralized client distribution) is
the highest-leverage untouched item.
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]>
Gate the board behind a connected Nostr identity and filter what it shows
to the viewer's web of trust, so it is no longer an unmoderated firehose.
- Login gate: the board shows a Connect prompt instead of the table until a
Nostr identity is connected; the connected npub is surfaced with a
Disconnect control and remembered (pubkey only) for silent reconnect.
- Connect methods: NIP-07 extension, pasting an nsec (decoded in-page to a
pubkey, then discarded — the board reads, never signs), or creating a new
key (reveals the nsec to save, reuses the device key if present).
- Web-of-trust filter (NIP-02): defaults to flyers from authors in the
viewer's follow graph, with a "Show all" opt-out. A fresh key with no
follows is seeded from a pinned curator account so new viewers still get a
curated board; if no trust data loads at all, it shows everything.
- Localized across all 14 languages (gate, filter, and connect strings),
RTL included.
Covered by the cross-engine Playwright suite (gate shown/hidden, connect via
each method, pubkey-only persistence, web-of-trust filtering + show-all, and
the curator-seeded fallback).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Mark board moderation (#1) and authenticity (#2) as Planned and add a joint
"identity + web-of-trust on the board" direction:
- Login enables a trust-filtered view, not access control (you cannot gate
writes to public relays; you filter what the board displays).
- Keep anonymous creation + printing available; require identity only to
appear on the public board.
- Web-of-trust (NIP-02 follow graph) is the real spam defense, since keys are
free; login alone is just a soft deterrent.
- Support NIP-07 plus nsec import / NIP-46 / promoted anon key so mobile (the
scanned-QR audience) isn't locked out.
- Phased plan (identity → WoT filter → blocklist/report/NIP-05) and the two
open decisions: gate viewing or not, and which login methods at launch.
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]>
- Give the paper-size selects a min/max width so "US Letter (8.5 × 11 in)"
is readable instead of truncated.
- Add equal spacing below the bulletin-board link to match the gap above it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
After Sign and publish succeeds, open a modal that tells the author the
flyer is published and shows its URL (pre-selected) with Copy and Close
buttons, so they know the process completed and where to view/share it.
- New modal markup in index.html + overlay/box styles (hidden in print).
- Closes on the Close button, backdrop click, or Escape; Copy writes the URL
to the clipboard with a "Copied" confirmation.
- Localized for all 14 languages (published_title / published_message /
close); the URL is result.posterUrl.
- Playwright: add a test that stubs the relay socket, publishes, and asserts
the modal shows the localized text and the naddr poster URL. 84 tests pass
across all engines.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
vercel.json sets cleanUrls + trailingSlash, so board.html is served at
/board/. Its relative asset paths (nostr-client.css, board.js, locales.js,
vendor/...) then resolved to /board/... and 404'd, leaving the page as raw
unstyled HTML stuck on "Loading…". Use root-absolute paths instead, and
point the back link at "/". Add an e2e guard asserting board.html assets are
root-absolute (the dev server can't reproduce Vercel's URL rewrites).
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]>
Verification:
- Add Playwright harness (e2e/) covering Chromium, Firefox, and WebKit plus
Android/iOS mobile emulations. Smoke tests assert: default flyer renders
with no JS/console errors, @media print hides app chrome and keeps the
flyer, anonymous npub generation (WebCrypto), legacy /nostr/ redirect
preserves query+hash, and event-lookup URLs show the loading state instead
of the default flyer. All six projects pass.
- package.json/playwright.config.js wire up the projects and a managed static
server; .gitignore excludes node_modules and Playwright output.
Docs:
- docs/flyer-field-limits.md: per-script character cheat sheet for each flyer
field (single-line caps + multi-line guidance), measured by rendering each
script at print size.
- README: add a Features section matching the shipping client and a
cross-browser verification section; link the cheat sheet.
- AGENTS.md: document the e2e suite and the one-time WebKit Linux dep.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A viewer arriving via an event-lookup URL (naddr/nevent/note/hex in the
query or #fragment) previously saw the default placeholder flyer flash
before the real event resolved. Now it renders a localized "Loading
content..." state, and a "Could not load this flyer." state on fetch
failure. Default flyer content now appears only on the bare main URL.
- Add loading_content and load_failed labels for all 14 languages
- Add renderStatusPreview() rendering a minimal flyer sheet with a single
centered, direction-aware message (no tear-offs, no QR)
- Render loading state synchronously before fetch in the URL-event path,
failed state in the catch handler
- Add .flyer-status-message CSS
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]>
The Load Event field requires 64 contiguous hex chars, but the event ID
printed on a tear-off tab wraps across two lines, so copying it back in
pastes as two 32-char halves separated by a newline/space and failed to
match. Strip inner whitespace before matching the raw hex id, which also
tolerates a pasted "Event ID:" label prefix. naddr/note/nevent inputs are
unaffected.
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]>