From 053ea875b8629dc5d596e8dc8999ddf675eca99c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:29:13 -0400 Subject: [PATCH] Tear-off tabs: title label, clickable "open & reprint" link, VoxVera.org event ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 --- README.md | 2 +- docs/nostr-static-client-spec.md | 2 + e2e/flyer.spec.js | 32 +++++++++++++++ site/nostr-client.css | 21 ++++++++++ site/nostr-client.js | 70 +++++++++++++++++++++++++++----- 5 files changed, 116 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 02c8966..d8a080c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. - **Edit, re-publish, and delete your flyers.** Because flyers are replaceable events, the author can edit one in place (same key + Flyer Name keeps the same URL), re-publish it as a keep-alive so relays don't age it out, or delete it. Delete publishes a replaceable **tombstone** (so VoxVera hides it even where relays ignore deletions) **and** a NIP-09 deletion request — best-effort, author-only. - **Open from a link or ID.** Load a flyer from an `naddr`, `nevent1`, `note1`, raw 64-char event ID, or `nostr:` URL. The lookup field tolerates copy/paste whitespace and newlines, so an event ID copied off a printed tear-off (where it wraps across two lines) still resolves. - **Short, scannable poster URLs.** Each poster URL is a standard NIP-19 `naddr` carrying a single relay hint, placed in the URL `#fragment` (no `?addr=` query). Shorter URLs make the tear-off QR code easier to scan. Older relay-list `naddr`s and `?addr=` URLs still resolve. -- **Tear-off tabs.** The printable flyer has ten tear-off tabs. Each tab is a single hyperlinked call to action (the URL is the link, not printed as text) and prints the full event ID so anyone can re-find the flyer by typing it. +- **Tear-off tabs.** The printable flyer has ten tear-off tabs. Each tab is labeled with the flyer's **title** (auto-sized to be as large as fits), a clickable **"Open & reprint this flyer"** link (live in the printed PDF), a QR code, and a **"Reopen at VoxVera.org — Event ID:"** line with the full event ID so anyone can re-find and reprint the flyer by typing it in. Localized in every language. - **Loading state on shared links.** Opening a poster/event link shows a localized "Loading content…" placeholder (and a "Could not load this flyer." state on failure) instead of the default flyer; the default sample content appears only on the bare site URL. - **14 languages with RTL support.** Flyer content and UI are localized; right-to-left scripts render correctly. Letter-spacing is applied per script so cursive (Arabic/Persian) and combining (Devanagari) scripts are not broken — see [the spec](docs/nostr-static-client-spec.md#letter-spacing-by-script). - **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). diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index 4f9289b..c24d2a0 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -103,6 +103,8 @@ Default behavior: `qr_target: "flyer_url"`. When publishing, the client computes the replaceable-event `naddr` and writes the generated poster URL to `tear_off_link`. The tear-off tabs and tear-off QR code point at `tear_off_link`, so people can re-open and reprint the flyer. The main flyer QR code points at `url`, so viewers can visit the creator's intended destination. +Each tear-off tab is labeled with the flyer's **title** (`.tear-off-title`, auto-sized by `fitTabTitles()` to be as large as fits the ~1in tab, shrinking rather than overflowing for long titles), a clickable **"Open & reprint this flyer"** link (`tab_link`, the `` so it stays live in a printed PDF), the tear-off QR, and a **"Reopen at VoxVera.org — Event ID:"** line (`tab_event_id`, the brand stays Latin; the rest is localized) with the full event id. All strings are translated in every supported language. + The `naddr` is encoded with a **single relay hint** (the primary configured relay) instead of the full relay list. This keeps the poster URL short and its tear-off QR code easy to scan — a typical poster URL drops from ~233 characters (full list) to ~171 — while still pointing a fresh viewer at a relay that has the event. Viewers also fall back to `DEFAULT_RELAYS`, which is where flyers are published by default. This stays a standard NIP-19 `naddr`, so any Nostr client can resolve it, and older relay-bearing `naddr` URLs continue to decode and resolve unchanged. The naddr is carried in the URL **fragment** (`#naddr1...`) rather than a `?addr=` query parameter. The fragment is shorter (less QR clutter), needs no URL-encoding, and never reaches the static host. `getUrlEventReference` reads the naddr from query params, the fragment, or anywhere in the full URL, so older `?addr=` poster URLs and printed QR codes still resolve. diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index 2cd477f..0dd13e5 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -210,6 +210,38 @@ test.describe("VoxVera static client", () => { expect(href || "").not.toMatch(/^javascript:/i); }); + test("tear-off tabs carry the title, an open-&-reprint link, and a VoxVera.org event-id", async ({ page }) => { + const PK = "c".repeat(64); + const IDENT = "voxvera:tab-test"; + await stubRelays(page, [{ + id: "ab".repeat(32), kind: 30078, pubkey: PK, created_at: 3000, + tags: [["d", IDENT], ["t", "voxvera"], ["t", "flyer"], ["language", "en"]], + content: JSON.stringify({ + type: "voxvera_flyer", version: 1, folder_name: "tab-test", lang: "en", + name: "N", title: "BITCOIN", subtitle: "s", headline: "h", content: "c", + url_message: "m", url: "https://example.com/x", footer_message: "f", + tear_off_link: "https://voxvera.org/#naddr1tab", qr_target: "flyer_url" + }) + }]); + await page.goto("/"); + const naddr = await page.evaluate(({ pk, ident }) => + window.NostrTools.nip19.naddrEncode({ identifier: ident, pubkey: pk, kind: 30078, relays: [] }), + { pk: PK, ident: IDENT }); + await page.goto("about:blank"); + await page.goto(`/#${naddr}`); + const tab = page.locator(".tear-off").first(); + // Title labels the tab and is auto-sized to a concrete font size. + await expect(tab.locator(".tear-off-title")).toHaveText("BITCOIN"); + expect(await tab.locator(".tear-off-title").evaluate((n) => n.style.fontSize)).toMatch(/\d+px/); + // The instruction line is the clickable link to the poster URL. + const link = tab.locator("a.tear-off-link"); + await expect(link).toHaveText("Open & reprint this flyer"); + await expect(link).toHaveAttribute("href", "https://voxvera.org/#naddr1tab"); + // Event-id label points people back to VoxVera.org. + await expect(tab.locator(".tear-off-event-id")).toContainText("Reopen at VoxVera.org"); + await expect(tab.locator(".tear-off-event-id")).toContainText("Event ID"); + }); + test("viewer resolves a flyer from relays that ignore the #d filter", async ({ page }) => { // Simulate a relay that does NOT honor the addressable "#d" tag filter (it // returns nothing for such a REQ) but does answer a broad author query — diff --git a/site/nostr-client.css b/site/nostr-client.css index 2c3bd3d..b6ed822 100644 --- a/site/nostr-client.css +++ b/site/nostr-client.css @@ -548,6 +548,27 @@ a { flex-direction: column; } +/* Tear-off title labels each tab. Its font-size is set by fitTabTitles() in JS + to be as large as fits; this caps the height so a long title shrinks (and at + worst clips) instead of pushing the link/event-id out of the 1in tab. */ +.tear-off-title { + font-weight: 700; + line-height: 1.05; + text-align: center; + letter-spacing: 0; + max-height: 0.42in; + overflow: hidden; + margin-bottom: 2px; +} + +/* The clickable action line under the title (also clickable in a printed PDF). */ +.tear-off-link { + display: block; + font-size: 10px; + line-height: 1.15; + text-align: center; +} + .tear-off-event-id { margin-top: auto; padding-top: 4px; diff --git a/site/nostr-client.js b/site/nostr-client.js index 052ffc8..07c8d6c 100644 --- a/site/nostr-client.js +++ b/site/nostr-client.js @@ -183,6 +183,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "توقيع ونشر", published_event: "الحدث المنشور", share_reprint: "شارك وأعد الطباعة.", + tab_link: "افتح وأعد طباعة هذا الملصق", + tab_event_id: "أعد الفتح على VoxVera.org — معرّف الحدث", built_with: "بني بواسطة", viewer: "العارض", client_title: "عميل VoxVera Nostr", @@ -271,6 +273,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "Signieren und veröffentlichen", published_event: "Veröffentlichtes Event", share_reprint: "Teilen und neu drucken.", + tab_link: "Plakat öffnen & neu drucken", + tab_event_id: "Erneut öffnen auf VoxVera.org — Event-ID", built_with: "Erstellt mit", viewer: "Viewer", client_title: "VoxVera Nostr-Client", @@ -359,6 +363,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "Sign and publish", published_event: "Published Event", share_reprint: "Share and reprint.", + tab_link: "Open & reprint this flyer", + tab_event_id: "Reopen at VoxVera.org — Event ID", built_with: "Built with", viewer: "Viewer", client_title: "VoxVera Nostr Client", @@ -448,6 +454,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "Firmar y publicar", published_event: "Evento publicado", share_reprint: "Comparta y reimprima.", + tab_link: "Abre y reimprime este cartel", + tab_event_id: "Reábrelo en VoxVera.org — ID del evento", built_with: "Creado con", viewer: "Visor", client_title: "Cliente Nostr de VoxVera", @@ -536,6 +544,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "امضا و انتشار", published_event: "رویداد منتشرشده", share_reprint: "به اشتراک بگذارید و دوباره چاپ کنید.", + tab_link: "این پوستر را باز و دوباره چاپ کنید", + tab_event_id: "در VoxVera.org دوباره باز کنید — شناسه رویداد", built_with: "ساخته شده با", viewer: "نمایشگر", client_title: "کلاینت Nostr VoxVera", @@ -624,6 +634,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "Signer et publier", published_event: "Evenement publie", share_reprint: "Partagez et reimprimez.", + tab_link: "Ouvrir et réimprimer cette affiche", + tab_event_id: "Rouvrir sur VoxVera.org — ID de l'événement", built_with: "Cree avec", viewer: "Visionneuse", client_title: "Client Nostr VoxVera", @@ -712,6 +724,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "חתום ופרסם", published_event: "אירוע שפורסם", share_reprint: "שתף והדפס מחדש.", + tab_link: "פתח והדפס מחדש את הכרזה", + tab_event_id: "פתח מחדש ב-VoxVera.org — מזהה אירוע", built_with: "נבנה עם", viewer: "מציג", client_title: "לקוח Nostr של VoxVera", @@ -800,6 +814,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "हस्ताक्षर कर प्रकाशित करें", published_event: "प्रकाशित इवेंट", share_reprint: "साझा करें और फिर से प्रिंट करें।", + tab_link: "इस पोस्टर को खोलें और फिर से प्रिंट करें", + tab_event_id: "VoxVera.org पर फिर से खोलें — इवेंट ID", built_with: "इसके साथ बनाया गया", viewer: "दर्शक", client_title: "VoxVera Nostr क्लाइंट", @@ -888,6 +904,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "署名して公開", published_event: "公開済みイベント", share_reprint: "共有して再印刷。", + tab_link: "開いて再印刷", + tab_event_id: "VoxVera.org で再度開く — イベントID", built_with: "作成:", viewer: "ビューア", client_title: "VoxVera Nostr クライアント", @@ -976,6 +994,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "Assinar e publicar", published_event: "Evento publicado", share_reprint: "Compartilhe e reimprima.", + tab_link: "Abrir e reimprimir este panfleto", + tab_event_id: "Reabrir em VoxVera.org — ID do evento", built_with: "Criado com", viewer: "Visualizador", client_title: "Cliente Nostr VoxVera", @@ -1064,6 +1084,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "Подписать и опубликовать", published_event: "Опубликованное событие", share_reprint: "Поделитесь и распечатайте снова.", + tab_link: "Открыть и распечатать снова", + tab_event_id: "Открыть снова на VoxVera.org — ID события", built_with: "Создано с помощью", viewer: "Просмотр", client_title: "Клиент VoxVera Nostr", @@ -1152,6 +1174,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "Saini na chapisha", published_event: "Tukio lililochapishwa", share_reprint: "Shiriki na uchapishe tena.", + tab_link: "Fungua na uchapishe tena bango hili", + tab_event_id: "Fungua tena kwenye VoxVera.org — Kitambulisho cha tukio", built_with: "Imejengwa na", viewer: "Kitazamaji", client_title: "Mteja wa VoxVera Nostr", @@ -1240,6 +1264,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "İmzala ve yayınla", published_event: "Yayınlanan olay", share_reprint: "Paylaşın ve yeniden yazdırın.", + tab_link: "Bu el ilanını aç ve yeniden yazdır", + tab_event_id: "VoxVera.org'da yeniden aç — Olay ID", built_with: "İle yapıldı", viewer: "Görüntüleyici", client_title: "VoxVera Nostr İstemcisi", @@ -1328,6 +1354,8 @@ Join us in a revolution that values truth and transparency. Together, we can bui publish_event: "签名并发布", published_event: "已发布事件", share_reprint: "分享并重新打印。", + tab_link: "打开并重新打印此传单", + tab_event_id: "在 VoxVera.org 重新打开 — 事件 ID", built_with: "构建自", viewer: "查看器", client_title: "VoxVera Nostr 客户端", @@ -2728,26 +2756,26 @@ Join us in a revolution that values truth and transparency. Together, we can bui const tearOffQrSvg = makeQrSvg(tearOff); const contentQrSvg = makeQrSvg(contentQr); const sheetClass = `${tearOff ? "container" : "container no-tear-offs"} ls-${trackingPolicy(flyerLang)}`; - const openPosterLabel = nostrLabel("open_poster", flyerLang); - const shareReprintLabel = nostrLabel("share_reprint", flyerLang); + const tabLinkLabel = nostrLabel("tab_link", flyerLang); const builtWithLabel = nostrLabel("built_with", flyerLang); - const eventIdLabel = nostrLabel("event_id", flyerLang); + const tabEventIdLabel = nostrLabel("tab_event_id", flyerLang); const eventId = String(config.event_id || "").trim(); - const tearOffStatement = [openPosterLabel, shareReprintLabel] - .map((part) => String(part || "").trim()) - .filter(Boolean) - .map((part) => escapeHtml(part)) - .join("
"); + // The flyer's title labels each tab; it is auto-sized to fit after render. + const tabTitle = String(config.title || "").trim(); + const tabTitleHtml = tabTitle + ? `
${escapeHtml(tabTitle)}
` + : ""; const eventIdValueHtml = eventId.length > 32 ? `${escapeHtml(eventId.slice(0, 32))}
${escapeHtml(eventId.slice(32))}` : escapeHtml(eventId); const eventIdHtml = eventId - ? `
${escapeHtml(eventIdLabel)}:
${eventIdValueHtml}
` + ? `
${escapeHtml(tabEventIdLabel)}:
${eventIdValueHtml}
` : ""; const tearOffHtml = Array.from({ length: 10 }).map(() => `
- ${tearOffStatement} + ${tabTitleHtml} + ${escapeHtml(tabLinkLabel)} ${eventIdHtml}
${tearOffQrSvg}
@@ -2777,9 +2805,31 @@ Join us in a revolution that values truth and transparency. Together, we can bui
`; + fitTabTitles(); updatePreviewScale(); } + // Size the tear-off title as large as it can be while still fitting the tab + // (so short titles are big and long ones shrink instead of overflowing). All + // ten tabs share the same title, so measure one and apply the result to all. + function fitTabTitles() { + const titles = el("flyer-preview") && el("flyer-preview").querySelectorAll(".tear-off-title"); + if (!titles || !titles.length) return; + const MAX = 22; + const MIN = 9; + const probe = titles[0]; + let size = MAX; + probe.style.fontSize = `${size}px`; + // .tear-off-title caps its height via CSS; shrink until the text fits within + // both that height and the tab width. + const fits = () => probe.scrollHeight <= probe.clientHeight + 1 && probe.scrollWidth <= probe.clientWidth + 1; + while (size > MIN && !fits()) { + size -= 1; + probe.style.fontSize = `${size}px`; + } + titles.forEach((node) => { node.style.fontSize = `${size}px`; }); + } + function updatePreviewScale() { const preview = el("flyer-preview"); const sheet = preview && preview.querySelector(".container");