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') }}
-