diff --git a/docs/nostr-static-client-spec.md b/docs/nostr-static-client-spec.md
index 3adb360..c70da3d 100644
--- a/docs/nostr-static-client-spec.md
+++ b/docs/nostr-static-client-spec.md
@@ -100,6 +100,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.
+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.
+
Supported values:
- `flyer_url`: tear-off QR points to the hosted client URL for the poster
@@ -135,6 +137,27 @@ Language changes should:
- update printed side-tab labels
- respect RTL direction metadata
+### Letter-spacing by script
+
+Flyer text uses tighter letter-spacing than the original design to fit more
+characters per line, but `letter-spacing` is not script-neutral, so it is
+applied per script via a policy class on the flyer `.container`
+(`LETTER_SPACING_POLICY` in `nostr-client.js`; `ls-full` / `ls-cjk` / `ls-none`
+rules and `--ls-*` variables in `nostr-client.css`):
+
+- `full` — Latin, Cyrillic, Hebrew: reduced positive tracking on the display
+ fields plus a slight negative on body/URL to reclaim line width.
+- `cjk` — Japanese, Chinese: tracking is clamped to `0` and never negative, so
+ full-width glyphs do not overlap.
+- `none` — Arabic, Persian, Hindi: letter-spacing is turned off entirely. These
+ are cursive or complex scripts where any tracking breaks the letter joins or
+ detaches combining marks. (This also corrected the prior behavior, which
+ applied the design's display tracking to every language unconditionally.)
+
+When adding a language, classify its script in `LETTER_SPACING_POLICY` (default
+is `full`) and verify the headline, subtitle, and body render correctly in the
+browser.
+
## Verification
```bash
diff --git a/site/nostr/index.html b/site/nostr/index.html
index 86e896d..24108b3 100644
--- a/site/nostr/index.html
+++ b/site/nostr/index.html
@@ -104,8 +104,6 @@
Footer message
-
-
diff --git a/site/nostr/nostr-client.css b/site/nostr/nostr-client.css
index f009078..6696623 100644
--- a/site/nostr/nostr-client.css
+++ b/site/nostr/nostr-client.css
@@ -258,8 +258,8 @@ code {
color: var(--muted);
}
-.field-fit-status {
- margin: 0 0 10px;
+.field-error {
+ margin: 0;
color: var(--warn);
font-size: 12px;
font-weight: 700;
@@ -382,6 +382,41 @@ a {
box-sizing: border-box;
overflow: hidden;
page-break-inside: avoid;
+ /* Per-script letter-spacing, set by the ls-* policy class (see
+ LETTER_SPACING_POLICY in nostr-client.js). Defaults below match the
+ historical design and act as a safe fallback if no policy class is set. */
+ --ls-display: 2px;
+ --ls-subtitle: 6px;
+ --ls-body: 0;
+ --ls-url: 0;
+ --ls-footer: 0;
+}
+
+/* Latin, Cyrillic, Hebrew: tighten tracking to reclaim line space. */
+.container.ls-full {
+ --ls-display: 0.5px;
+ --ls-subtitle: 2px;
+ --ls-body: -0.3px;
+ --ls-url: -0.3px;
+ --ls-footer: 0;
+}
+
+/* CJK: tracking is safe but must never go negative (glyphs would overlap). */
+.container.ls-cjk {
+ --ls-display: 0;
+ --ls-subtitle: 0;
+ --ls-body: 0;
+ --ls-url: 0;
+ --ls-footer: 0;
+}
+
+/* Arabic, Persian, Hindi: letter-spacing breaks shaping — turn it off. */
+.container.ls-none {
+ --ls-display: normal;
+ --ls-subtitle: normal;
+ --ls-body: normal;
+ --ls-url: normal;
+ --ls-footer: normal;
}
.container.no-tear-offs .left-tear-offs {
@@ -410,6 +445,26 @@ a {
overflow-wrap: anywhere;
word-break: normal;
hyphens: none;
+ display: flex;
+ flex-direction: column;
+}
+
+.tear-off-event-id {
+ margin-top: auto;
+ padding-top: 4px;
+ font-size: 8px;
+ line-height: 1.2;
+ word-break: break-all;
+ overflow-wrap: anywhere;
+}
+
+.tear-off-event-id-value {
+ display: block;
+ font-family: "Courier New", monospace;
+ font-size: 12px;
+ line-height: 1.1;
+ letter-spacing: -0.2px;
+ word-break: break-all;
}
.qr-code {
@@ -463,7 +518,7 @@ a {
padding: 0;
font-size: 24px;
line-height: 1.2;
- letter-spacing: 2px;
+ letter-spacing: var(--ls-display);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -473,7 +528,7 @@ a {
.distribute {
font-size: 18px;
line-height: 1.2;
- letter-spacing: 6px;
+ letter-spacing: var(--ls-subtitle);
margin: 2px 0;
padding: 0;
white-space: nowrap;
@@ -487,6 +542,7 @@ a {
margin-top: 4px;
font-size: 14px;
line-height: 1.4;
+ letter-spacing: var(--ls-body);
text-align: left;
overflow: hidden;
display: flex;
@@ -516,6 +572,7 @@ a {
.qr-code-url {
font-size: 12px;
+ letter-spacing: var(--ls-url);
text-align: left;
overflow-wrap: anywhere;
word-break: normal;
@@ -551,6 +608,7 @@ hr {
.container .footer .binary {
margin-bottom: 8px;
margin-top: 10px;
+ letter-spacing: var(--ls-footer);
}
.container .footer .credit {
diff --git a/site/nostr/nostr-client.js b/site/nostr/nostr-client.js
index f2cc589..bcf8194 100644
--- a/site/nostr/nostr-client.js
+++ b/site/nostr/nostr-client.js
@@ -12,6 +12,23 @@
const FALLBACK_LANG = "en";
const LANGUAGE_SELECT_IDS = ["topbar-lang", "field-lang", "viewer-lang"];
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:
+ // "full" (Latin, Cyrillic, Hebrew): reduced positive tracking + slight
+ // negative on body/url to reclaim space per line.
+ // "cjk" (Japanese, Chinese): tracking clamped to 0 — full-width glyphs
+ // must not overlap, so never negative.
+ // "none" (Arabic, Persian, Hindi): letter-spacing OFF. These are cursive
+ // or complex scripts where any tracking breaks the letter joins or
+ // detaches combining marks; neutralizing it also fixes rendering.
+ // The values themselves live in nostr-client.css, keyed by the ls-* class.
+ const LETTER_SPACING_POLICY = {
+ ar: "none", fa: "none", hi: "none",
+ ja: "cjk", zh: "cjk"
+ };
+ function trackingPolicy(lang) {
+ return LETTER_SPACING_POLICY[supportedLang(lang)] || "full";
+ }
const FIELD_LIMITS = {
folder_name: 64,
name: 120,
@@ -119,15 +136,6 @@ Join us in a revolution that values truth and transparency. Together, we can bui
"field-url": ".qr-code-url a",
"field-footer-message": ".footer .binary"
};
- const DISPLAY_FIT_LABEL_KEYS = {
- "field-title": "labels.title",
- "field-subtitle": "labels.subtitle",
- "field-headline": "labels.headline",
- "field-content": "labels.content",
- "field-url-message": "labels.url_message",
- "field-url": "labels.poster_url",
- "field-footer-message": "labels.footer_message"
- };
const NOSTR_UI = {
ar: {
back: "رجوع",
@@ -141,7 +149,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "اللغة",
load_event: "تحميل الحدث",
normalized_config: "الإعداد الموحد",
- open_poster: "افتح هذا الملصق:",
+ open_poster: "انقر لفتح هذا الملصق.",
page_title: "عنوان الصفحة",
preview: "معاينة",
print: "اطبع المنشور",
@@ -193,7 +201,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "Sprache",
load_event: "Event laden",
normalized_config: "Normalisierte Konfiguration",
- open_poster: "Dieses Plakat öffnen:",
+ open_poster: "Klicken Sie, um dieses Plakat zu öffnen.",
page_title: "Seitentitel",
preview: "Vorschau",
print: "Flyer drucken",
@@ -245,7 +253,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "Language",
load_event: "Load Event",
normalized_config: "Normalized Config",
- open_poster: "Open this poster:",
+ open_poster: "Click to open this poster.",
page_title: "Page title",
preview: "Preview",
print: "Print this page",
@@ -298,7 +306,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "Idioma",
load_event: "Cargar evento",
normalized_config: "Configuración normalizada",
- open_poster: "Abra este cartel:",
+ open_poster: "Haga clic para abrir este cartel.",
page_title: "Título de la página",
preview: "Vista previa",
print: "Imprimir volante",
@@ -350,7 +358,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "زبان",
load_event: "بارگذاری رویداد",
normalized_config: "پیکربندی عادیشده",
- open_poster: "این پوستر را باز کنید:",
+ open_poster: "برای باز کردن این پوستر کلیک کنید.",
page_title: "عنوان صفحه",
preview: "پیشنمایش",
print: "چاپ اعلامیه",
@@ -402,7 +410,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "Langue",
load_event: "Charger l'evenement",
normalized_config: "Configuration normalisee",
- open_poster: "Ouvrez cette affiche :",
+ open_poster: "Cliquez pour ouvrir cette affiche.",
page_title: "Titre de page",
preview: "Apercu",
print: "Imprimer le flyer",
@@ -454,7 +462,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "שפה",
load_event: "טען אירוע",
normalized_config: "תצורה מנורמלת",
- open_poster: "פתח את הכרזה הזו:",
+ open_poster: "לחץ כדי לפתוח כרזה זו.",
page_title: "כותרת עמוד",
preview: "תצוגה מקדימה",
print: "הדפס פלייר",
@@ -506,7 +514,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "भाषा",
load_event: "इवेंट लोड करें",
normalized_config: "सामान्यीकृत कॉन्फिग",
- open_poster: "यह पोस्टर खोलें:",
+ open_poster: "इस पोस्टर को खोलने के लिए क्लिक करें।",
page_title: "पृष्ठ शीर्षक",
preview: "पूर्वावलोकन",
print: "फ्लायर प्रिंट करें",
@@ -558,7 +566,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "言語",
load_event: "イベントを読み込む",
normalized_config: "正規化設定",
- open_poster: "このポスターを開く:",
+ open_poster: "クリックしてこのポスターを開く。",
page_title: "ページタイトル",
preview: "プレビュー",
print: "フライヤーを印刷",
@@ -610,7 +618,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "Idioma",
load_event: "Carregar evento",
normalized_config: "Configuracao normalizada",
- open_poster: "Abra este cartaz:",
+ open_poster: "Clique para abrir este cartaz.",
page_title: "Titulo da pagina",
preview: "Previa",
print: "Imprimir Panfleto",
@@ -662,7 +670,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "Язык",
load_event: "Загрузить событие",
normalized_config: "Нормализованная конфигурация",
- open_poster: "Откройте этот плакат:",
+ open_poster: "Нажмите, чтобы открыть этот плакат.",
page_title: "Заголовок страницы",
preview: "Предпросмотр",
print: "Печать листовки",
@@ -714,7 +722,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "Lugha",
load_event: "Pakia tukio",
normalized_config: "Usanidi uliorekebishwa",
- open_poster: "Fungua bango hili:",
+ open_poster: "Bofya kufungua bango hili.",
page_title: "Kichwa cha ukurasa",
preview: "Hakiki",
print: "Chapa Kipeperushi",
@@ -766,7 +774,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "Dil",
load_event: "Olay yükle",
normalized_config: "Normalleştirilmiş yapılandırma",
- open_poster: "Bu posteri açın:",
+ open_poster: "Bu posteri açmak için tıklayın.",
page_title: "Sayfa başlığı",
preview: "Önizleme",
print: "İlanı Yazdır",
@@ -818,7 +826,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
language: "语言",
load_event: "加载事件",
normalized_config: "标准化配置",
- open_poster: "打开此海报:",
+ open_poster: "点击打开此海报。",
page_title: "页面标题",
preview: "预览",
print: "打印传单",
@@ -861,6 +869,7 @@ Join us in a revolution that values truth and transparency. Together, we can bui
};
let flyerSource = "default";
let loadedEventConfig = null;
+ let loadedEventId = "";
const lastAcceptedFieldValues = {};
const el = (id) => document.getElementById(id);
@@ -918,18 +927,46 @@ Join us in a revolution that values truth and transparency. Together, we can bui
return Boolean(node && node.dataset.emptyKey);
}
- function fieldLabel(fieldId, lang = currentUiLang()) {
- const localePath = DISPLAY_FIT_LABEL_KEYS[fieldId];
- if (localePath) return translate(localePath, lang) || fieldId;
+ // The "too long" warning is shown inline under the offending field (so the
+ // issue is visible without scrolling to a single shared status line). The
+ // message element is created lazily inside the field's