From f172312cab9b95f264206230f2ad3c2a5d66c67d Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 18:40:31 +0000 Subject: [PATCH] Put the UPG 100 choice on the surface that actually shows it The Prayer-list dropdown went into PosterWall's per-card print row. The profile people open -- upg-100-list -- does not render PosterWall at all; its print path is the legend's Export menu, which still offered two items and printed immediately. So the feature was built, landed and served, and invisible on the only screen it was asked for. Two surfaces legitimately offer printing and both carry a button labelled Prayer list, so the label was not enough to tell them apart. The grep that would have caught it -- does this profile mount that component -- takes five seconds and I never ran it. The legend's packet item is now a disclosure: it is named UPG 100, it explains what a UPG 100 list is, and it reveals Full detail and Lightweight. The explainer matters because this menu item is the only place a reader meets the idea, so it wraps rather than truncates. The submenu opens BELOW its trigger by construction, not by positioning: the menu is a flex column, so a block after the button flows underneath it. A panel that grows its parent cannot cover its parent -- which is exactly what the card-deck row could do, hanging out of its card at top:100% with nowhere to go on a short viewport. emitExport widens its existing string to packet:full / packet:lightweight rather than adding a second event; poster and a bare packet still work, so an older consumer keeps working and one event means nobody can handle only half the menu. printPrayerPacket takes the variant and forwards it to printPacket, which already accepts it. UPG 100 is allowed to be identical in all eleven locales, recorded in check:i18n-never-translated as a brand name rather than as debt: it is not waiting on a ruling, it IS the ruling, and the explainer beside it carries the meaning per locale. Inventing eleven names for one product would be the failure that gate exists to prevent. --- internal/check-i18n-never-translated.mjs | 8 +++ .../components/legends/SemanticTreeLegend.vue | 61 +++++++++++++++++-- library/i18n/locales/ar/legend.json | 6 +- library/i18n/locales/de/legend.json | 6 +- library/i18n/locales/en/legend.json | 6 +- library/i18n/locales/es/legend.json | 6 +- library/i18n/locales/fr/legend.json | 6 +- library/i18n/locales/hi/legend.json | 6 +- library/i18n/locales/it/legend.json | 6 +- library/i18n/locales/pt/legend.json | 6 +- library/i18n/locales/ro/legend.json | 6 +- library/i18n/locales/ru/legend.json | 6 +- library/i18n/locales/zh/legend.json | 6 +- .../my-upg-100-list/upg-100-list.vue | 15 ++++- 14 files changed, 132 insertions(+), 18 deletions(-) diff --git a/internal/check-i18n-never-translated.mjs b/internal/check-i18n-never-translated.mjs index 98dccf6b5..4e3619995 100644 --- a/internal/check-i18n-never-translated.mjs +++ b/internal/check-i18n-never-translated.mjs @@ -81,6 +81,14 @@ const ALLOWED = new Map([ 'DRIVER-GATED: needs the field\'s term per locale, not an invented acronym — see DECIDE-UUPGS-COLUMN-HEADER'], ['prayerLaps.json:upgs', 'DRIVER-GATED: same term as legend.json:columns.upgs — see DECIDE-UUPGS-COLUMN-HEADER'], + // ⚠ SETTLED, NOT A BOOKMARK — unlike the two above, this one is not waiting on a ruling; it IS + // the ruling. "UPG 100" is the product's chosen NAME for the prayer list, so it is a brand + // designation and correctly identical in every locale, exactly like a version number. The + // surrounding copy carries the meaning in each language (export.packetExplain is translated per + // locale and explains what a UPG 100 list is), so a reader does not meet a bare acronym with + // nothing to anchor it. Do not "resolve" this entry by inventing eleven names for one product. + ['legend.json:export.packet', + 'BRAND: the product name for the prayer list; export.packetExplain carries the meaning per locale'], ]) // Below this many locales the comparison is not meaningful — a key present in two files being the diff --git a/library/components/legends/SemanticTreeLegend.vue b/library/components/legends/SemanticTreeLegend.vue index fc0439034..f5f64b4f4 100644 --- a/library/components/legends/SemanticTreeLegend.vue +++ b/library/components/legends/SemanticTreeLegend.vue @@ -221,6 +221,10 @@ const localSelected = ref(null) const panelOpen = ref(props.open !== null ? props.open : true) const showInfoForTab = ref(null) const exportMenuOpen = ref(false) // top-right Export dropdown (behind SHOW_EXPORT_BUTTON) +// Whether the UPG-100 item has revealed its two variants. Separate from exportMenuOpen so closing +// the whole menu also collapses the submenu — see the watch below; a submenu left open would +// re-appear expanded the next time the reader opens Export, having forgotten why it was open. +const packetOpen = ref(false) const { scrimRef, scrimStyle } = useViewportScrim(exportMenuOpen) // Feature flag: show the Export affordance. Gated here in the SHARED legend so // one switch covers every bundle. It was false by an earlier coder decision; @@ -562,15 +566,21 @@ function toggleExportMenu(e) { e?.stopPropagation() exportMenuOpen.value = !exportMenuOpen.value } -// Fire the chosen export kind ('poster' | 'packet') up to the profile, then -// close the menu. The profile renders + downloads the PDF. +// Fire the chosen export kind up to the profile, then close the menu. The profile does the render. +// ⚠ THE PACKET KINDS NOW CARRY THEIR VARIANT — 'packet:full' / 'packet:lightweight' — while 'poster' +// and a bare 'packet' are unchanged, so an older consumer that only understands 'poster' | 'packet' +// keeps working and a new one can split on the colon. Widening the EXISTING string beats adding a +// second emit: one event means a consumer cannot handle half the menu. function emitExport(kind) { exportMenuOpen.value = false + packetOpen.value = false emit('export', kind) } // The Export button only exists while a row is selected; if the selection is // cleared (deselect / tab switch / nodes change) the menu must not linger. -watch(selectedId, id => { if (!id) exportMenuOpen.value = false }) +watch(selectedId, id => { if (!id) { exportMenuOpen.value = false; packetOpen.value = false } }) +// Closing the menu collapses the submenu, so it never re-opens pre-expanded. +watch(exportMenuOpen, open => { if (!open) packetOpen.value = false }) // Bring the selected row into view when the selection changes (e.g. a People-Group // search result highlights a row that may be scrolled off-screen). @@ -1110,6 +1120,27 @@ useShadowStyles(` font: 600 11px system-ui, sans-serif; } .stl-export-item[disabled] { opacity: 0.55; } +/* ★ THE SUBMENU FLOWS BELOW ITS TRIGGER, IT IS NOT POSITIONED OVER IT. .stl-export-menu is a flex + COLUMN, so a block placed after the packet button sits underneath it by document order — no + absolute positioning, nothing to overlap. This matters because the equivalent disclosure on the + card deck did cover its own label on mobile: that one hangs at top:100% out of its card, where a + short viewport leaves it nowhere to go. A panel that grows its parent cannot cover its parent. */ +.stl-export-caret { flex-shrink: 0; font-size: 9px; opacity: 0.7; transition: transform 0.15s; } +.stl-export-caret.is-open { transform: rotate(180deg); } +.stl-export-sub { + display: flex; flex-direction: column; gap: 2px; + margin: 2px 0 2px 6px; padding-inline-start: 6px; + border-inline-start: 1px solid #30363d; /* inline-start, so it mirrors under RTL */ +} +/* The explainer is the only place a reader meets the idea, so it wraps rather than truncating — + a clipped definition is worse than none. */ +.stl-export-note { + margin: 2px 0 4px; padding: 0 2px; + font: 400 10px/1.45 system-ui, sans-serif; color: #8b949e; white-space: normal; +} +.stl-export-subitem { font-weight: 500; padding-block: 5px; } +.stl-panel[data-theme="light"] .stl-export-sub { border-inline-start-color: #d0d7de; } +.stl-panel[data-theme="light"] .stl-export-note { color: #57606a; } .stl-export-lock { color: #6e7681; flex-shrink: 0; } .stl-export-item-label { flex: 1; min-width: 0; } .stl-export-soon { @@ -1240,14 +1271,36 @@ useShadowStyles(` {{ t('legend.export.poster') }} - +
+

