The UPG-100 poster list gets the per-row control, with an Auto option

Round 2 item 6. The control was already built in PosterWall -- state,
persistence, grid binding, markup -- and gated behind an opt-in prop that
only the regions dashboard passed. So most of this is enabling it rather
than writing it, which is what "reuse the pattern, don't re-derive" asked
for. I checked that before building anything, because the last time I
added a feature without checking which surface already had it, it landed
somewhere nobody looks.

Auto is genuinely new: the choices were 2 to 5, always forcing a count,
and the brief asks for Auto meaning today's CSS with overrides on top.
gridStyle already returned {} for a non-positive value -- the comment at
that line even says so -- so Auto needed no new layout branch. It needed
to become REACHABLE: a 0 in the choice list, a label, a read path and a
write path. It extends the earlier min-2/max-5/default-2 ruling rather
than contradicting it, since those bound the counts and Auto is the option
that declines to pick one.

Round-tripping the storage values, rather than reading the code, caught a
bug I had just written: getItem returns null when nothing is stored, and
Number(null) is 0 -- which is exactly Auto now that 0 means something. So
every first-time reader would have silently got Auto instead of the
default, and merely ADDING an option would have changed the layout for
everyone who never touched the control. A missing key now returns null and
falls back to the default; a stored 0 reads as Auto.

Auto is also persisted, which needed its own clause: the range check
rejects 0, so without it the one value that is not a count would be the
one value that never survived a reload. A preference that forgets only
some of its values is worse than one that forgets all of them, because the
reader cannot tell it is broken.
This commit is contained in:
Doxa Maps 2026-09-28 19:11:20 +00:00
parent 3adeca1474
commit 1717ab3016
13 changed files with 45 additions and 14 deletions

View file

