diff --git a/AGENTS.md b/AGENTS.md index f5db780..1c78885 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ - Treat Nostr event content as untrusted. Escape rendered content and reject unsafe schema input. - Preserve print behavior: app controls must not print or shift the flyer sheet in print output. - Preserve the old flyer visual language unless the user explicitly asks for a redesign. +- Paper size (US Letter / A4) is a local print/view preference (`localStorage`), defaulted from the browser locale region. Never write it into the Nostr event — a flyer must reprint on either size without changing the source event. ## Localization diff --git a/README.md b/README.md index 64a3bbc..6b53358 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ The previous Tor/OnionShare CLI version is preserved on the `legacy-tor` branch. - **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. - **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). -- **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. +- **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. - **Self-contained.** No build step and no CDNs — Nostr and QR libraries are vendored under `site/vendor/`. diff --git a/docs/flyer-field-limits.md b/docs/flyer-field-limits.md index 927ffe8..3c1ab75 100644 --- a/docs/flyer-field-limits.md +++ b/docs/flyer-field-limits.md @@ -9,7 +9,8 @@ So the real limit is enforced for you as you type — the numbers below are practical guidance for drafting copy, sizing translations, or prompting an LLM to write flyer text that will fit on the first try. -Capacity varies by **script**, not by individual language, for three reasons: +Capacity varies by **script** (not by individual language) and by **paper +size**, for these reasons: 1. The flyer body font is monospace (`Courier New`), so within a script every character takes the same width regardless of upper/lowercase. @@ -17,19 +18,22 @@ Capacity varies by **script**, not by individual language, for three reasons: [`nostr-static-client-spec.md`](nostr-static-client-spec.md#letter-spacing-by-script)). 3. Glyph width differs by script — CJK glyphs are full-width (≈2× a Latin character), Devanagari fallback glyphs are narrower, etc. +4. **Paper size.** A4 (210×297mm) is slightly narrower and taller than US + Letter (8.5×11in), so single-line fields hold ~2 fewer characters on A4 + while the body holds a little more. Both sizes are shown below. ## Single-line fields (hard caps) **Title**, **Subtitle**, and **Headline** are single-line: they never wrap, so these are the limits people actually run into. Counts are characters -(codepoints). +(codepoints), shown as **Letter / A4**. -| Languages | Title | Headline | Subtitle | -|---|---|---|---| -| Latin & Cyrillic & Hebrew — en, es, de, fr, pt, sw, tr, ru, he | ~28 | ~28 | ~32 | -| Arabic & Persian — ar, fa | ~28 | ~28 | ~38 | -| Devanagari — hi | ~36 | ~36 | ~51 | -| CJK — ja, zh | ~17 | ~17 | ~23 | +| Languages | Title & Headline | Subtitle | +|---|---|---| +| Latin & Cyrillic & Hebrew — en, es, de, fr, pt, sw, tr, ru, he | ~28 / ~26 | ~32 / ~30 | +| Arabic & Persian — ar, fa | ~28 / ~27 | ~38 / ~36 | +| Devanagari — hi | ~36 / ~35 | ~51 / ~48 | +| CJK — ja, zh | ~17 / ~16 | ~23 / ~21 | ## Multi-line fields (generous) @@ -37,12 +41,12 @@ these are the limits people actually run into. Counts are characters (`footer_message`) wrap across multiple lines, so they hold far more and are bounded only by the remaining height of the sheet. -| Languages | Body (content) | +| Languages | Body (content) — Letter / A4 | |---|---| -| Latin & Cyrillic & Hebrew | ~1,900 (a few hundred words) | -| Arabic & Persian | ~1,850 | -| Devanagari — hi | ~2,450 | -| CJK — ja, zh | ~1,170 | +| Latin & Cyrillic & Hebrew | ~1,900 / ~2,000 (a few hundred words) | +| Arabic & Persian | ~1,850 / ~1,930 | +| Devanagari — hi | ~2,450 / ~2,545 | +| CJK — ja, zh | ~1,170 / ~1,220 | `url_message` and `footer_message` share the lower portion of the sheet and can hold roughly 1,200–4,800 characters depending on script, but for a readable @@ -55,7 +59,7 @@ URLs scan more reliably). ## Notes and caveats - **Approximate.** These come from rendering each script in a headless browser - at true print size (8.5in × 11in, content column 4.75in). The + at true print size for both Letter (8.5×11in) and A4 (210×297mm). The Latin/Cyrillic numbers (`Courier New`) are the most reliable; numbers for Hebrew, Arabic, Persian, Devanagari, and CJK depend on the system fallback font, which can differ slightly between devices and from print output. diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md index 5692050..637339d 100644 --- a/docs/nostr-static-client-spec.md +++ b/docs/nostr-static-client-spec.md @@ -122,6 +122,36 @@ The client and schema helper must: - keep attachments unsupported in V1 - avoid remote frontend dependencies +## Print paper size + +The flyer supports two print sizes: **US Letter** (8.5×11in) and **A4** +(210×297mm), covering every country whose language the client supports (Letter +in North America and a few Latin American countries; A4 everywhere else, +including the EU, Russia, China, Japan, and the Middle East). + +- Dimensions live in CSS custom properties `--sheet-w` / `--sheet-h`, switched + by a `paper-letter` / `paper-a4` class on `` (`applyPaperSize` in + `nostr-client.js`). The tear-off column (`3.75in`) and QR codes (`1.25in`) + stay fixed physical sizes; the content column flexes to the sheet width. +- The print `@page { size: … }` is injected by JS (an element with id + `voxvera-page-size`), because `@page size` cannot read CSS custom properties + across engines. The static `@media print` block keeps `margin: 0` and a + Letter default for the pre-JS state. +- The default is chosen from the **region subtag** of the browser locale + (`preferredPaperSize` — e.g. `en-GB` → A4, `en-US` → Letter). This is "by + location" without geolocation, using the same signal as language. When no + locale carries a region, it falls back to Letter. +- A user override (the paper-size selector) is stored in `localStorage` + (`voxvera_paper`) and wins over the region default. +- **Paper size is a local print/view preference and is never written into the + Nostr event.** A flyer authored on Letter reprints on A4 for a European + viewer without changing the source event. The live fit-check protects + authoring on whichever size is active; switching to a narrower size after + authoring can ellipsis-truncate a maxed-out single-line field. + +Field capacity therefore varies by paper size; see +[`flyer-field-limits.md`](flyer-field-limits.md). + ## Localization Flyer defaults come from `site/locales.js`. diff --git a/e2e/flyer.spec.js b/e2e/flyer.spec.js index e543abe..f109394 100644 --- a/e2e/flyer.spec.js +++ b/e2e/flyer.spec.js @@ -86,4 +86,64 @@ test.describe("VoxVera static client", () => { ); await expect(page.locator(".content h1")).toHaveCount(0); }); + + test("infers language from the first supported browser preference", async ({ page }) => { + // Browser prefers Dutch (unsupported), then German (supported), then + // English. We should honor German rather than jumping to the English + // fallback — i.e. walk navigator.languages, not just navigator.language. + await page.addInitScript(() => { + Object.defineProperty(navigator, "languages", { + configurable: true, + get: () => ["nl-NL", "de-DE", "en-US"] + }); + Object.defineProperty(navigator, "language", { + configurable: true, + get: () => "nl-NL" + }); + }); + await page.goto("/"); + // German default headline (locales.js de.landing.title). + await expect(page.locator(".content h1").first()).toContainText("STRENG GEHEIM"); + await expect(page.locator("html")).toHaveAttribute("lang", "de"); + }); + + test("defaults to A4 for an A4-region locale (no geolocation)", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "languages", { configurable: true, get: () => ["de-DE"] }); + Object.defineProperty(navigator, "language", { configurable: true, get: () => "de-DE" }); + }); + await page.goto("/"); + await expect(page.locator("html")).toHaveClass(/paper-a4/); + const pageRule = await page.evaluate( + () => document.getElementById("voxvera-page-size").textContent + ); + expect(pageRule).toContain("A4"); + }); + + test("defaults to US Letter for a Letter-region locale", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "languages", { configurable: true, get: () => ["en-US"] }); + Object.defineProperty(navigator, "language", { configurable: true, get: () => "en-US" }); + }); + await page.goto("/"); + await expect(page.locator("html")).toHaveClass(/paper-letter/); + const pageRule = await page.evaluate( + () => document.getElementById("voxvera-page-size").textContent + ); + expect(pageRule).toContain("Letter"); + }); + + test("manual paper-size override persists across reloads", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(navigator, "languages", { configurable: true, get: () => ["en-US"] }); + }); + // Editor mode shows the topbar controls; viewer mode hides them. + await page.goto("/#editor"); + await expect(page.locator("html")).toHaveClass(/paper-letter/); + await page.locator("#topbar-paper").selectOption("a4"); + await expect(page.locator("html")).toHaveClass(/paper-a4/); + await page.reload(); + // Stored override beats the en-US region default. + await expect(page.locator("html")).toHaveClass(/paper-a4/); + }); }); diff --git a/site/index.html b/site/index.html index 24108b3..f0a7087 100644 --- a/site/index.html +++ b/site/index.html @@ -18,6 +18,7 @@ + @@ -26,6 +27,7 @@ +
diff --git a/site/nostr-client.css b/site/nostr-client.css index 358690e..7cc768d 100644 --- a/site/nostr-client.css +++ b/site/nostr-client.css @@ -7,6 +7,21 @@ --accent: #b80000; --ok: #1d6b3a; --warn: #8a4d00; + /* Printable sheet dimensions. Switched by the paper-* class on + (applyPaperSize in nostr-client.js). The tear-off column (3.75in) and QR + codes (1.25in) stay fixed physical sizes; the content column flexes. */ + --sheet-w: 8.5in; + --sheet-h: 11in; +} + +html.paper-letter { + --sheet-w: 8.5in; + --sheet-h: 11in; +} + +html.paper-a4 { + --sheet-w: 210mm; + --sheet-h: 297mm; } * { @@ -278,8 +293,8 @@ code { .flyer-sheet { display: flex; - width: 8.5in; - height: 11in; + width: var(--sheet-w); + height: var(--sheet-h); border: 1px solid #000; background: #fff; color: #000; @@ -374,8 +389,8 @@ a { .container { display: flex; flex-direction: row; - width: 8.5in; - height: 11in; + width: var(--sheet-w); + height: var(--sheet-h); border: 1px solid #000; background: #fff; color: #000; @@ -502,7 +517,7 @@ a { } .content { - width: calc(8.5in - 3.75in); + width: calc(var(--sheet-w) - 3.75in); padding: 15px 20px; text-align: center; box-sizing: border-box; @@ -880,6 +895,9 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { } @media print { + /* @page size is injected by applyPaperSize() in nostr-client.js (Letter or + A4) since @page cannot read CSS custom properties across engines. This + rule is the margin fallback and the default size before JS runs. */ @page { size: Letter portrait; margin: 0; @@ -887,8 +905,8 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { html, body { - width: 8.5in; - height: 11in; + width: var(--sheet-w); + height: var(--sheet-h); margin: 0; padding: 0; overflow: hidden; @@ -910,16 +928,16 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { main { display: block; - width: 8.5in; - height: 11in; + width: var(--sheet-w); + height: var(--sheet-h); padding: 0; margin: 0; } .preview-band { display: block; - width: 8.5in; - height: 11in; + width: var(--sheet-w); + height: var(--sheet-h); border: 0; margin: 0; padding: 0; @@ -927,8 +945,8 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { .flyer-preview { display: block; - width: 8.5in; - height: 11in; + width: var(--sheet-w); + height: var(--sheet-h); overflow: visible; padding: 0; margin: 0; @@ -938,8 +956,8 @@ body.mode-viewer #viewer-panel.drawer-open .result-panel { .container { position: absolute; inset: 0 auto auto 0; - width: 8.5in; - height: 11in; + width: var(--sheet-w); + height: var(--sheet-h); transform: none; margin: 0; border: 0; diff --git a/site/nostr-client.js b/site/nostr-client.js index 918eacd..3187dfe 100644 --- a/site/nostr-client.js +++ b/site/nostr-client.js @@ -11,6 +11,20 @@ const LOCALES = window.VoxVeraLocales || {}; const FALLBACK_LANG = "en"; const LANGUAGE_SELECT_IDS = ["topbar-lang", "field-lang", "viewer-lang"]; + const PAPER_SELECT_IDS = ["topbar-paper", "viewer-paper"]; + const PAPER_STORAGE_KEY = "voxvera_paper"; + // Print paper sizes. `css` is the CSS @page size keyword. The actual sheet + // dimensions live in nostr-client.css (--sheet-w / --sheet-h per paper class). + const PAPER_SIZES = { + letter: { css: "Letter" }, + a4: { css: "A4" } + }; + const DEFAULT_PAPER = "letter"; + // ISO 3166-1 alpha-2 regions whose default print paper is US Letter. + // Everywhere else defaults to A4 (ISO 216), which is the global standard. + const LETTER_REGIONS = new Set([ + "US", "CA", "MX", "CL", "CO", "CR", "GT", "DO", "PH", "VE", "PA", "NI", "SV", "HN", "BO", "EC", "PR" + ]); const EVENT_KIND = 30078; // Per-script letter-spacing policy for flyer text. `letter-spacing` is not // script-neutral, so the flyer tightens tracking only where it is safe: @@ -183,6 +197,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "تم الجلب", loading_content: "جارٍ تحميل المحتوى...", load_failed: "تعذّر تحميل هذا الملصق.", + paper_size: "حجم الورق", + paper_letter: "US Letter (8.5 × 11 بوصة)", + paper_a4: "A4 (210 × 297 مم)", config_copied: "تم نسخ الإعداد.", lookup_placeholder: "naddr أو معرف حدث أو note1 أو nevent1 أو رابط nostr:", event_reference_required: "أدخل naddr أو معرف حدث أو note1 أو nevent1.", @@ -237,6 +254,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "Abgerufen", loading_content: "Inhalt wird geladen...", load_failed: "Dieses Plakat konnte nicht geladen werden.", + paper_size: "Papierformat", + paper_letter: "US Letter (8,5 × 11 Zoll)", + paper_a4: "A4 (210 × 297 mm)", config_copied: "Konfiguration kopiert.", lookup_placeholder: "naddr, Event-ID, note1, nevent1 oder nostr:-URL", event_reference_required: "Geben Sie naddr, Event-ID, note1 oder nevent1 ein.", @@ -291,6 +311,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "Fetched", loading_content: "Loading content...", load_failed: "Could not load this flyer.", + paper_size: "Paper size", + paper_letter: "US Letter (8.5 × 11 in)", + paper_a4: "A4 (210 × 297 mm)", config_copied: "Config copied.", lookup_placeholder: "naddr, event id, note1, nevent1, or nostr: URL", event_reference_required: "Enter an naddr, event id, note1, or nevent1.", @@ -346,6 +369,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "Recuperado", loading_content: "Cargando contenido...", load_failed: "No se pudo cargar este cartel.", + paper_size: "Tamaño de papel", + paper_letter: "US Letter (8,5 × 11 in)", + paper_a4: "A4 (210 × 297 mm)", config_copied: "Configuración copiada.", lookup_placeholder: "naddr, ID de evento, note1, nevent1 o URL nostr:", event_reference_required: "Ingrese un naddr, ID de evento, note1 o nevent1.", @@ -400,6 +426,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "دریافت شد", loading_content: "در حال بارگذاری محتوا...", load_failed: "بارگذاری این پوستر ممکن نشد.", + paper_size: "اندازه کاغذ", + paper_letter: "US Letter (8.5 × 11 اینچ)", + paper_a4: "A4 (210 × 297 میلی‌متر)", config_copied: "پیکربندی کپی شد.", lookup_placeholder: "naddr، شناسه رویداد، note1، nevent1 یا URL nostr:", event_reference_required: "یک naddr، شناسه رویداد، note1 یا nevent1 وارد کنید.", @@ -454,6 +483,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "Charge", loading_content: "Chargement du contenu...", load_failed: "Impossible de charger cette affiche.", + paper_size: "Format du papier", + paper_letter: "US Letter (8,5 × 11 po)", + paper_a4: "A4 (210 × 297 mm)", config_copied: "Configuration copiee.", lookup_placeholder: "naddr, ID d'evenement, note1, nevent1 ou URL nostr:", event_reference_required: "Saisissez un naddr, ID d'evenement, note1 ou nevent1.", @@ -508,6 +540,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "נטען", loading_content: "טוען תוכן...", load_failed: "לא ניתן לטעון כרזה זו.", + paper_size: "גודל נייר", + paper_letter: "US Letter (8.5 × 11 אינץ׳)", + paper_a4: "A4 (210 × 297 מ״מ)", config_copied: "התצורה הועתקה.", lookup_placeholder: "naddr, מזהה אירוע, note1, nevent1 או כתובת nostr:", event_reference_required: "הזן naddr, מזהה אירוע, note1 או nevent1.", @@ -562,6 +597,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "लाया गया", loading_content: "सामग्री लोड हो रही है...", load_failed: "यह पोस्टर लोड नहीं हो सका।", + paper_size: "कागज़ का आकार", + paper_letter: "US Letter (8.5 × 11 इंच)", + paper_a4: "A4 (210 × 297 मिमी)", config_copied: "कॉन्फिग कॉपी हुआ.", lookup_placeholder: "naddr, इवेंट ID, note1, nevent1, या nostr: URL", event_reference_required: "naddr, इवेंट ID, note1, या nevent1 दर्ज करें.", @@ -616,6 +654,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "取得しました", loading_content: "コンテンツを読み込んでいます...", load_failed: "このちらしを読み込めませんでした。", + paper_size: "用紙サイズ", + paper_letter: "US レター (8.5 × 11 インチ)", + paper_a4: "A4 (210 × 297 mm)", config_copied: "設定をコピーしました。", lookup_placeholder: "naddr、イベントID、note1、nevent1、または nostr: URL", event_reference_required: "naddr、イベントID、note1、またはnevent1を入力してください。", @@ -670,6 +711,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "Buscado", loading_content: "Carregando conteúdo...", load_failed: "Não foi possível carregar este panfleto.", + paper_size: "Tamanho do papel", + paper_letter: "US Letter (8,5 × 11 pol)", + paper_a4: "A4 (210 × 297 mm)", config_copied: "Configuracao copiada.", lookup_placeholder: "naddr, ID do evento, note1, nevent1 ou URL nostr:", event_reference_required: "Insira um naddr, ID do evento, note1 ou nevent1.", @@ -724,6 +768,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "Загружено", loading_content: "Загрузка содержимого...", load_failed: "Не удалось загрузить эту листовку.", + paper_size: "Размер бумаги", + paper_letter: "US Letter (8,5 × 11 дюйма)", + paper_a4: "A4 (210 × 297 мм)", config_copied: "Конфигурация скопирована.", lookup_placeholder: "naddr, ID события, note1, nevent1 или URL nostr:", event_reference_required: "Введите naddr, ID события, note1 или nevent1.", @@ -778,6 +825,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "Imepakuliwa", loading_content: "Inapakia maudhui...", load_failed: "Imeshindwa kupakia bango hili.", + paper_size: "Ukubwa wa karatasi", + paper_letter: "US Letter (8.5 × 11 inchi)", + paper_a4: "A4 (210 × 297 mm)", config_copied: "Usanidi umenakiliwa.", lookup_placeholder: "naddr, kitambulisho cha tukio, note1, nevent1 au URL ya nostr:", event_reference_required: "Weka naddr, kitambulisho cha tukio, note1 au nevent1.", @@ -832,6 +882,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "Getirildi", loading_content: "İçerik yükleniyor...", load_failed: "Bu el ilanı yüklenemedi.", + paper_size: "Kağıt boyutu", + paper_letter: "US Letter (8,5 × 11 inç)", + paper_a4: "A4 (210 × 297 mm)", config_copied: "Yapılandırma kopyalandı.", lookup_placeholder: "naddr, olay ID, note1, nevent1 veya nostr: URL", event_reference_required: "Bir naddr, olay ID, note1 veya nevent1 girin.", @@ -886,6 +939,9 @@ Join us in a revolution that values truth and transparency. Together, we can bui fetched: "已获取", loading_content: "正在加载内容...", load_failed: "无法加载此传单。", + paper_size: "纸张大小", + paper_letter: "US Letter (8.5 × 11 英寸)", + paper_a4: "A4 (210 × 297 毫米)", config_copied: "配置已复制。", lookup_placeholder: "naddr、事件 ID、note1、nevent1 或 nostr: URL", event_reference_required: "请输入 naddr、事件 ID、note1 或 nevent1。", @@ -1142,6 +1198,14 @@ Join us in a revolution that values truth and transparency. Together, we can bui const select = el(selectId); if (select) select.value = selected; }); + PAPER_SELECT_IDS.forEach((selectId) => { + const select = el(selectId); + if (!select) return; + select.setAttribute("aria-label", nostrLabel("paper_size", selected)); + Array.from(select.options).forEach((opt) => { + opt.textContent = nostrLabel(`paper_${opt.value}`, selected); + }); + }); } function setDefaultText(lang) { @@ -1149,8 +1213,10 @@ Join us in a revolution that values truth and transparency. Together, we can bui el("editor-relays").value = relayText; el("viewer-relays").value = relayText; populateLanguageOptions(); - const defaultLang = supportedLang(lang || getStoredUiLang() || navigator.language.split("-")[0]); + populatePaperOptions(); + const defaultLang = supportedLang(lang || getStoredUiLang() || preferredBrowserLang()); applyFlyerDefaults(defaultLang); + applyPaperSize(getStoredPaper() || preferredPaperSize()); Object.entries(EMPTY_OUTPUT_LABELS).forEach(([id, key]) => setEmptyOutput(id, key, defaultLang)); setLocalizedText("publish-status", "idle", defaultLang); setLocalizedText("viewer-status", "idle", defaultLang); @@ -1170,6 +1236,96 @@ Join us in a revolution that values truth and transparency. Together, we can bui } } + // Walk the browser's full ordered preference list (navigator.languages), + // not just the single top choice, and return the first base language we + // actually support. This honors a user's secondary preference (e.g. prefers + // Dutch, then German) instead of jumping straight to the English fallback. + function preferredBrowserLang() { + const list = (navigator.languages && navigator.languages.length) + ? navigator.languages + : [navigator.language || ""]; + for (const tag of list) { + const base = String(tag || "").split("-")[0]; + if (base && LOCALES[base]) return base; + } + return ""; + } + + function supportedPaper(paper) { + return PAPER_SIZES[paper] ? paper : DEFAULT_PAPER; + } + + function getStoredPaper() { + try { + const stored = window.localStorage.getItem(PAPER_STORAGE_KEY); + return stored && PAPER_SIZES[stored] ? stored : ""; + } catch (_) { + return ""; + } + } + + // "By location" without geolocation: read the region subtag of the browser's + // locale list (the "GB" in "en-GB") and pick the paper that country prints on. + // Same signal we use for language, no permission prompt. Falls back to + // DEFAULT_PAPER only when no locale carries a region at all. + function preferredPaperSize() { + const list = (navigator.languages && navigator.languages.length) + ? navigator.languages + : [navigator.language || ""]; + for (const tag of list) { + const parts = String(tag || "").split("-"); + const region = parts.length > 1 ? parts[parts.length - 1].toUpperCase() : ""; + if (/^[A-Z]{2}$/.test(region)) { + return LETTER_REGIONS.has(region) ? "letter" : "a4"; + } + } + return DEFAULT_PAPER; + } + + function ensurePageSizeStyle() { + let style = document.getElementById("voxvera-page-size"); + if (!style) { + style = document.createElement("style"); + style.id = "voxvera-page-size"; + document.head.appendChild(style); + } + return style; + } + + // Paper size is a local print/view preference — it is never written into the + // Nostr event, so a flyer authored on Letter reprints on A4 for a European + // viewer without changing the source event. + function applyPaperSize(paper) { + const selected = supportedPaper(paper); + const root = document.documentElement; + root.classList.remove("paper-letter", "paper-a4"); + root.classList.add(`paper-${selected}`); + // @page size cannot read CSS custom properties reliably across engines, so + // drive it from an injected stylesheet that we rewrite on each change. + ensurePageSizeStyle().textContent = + `@media print { @page { size: ${PAPER_SIZES[selected].css} portrait; margin: 0; } }`; + try { + window.localStorage.setItem(PAPER_STORAGE_KEY, selected); + } catch (_) {} + PAPER_SELECT_IDS.forEach((id) => { + const sel = el(id); + if (sel) sel.value = selected; + }); + updatePreviewScale(); + } + + function populatePaperOptions() { + PAPER_SELECT_IDS.forEach((id) => { + const sel = el(id); + if (!sel || sel.options.length) return; + Object.keys(PAPER_SIZES).forEach((key) => { + const opt = document.createElement("option"); + opt.value = key; + sel.appendChild(opt); + }); + }); + } + function applyFlyerDefaults(lang) { const defaults = localeDefaults(lang); flyerSource = "default"; @@ -1934,7 +2090,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui } document.addEventListener("DOMContentLoaded", () => { - setDefaultText(getStoredUiLang() || navigator.language.split("-")[0]); + setDefaultText(); if (window.ResizeObserver) { new ResizeObserver(updatePreviewScale).observe(el("flyer-preview")); } else { @@ -1968,6 +2124,12 @@ Join us in a revolution that values truth and transparency. Together, we can bui select.addEventListener("change", () => applyLanguageChange(select.value || FALLBACK_LANG)); }); + PAPER_SELECT_IDS.forEach((selectId) => { + const select = el(selectId); + if (!select) return; + select.addEventListener("change", () => applyPaperSize(select.value || DEFAULT_PAPER)); + }); + TEXT_FIELD_IDS.forEach((fieldId) => { const field = el(fieldId); if (!field) return;