{{ t('legend.export.packetExplain') }}

+ + +
diff --git a/library/i18n/locales/ar/legend.json b/library/i18n/locales/ar/legend.json index 3d4fe4ae8..fc1a834c5 100644 --- a/library/i18n/locales/ar/legend.json +++ b/library/i18n/locales/ar/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "ملصق", - "packet": "كُتيّب" + "packet": "UPG 100", + "packetExplain": "قائمة صلاة من نحو ٥٠ إلى ١٠٠ شعب غير مبلَّغ — يستطيع شخص واحد أن يحمل واحدة.", + "packetAria": "تفاصيل حزمة الصلاة", + "packetFull": "تفاصيل كاملة", + "packetLightweight": "مختصرة" }, "noDataAtThisLevel": "لا توجد بيانات في هذا المستوى", "exportButton": "تصدير", diff --git a/library/i18n/locales/de/legend.json b/library/i18n/locales/de/legend.json index 68db51d17..541e0cea0 100644 --- a/library/i18n/locales/de/legend.json +++ b/library/i18n/locales/de/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "Plakat", - "packet": "Heft" + "packet": "UPG 100", + "packetExplain": "Eine Gebetsliste von etwa 50 bis 100 unerreichten Volksgruppen — eine Person kann eine tragen.", + "packetAria": "Detailgrad des Gebetspakets", + "packetFull": "Vollständig", + "packetLightweight": "Kompakt" }, "noDataAtThisLevel": "Keine Daten auf dieser Ebene", "exportButton": "Exportieren", diff --git a/library/i18n/locales/en/legend.json b/library/i18n/locales/en/legend.json index b7ab49362..9568142aa 100644 --- a/library/i18n/locales/en/legend.json +++ b/library/i18n/locales/en/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "Poster", - "packet": "Packet" + "packet": "UPG 100", + "packetExplain": "A prayer list of about 50 to 100 unreached people groups — one person can carry one.", + "packetAria": "Prayer packet detail", + "packetFull": "Full detail", + "packetLightweight": "Lightweight" }, "noDataAtThisLevel": "No data at this level", "exportButton": "Export", diff --git a/library/i18n/locales/es/legend.json b/library/i18n/locales/es/legend.json index 782350045..f97f21aea 100644 --- a/library/i18n/locales/es/legend.json +++ b/library/i18n/locales/es/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "Cartel", - "packet": "Cuadernillo" + "packet": "UPG 100", + "packetExplain": "Una lista de oración de unos 50 a 100 pueblos no alcanzados: una persona puede llevar una.", + "packetAria": "Detalle del paquete de oración", + "packetFull": "Detalle completo", + "packetLightweight": "Ligero" }, "noDataAtThisLevel": "No hay datos en este nivel", "exportButton": "Exportar", diff --git a/library/i18n/locales/fr/legend.json b/library/i18n/locales/fr/legend.json index 12b18990f..0a346d941 100644 --- a/library/i18n/locales/fr/legend.json +++ b/library/i18n/locales/fr/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "Affiche", - "packet": "Livret" + "packet": "UPG 100", + "packetExplain": "Une liste de prière d’environ 50 à 100 peuples non atteints : une personne peut en porter une.", + "packetAria": "Niveau de détail du dossier de prière", + "packetFull": "Détail complet", + "packetLightweight": "Allégé" }, "noDataAtThisLevel": "Aucune donnée à ce niveau", "exportButton": "Exporter", diff --git a/library/i18n/locales/hi/legend.json b/library/i18n/locales/hi/legend.json index 320996f35..85229349e 100644 --- a/library/i18n/locales/hi/legend.json +++ b/library/i18n/locales/hi/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "पोस्टर", - "packet": "पुस्तिका" + "packet": "UPG 100", + "packetExplain": "लगभग 50 से 100 अनपहुँचे जनसमूहों की एक प्रार्थना सूची — एक व्यक्ति एक ले सकता है।", + "packetAria": "प्रार्थना पैकेट विवरण", + "packetFull": "पूर्ण विवरण", + "packetLightweight": "संक्षिप्त" }, "noDataAtThisLevel": "इस स्तर पर कोई डेटा नहीं", "exportButton": "निर्यात", diff --git a/library/i18n/locales/it/legend.json b/library/i18n/locales/it/legend.json index 2f2435021..3b78f1779 100644 --- a/library/i18n/locales/it/legend.json +++ b/library/i18n/locales/it/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "Manifesto", - "packet": "Libretto" + "packet": "UPG 100", + "packetExplain": "Una lista di preghiera di circa 50-100 popoli non raggiunti: una persona può portarne una.", + "packetAria": "Dettaglio del pacchetto di preghiera", + "packetFull": "Dettaglio completo", + "packetLightweight": "Leggero" }, "noDataAtThisLevel": "Nessun dato a questo livello", "exportButton": "Esporta", diff --git a/library/i18n/locales/pt/legend.json b/library/i18n/locales/pt/legend.json index 8538bda32..73cdac70d 100644 --- a/library/i18n/locales/pt/legend.json +++ b/library/i18n/locales/pt/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "Cartaz", - "packet": "Caderno" + "packet": "UPG 100", + "packetExplain": "Uma lista de oração de cerca de 50 a 100 povos não alcançados: uma pessoa pode levar uma.", + "packetAria": "Detalhe do pacote de oração", + "packetFull": "Detalhe completo", + "packetLightweight": "Leve" }, "noDataAtThisLevel": "Sem dados neste nível", "exportButton": "Exportar", diff --git a/library/i18n/locales/ro/legend.json b/library/i18n/locales/ro/legend.json index 93509fa97..f38af783a 100644 --- a/library/i18n/locales/ro/legend.json +++ b/library/i18n/locales/ro/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "Afiș", - "packet": "Broșură" + "packet": "UPG 100", + "packetExplain": "O listă de rugăciune de aproximativ 50-100 de popoare neatinse — o persoană poate purta una.", + "packetAria": "Nivelul de detaliu al pachetului de rugăciune", + "packetFull": "Detaliu complet", + "packetLightweight": "Redus" }, "noDataAtThisLevel": "Nu există date la acest nivel", "exportButton": "Exportă", diff --git a/library/i18n/locales/ru/legend.json b/library/i18n/locales/ru/legend.json index 6d67c10ef..312daa30a 100644 --- a/library/i18n/locales/ru/legend.json +++ b/library/i18n/locales/ru/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "Плакат", - "packet": "Буклет" + "packet": "UPG 100", + "packetExplain": "Молитвенный список примерно из 50–100 неохваченных народов — один человек может взять один.", + "packetAria": "Детализация молитвенного пакета", + "packetFull": "Полная версия", + "packetLightweight": "Краткая версия" }, "noDataAtThisLevel": "На этом уровне нет данных", "exportButton": "Экспорт", diff --git a/library/i18n/locales/zh/legend.json b/library/i18n/locales/zh/legend.json index 98a08c22b..26e2f8afb 100644 --- a/library/i18n/locales/zh/legend.json +++ b/library/i18n/locales/zh/legend.json @@ -70,7 +70,11 @@ }, "export": { "poster": "海报", - "packet": "祷告册" + "packet": "UPG 100", + "packetExplain": "约 50 到 100 个未得之民的祷告名单——一个人就能领一份。", + "packetAria": "祷告手册详细程度", + "packetFull": "完整版", + "packetLightweight": "精简版" }, "noDataAtThisLevel": "此层级没有数据", "exportButton": "导出", diff --git a/plugin-designs/my-upg-100-list/upg-100-list.vue b/plugin-designs/my-upg-100-list/upg-100-list.vue index 154077bfe..d44dad290 100644 --- a/plugin-designs/my-upg-100-list/upg-100-list.vue +++ b/plugin-designs/my-upg-100-list/upg-100-list.vue @@ -1315,13 +1315,18 @@ provide('onLegendExport', onLegendExport) async function onLegendExport(kind) { const zoneId = selectedKey.value; if (!zoneId) return; // the button only shows with a selection + // ★ THE KIND NOW CARRIES ITS VARIANT: 'packet:full' | 'packet:lightweight'. A bare 'packet' still + // works and still means the full packet, so nothing that emits the old string breaks — the legend + // WIDENED its existing event rather than adding a second one, because two events would let a + // consumer handle half the menu and look fine. + const [base, variant] = String(kind).split(':'); // Guard re-entry the way the HUD does: a second click while a print is building would run two // renders over one print frame. if (batchBusy.value) return; batchBusy.value = true; - busyAction.value = kind === 'packet' ? 'packet' : 'poster'; + busyAction.value = base === 'packet' ? 'packet' : 'poster'; try { - if (kind === 'packet') { await printPrayerPacket(zoneId); return; } + if (base === 'packet') { await printPrayerPacket(zoneId, variant || 'full'); return; } // The legend selects by KEY, while the print actions label by item — hand over the zone // object so the print dialog's title says the list's name rather than its slug. // Same resolution printPrayerPacket uses, so both print actions name the list identically. @@ -1435,7 +1440,10 @@ function flashRef(target, text, ms) { } const flashStatus = (text, ms) => flashRef(status, text, ms); -async function printPrayerPacket(zoneId) { +// `variant` selects WHICH packet — 'full' (map, life calendar, %-evangelical grid, triangle, +// profiles, sets, points, wheel) or 'lightweight' (map, triangle, profiles, points, wheel). +// Defaults to 'full', so every caller that does not pass one prints exactly what it printed before. +async function printPrayerPacket(zoneId, variant = 'full') { const zone = allLists().find((z) => z.id === zoneId); if (!zone) return; // The map svg needs the renderer's own mapCss/W, so it is resolved here and @@ -1447,6 +1455,7 @@ async function printPrayerPacket(zoneId) { ]); const R = createPacketRenderer({ strings, lang: packetLang() }); await packetPrints.printPacket(zone, { + variant, mapSvg: await packetMapSvg(zoneId, R), funnel: await packetFunnel(), });