diff --git a/AGENTS.md b/AGENTS.md
index 1c78885..a3ea8f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -5,6 +5,7 @@
## Current Architecture
- Active frontend: `site/` (`index.html`, `nostr-client.js`, `nostr-client.css`, `locales.js`)
+- Bulletin board: `site/board.html` + `site/board.js` (standalone page listing recent flyers from the relays; escapes untrusted relay content and rejects non-http(s) link URLs)
- Static deployment root: `site`
- Browser dependencies: vendored under `site/vendor/`
- Legacy path: `site/nostr/index.html` is a redirect stub forwarding old `/nostr/` poster URLs and QR codes to the root
diff --git a/README.md b/README.md
index 6b53358..9f63e2a 100644
--- a/README.md
+++ b/README.md
@@ -18,11 +18,13 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch.
- **US Letter and A4.** Print size defaults to the right paper for the visitor's country (from the browser locale's region — `en-US` → Letter, `en-GB` → A4, no geolocation), with a manual override. Paper size is a local print preference and is never written into the event. See the [spec](docs/nostr-static-client-spec.md#print-paper-size).
- **Fits-the-page editing.** Fields that would overflow the printable sheet are rejected as you type, with an inline error under the affected field. See the [field length cheat sheet](docs/flyer-field-limits.md) for how much text fits each field per language and paper size.
- **QR codes.** The tear-off QR points at the poster URL (re-open and reprint); the main flyer QR points at the creator's content URL.
+- **Bulletin board.** A small screen-only link below the flyer preview (outside the printed sheet, never printed) opens a spreadsheet-style page of the most recent flyers found on the relays — title, link, posting npub, language, and date — sortable by any column.
- **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`.
## What Is In `main`
- `site/index.html`, `site/nostr-client.js`, `site/nostr-client.css`, `site/locales.js`: static editor/viewer client
+- `site/board.html`, `site/board.js`: bulletin-board page listing recent flyers from the relays
- `site/vendor/`: vendored browser dependencies for Nostr and QR generation
- `site/nostr/index.html`: redirect stub so legacy `/nostr/` poster URLs and QR codes still resolve
- `site/CNAME`: custom-domain target
diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md
index 637339d..8355be5 100644
--- a/docs/nostr-static-client-spec.md
+++ b/docs/nostr-static-client-spec.md
@@ -26,6 +26,8 @@ site/
nostr-client.css
nostr-client.js
locales.js
+ board.html (bulletin board page)
+ board.js
CNAME
vendor/
nostr-tools/
@@ -152,6 +154,32 @@ including the EU, Russia, China, Japan, and the Middle East).
Field capacity therefore varies by paper size; see
[`flyer-field-limits.md`](flyer-field-limits.md).
+## Bulletin board
+
+`site/board.html` + `site/board.js` are a standalone page that lists the most
+recent flyers found on the default relays as a sortable, spreadsheet-style
+table: **title**, **link** (the poster URL, rendered as the word "link"),
+**posting npub**, **language**, and **posted date**. Any column header sorts
+(default is date descending); replaceable events are de-duplicated per
+author+`d` identifier, keeping the latest.
+
+- The entry point is a small screen-only link below the flyer preview
+ (`.board-link`, label `bulletin_board`), outside the printable sheet. It must
+ never print (hidden in the `@media print` block alongside the other app
+ chrome).
+- `board.js` is independent of `nostr-client.js` (which exports nothing), so its
+ UI strings live in `BOARD_UI` (all supported languages); it reuses the
+ vendored nostr-tools and `locales.js` (for language names + RTL direction).
+- The board has its own language selector. It reads/writes the same
+ `voxvera_nostr_lang` localStorage key as the main client, so the chosen
+ language carries across both pages, and switching it re-localizes the chrome,
+ column headers, language labels, and date formatting (and flips RTL).
+- Relay content is untrusted: titles and npubs are escaped, and link URLs are
+ rejected unless `http:`/`https:` (so `javascript:`/`data:` never render as a
+ link).
+- The npub column is forward-looking — anonymous keys today, but a stable
+ identity per author maps cleanly onto this view later.
+
## Localization
Flyer defaults come from `site/locales.js`.
diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js
index f109394..c6f1f05 100644
--- a/e2e/flyer.spec.js
+++ b/e2e/flyer.spec.js
@@ -146,4 +146,48 @@ test.describe("VoxVera static client", () => {
// Stored override beats the en-US region default.
await expect(page.locator("html")).toHaveClass(/paper-a4/);
});
+
+ test("bulletin board link sits outside the flyer sheet and is not printed", async ({ page }) => {
+ await page.goto("/");
+ const link = page.locator(".preview-band > .board-link a");
+ await expect(link).toHaveText("Bulletin board");
+ await expect(link).toHaveAttribute("href", "board.html");
+ // It is app chrome below the preview, not part of the printable sheet.
+ await expect(page.locator(".container .board-link")).toHaveCount(0);
+ // Screen-only — it must not appear in print output.
+ await page.emulateMedia({ media: "print" });
+ await expect(page.locator(".board-link")).toBeHidden();
+ });
+
+ test("bulletin board page renders localized, sortable columns", async ({ page }) => {
+ await page.goto("/board.html");
+ await expect(page.locator("#board-title")).toHaveText("Bulletin Board");
+ const headers = page.locator("#board-head th");
+ await expect(headers).toHaveCount(5);
+ await expect(headers.nth(0)).toContainText("Title");
+ await expect(headers.nth(1)).toContainText("Link");
+ await expect(headers.nth(2)).toContainText("Posted by");
+ await expect(headers.nth(3)).toContainText("Language");
+ await expect(headers.nth(4)).toContainText("Posted");
+ // Default sort is date descending.
+ await expect(headers.nth(4).locator(".sort-indicator")).toContainText("▼");
+ // Clicking the Title header makes it the active ascending sort.
+ await headers.nth(0).click();
+ await expect(headers.nth(0).locator(".sort-indicator")).toContainText("▲");
+ });
+
+ test("bulletin board language selector localizes the page", async ({ page }) => {
+ await page.goto("/board.html");
+ await expect(page.locator("#board-title")).toHaveText("Bulletin Board");
+ await page.locator("#board-lang").selectOption("de");
+ await expect(page.locator("#board-title")).toHaveText("Schwarzes Brett");
+ await expect(page.locator("#board-head th").first()).toContainText("Titel");
+ // An RTL language flips the document direction.
+ await page.locator("#board-lang").selectOption("ar");
+ await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
+ await expect(page.locator("#board-title")).toHaveText("لوحة الإعلانات");
+ // The choice persists (shared key) and survives a reload.
+ await page.reload();
+ await expect(page.locator("#board-title")).toHaveText("لوحة الإعلانات");
+ });
});
diff --git a/site/board.html b/site/board.html
new file mode 100644
index 0000000..b9b5e47
--- /dev/null
+++ b/site/board.html
@@ -0,0 +1,31 @@
+
+
+