@ -173,11 +173,23 @@ const props = defineProps({
// before the reader has said otherwise; once they choose, their choice is the default.
const perRow = ref(readStoredPerRow() ?? (props.columns || PER_ROW_DEFAULT))
watch(() => props.columns, (n) => { if (readStoredPerRow() == null) perRow.value = n || PER_ROW_DEFAULT })
watch(perRow, (n) => { if (Number.isFinite(n) && n >= PER_ROW_MIN && n <= PER_ROW_MAX) storePerRow(n) })
// ⚠ PER_ROW_AUTO (0) IS STORED TOO, so "Auto" survives a reload like any other choice. Without this
// clause the range check below rejects 0, nothing is written, and the next visit silently falls back
// to the default — a preference that forgets only ONE of its values is worse than one that forgets
// them all, because the reader cannot tell it is broken.
watch(perRow, (n) => {
if (n === PER_ROW_AUTO || (Number.isFinite(n) && n >= PER_ROW_MIN && n <= PER_ROW_MAX)) storePerRow(n)
})
// The choices the control offers. Two is the Driver's default; the ceiling is the wall's own
// auto-fill behaviour, offered as "fit" so the knob can always get back to where it started.
// Driver: "poster per row should have a minimum of 2 and a max of 5 and a default of 2".
const PER_ROW_CHOICES = [2, 3, 4, 5]
// ★ AUTO IS 0, AND 0 IS NOT A COUNT — it is the absence of an override. gridStyle already returns
// {} for a non-positive value, leaving the stylesheet's auto-fill rule in charge, so Auto needed no
// new branch in the layout: it needed to become REACHABLE from the control and from storage.
// This extends the earlier ruling (min 2, max 5, default 2) rather than contradicting it: those
// bound the COUNTS, and Auto is the option that declines to pick one.
const PER_ROW_AUTO = 0
const PER_ROW_CHOICES = [PER_ROW_AUTO, 2, 3, 4, 5]
const PER_ROW_MIN = 2, PER_ROW_MAX = 5, PER_ROW_DEFAULT = 2
// ...and "it needs to honour local storage so if you change the default number it is saved in local
// storage so you can reload and your default you set is persistent". One key for the whole wall,
@ -188,7 +200,14 @@ const PER_ROW_KEY = 'doxa-maps:poster-wall:per-row'
function readStoredPerRow() {
try {
const raw = window.localStorage.getItem(PER_ROW_KEY)
// ⚠ A MISSING KEY MUST NOT READ AS AUTO. getItem returns null when nothing is stored, and
// Number(null) is 0 — which is exactly PER_ROW_AUTO now that 0 means something. Without this
// guard every first-time reader would silently get Auto instead of the default, i.e. adding an
// option would have CHANGED the layout for everyone who never touched the control. Caught by
// round-tripping the storage values rather than by reading the code.
if (raw == null || raw === '') return null
const n = Number(raw)
if (n === PER_ROW_AUTO) return PER_ROW_AUTO
return Number.isFinite(n) && n >= PER_ROW_MIN && n <= PER_ROW_MAX ? n : null
} catch (_) { return null }
}
@ -1061,7 +1080,7 @@ useShadowStyles(
<div v-if="columnsControl && !isCollapsed(sec.name)" class="perrow">
<label class="perrow__label" :for="`perrow-${instanceId}-${sec.name}`">{{ t('buttons.postersPerRow') }}</label>
<select :id="`perrow-${instanceId}-${sec.name}`" class="perrow__select" v-model.number="perRow">
<option v-for="n in PER_ROW_CHOICES" :key="n" :value="n">{{ n }}</option>
<option v-for="n in PER_ROW_CHOICES" :key="n" :value="n">{{ n === PER_ROW_AUTO ? t('buttons.postersPerRowAuto') : n }}</option>
</select>
</div>
<div v-show="!isCollapsed(sec.name)" class="grid" :style="gridStyle">

View file

@ -46,5 +46,6 @@
"packetVariantAria": "تفاصيل حزمة الصلاة",
"packetFull": "تفاصيل كاملة",
"packetLightweight": "مختصرة",
"working": "جارٍ العمل…"
"working": "جارٍ العمل…",
"postersPerRowAuto": "تلقائي"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "Detailgrad des Gebetspakets",
"packetFull": "Vollständig",
"packetLightweight": "Kompakt",
"working": "Wird bearbeitet…"
"working": "Wird bearbeitet…",
"postersPerRowAuto": "Automatisch"
}

View file

@ -46,5 +46,6 @@
"clearSelection": "Clear selection",
"noOptions": "No options found",
"renderingLocal": "Rendering a print-quality image in your browser — nothing is uploaded.",
"postersPerRow": "Posters per row"
"postersPerRow": "Posters per row",
"postersPerRowAuto": "Auto"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "Detalle del paquete de oración",
"packetFull": "Detalle completo",
"packetLightweight": "Ligero",
"working": "Procesando…"
"working": "Procesando…",
"postersPerRowAuto": "Automático"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "Niveau de détail du dossier de prière",
"packetFull": "Détail complet",
"packetLightweight": "Allégé",
"working": "Traitement…"
"working": "Traitement…",
"postersPerRowAuto": "Auto"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "प्रार्थना पैकेट विवरण",
"packetFull": "पूर्ण विवरण",
"packetLightweight": "संक्षिप्त",
"working": "कार्य जारी…"
"working": "कार्य जारी…",
"postersPerRowAuto": "स्वतः"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "Dettaglio del pacchetto di preghiera",
"packetFull": "Dettaglio completo",
"packetLightweight": "Leggero",
"working": "Elaborazione…"
"working": "Elaborazione…",
"postersPerRowAuto": "Automatico"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "Detalhe do pacote de oração",
"packetFull": "Detalhe completo",
"packetLightweight": "Leve",
"working": "Processando…"
"working": "Processando…",
"postersPerRowAuto": "Automático"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "Nivelul de detaliu al pachetului de rugăciune",
"packetFull": "Detaliu complet",
"packetLightweight": "Redus",
"working": "Se procesează…"
"working": "Se procesează…",
"postersPerRowAuto": "Automat"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "Детализация молитвенного пакета",
"packetFull": "Полная версия",
"packetLightweight": "Краткая версия",
"working": "Выполняется…"
"working": "Выполняется…",
"postersPerRowAuto": "Авто"
}

View file

@ -46,5 +46,6 @@
"packetVariantAria": "祷告手册详细程度",
"packetFull": "完整版",
"packetLightweight": "精简版",
"working": "处理中…"
"working": "处理中…",
"postersPerRowAuto": "自动"
}

View file

@ -293,5 +293,6 @@ const wallTag = computed(() => t('messages.upg100WallTag'))
preview-action="print"
:hide-prayer-list-when-soon="true"
download-prefix="doxa-upg-100"
:columns-control="true"
/>
</template>