Tear-off tabs: title label, clickable "open & reprint" link, VoxVera.org event ID

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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-11 08:29:13 -04:00
1 parent 085ff81c1a
commit 053ea875b8
5 files changed
+116 -11

No files matched your search

+1 -1
View File
@@ -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).
+2
View File
@@ -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 `<a href>` 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.
+32
View File
@@ -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 —
+21
View File
@@ -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;
+60 -10
View File
@@ -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("<br>");
// The flyer's title labels each tab; it is auto-sized to fit after render.
const tabTitle = String(config.title || "").trim();
const tabTitleHtml = tabTitle
? `<div class="tear-off-title" dir="${escapeHtml((flyerLocale.meta && flyerLocale.meta.direction) || "ltr")}">${escapeHtml(tabTitle)}</div>`
: "";
const eventIdValueHtml = eventId.length > 32
? `${escapeHtml(eventId.slice(0, 32))}<br>${escapeHtml(eventId.slice(32))}`
: escapeHtml(eventId);
const eventIdHtml = eventId
? `<div class="tear-off-event-id">${escapeHtml(eventIdLabel)}:<br><span class="tear-off-event-id-value">${eventIdValueHtml}</span></div>`
? `<div class="tear-off-event-id">${escapeHtml(tabEventIdLabel)}:<br><span class="tear-off-event-id-value">${eventIdValueHtml}</span></div>`
: "";
const tearOffHtml = Array.from({ length: 10 }).map(() => `
<div class="tear-off">
<div class="tear-off-text">
<a href="${escapeHtml(tearOff)}" title="${escapeHtml(tearOff)}">${tearOffStatement}</a>
${tabTitleHtml}
<a class="tear-off-link" href="${escapeHtml(tearOff)}" title="${escapeHtml(tearOff)}">${escapeHtml(tabLinkLabel)}</a>
${eventIdHtml}
</div>
<div class="qr-code" aria-label="QR code for ${escapeHtml(tearOff)}">${tearOffQrSvg}</div>
@@ -2777,9 +2805,31 @@ Join us in a revolution that values truth and transparency. Together, we can bui
</div>
</div>
`;
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");