library/ keeps the reason and drops the speaker: no comment quotes or cites the Driver

Every reference to the Driver in library/ is reworded: the engineering reason
stays, the speaker and the quote go, and a decision still open reads
"provisional, pending a product decision". Comments only: each changed file
is identical after stripping comments (JavaScript, template and CSS, including
CSS comments inside the style strings shadow-DOM components inject).

Those style-string comments do ship, so several bundles change by exactly
those comment bytes.
This commit is contained in:
Doxa Maps 2026-09-29 04:28:47 +00:00
parent 474b0b4e8a
commit bfc99ff827
59 changed files with 320 additions and 357 deletions

View file

@ -92,7 +92,7 @@ const profileName = computed(() => config.value?.profile || null)
// prop is genuinely absent: profile-config arrives with keys profile/dataSource/instanceId/ // prop is genuinely absent: profile-config arrives with keys profile/dataSource/instanceId/
// assetsBase and NO tk. This computed then produced '' and provided it to every child, so a map // assetsBase and NO tk. This computed then produced '' and provided it to every child, so a map
// nested INSIDE the composite got an empty accessToken and logged // nested INSIDE the composite got an empty accessToken and logged
// "No Mapbox access token provided" — the error the Driver saw at dashboard-plus-grid.js:605. // "No Mapbox access token provided" — the error reported at dashboard-plus-grid.js:605.
// //
// The generator's assumption was that a composite's nested maps each render in their OWN IFRAME // The generator's assumption was that a composite's nested maps each render in their OWN IFRAME
// LEAF running its own token ladder. That is false for dashboard-plus-grid: it has zero iframes // LEAF running its own token ladder. That is false for dashboard-plus-grid: it has zero iframes
@ -123,8 +123,8 @@ const mapboxToken = computed(() => {
}) })
// SHARE WHAT THIS ELEMENT WAS GIVEN. An element handed a real `tk` publishes it page-wide, so a // SHARE WHAT THIS ELEMENT WAS GIVEN. An element handed a real `tk` publishes it page-wide, so a
// SIBLING or NESTED element that was handed none stops starting from nothing — the Driver's point // SIBLING or NESTED element that was handed none stops starting from nothing: a token belongs to
// that a token belongs to the page, not to whichever component happened to receive it. Publishing // the page, not to whichever component happened to receive it. Publishing
// is idempotent and ignores empties, so this cannot clobber a good token with a blank one. // is idempotent and ignores empties, so this cannot clobber a good token with a blank one.
watch(() => config.value?.tk, (tk) => { if (typeof tk === 'string' && tk) publishMapToken(tk) }, { immediate: true }) watch(() => config.value?.tk, (tk) => { if (typeof tk === 'string' && tk) publishMapToken(tk) }, { immediate: true })
// A token-SERVICE URL in the profile-config (tokenUrl) is asked only when no token is at hand; its answer // A token-SERVICE URL in the profile-config (tokenUrl) is asked only when no token is at hand; its answer

View file

@ -1,11 +1,10 @@
/** /**
* WAGF region color strategy — colors pins by the region they belong to. * WAGF region color strategy — colors pins by the region they belong to.
* *
* ★ THE VALUES ARE THE RESEARCH MAP'S, ON THE DRIVER'S INSTRUCTION (2026-09-24). He compared the * ★ THE VALUES ARE THE RESEARCH MAP'S (2026-09-24). The Simple Research map's Regions tab and the
* two surfaces himself: "when i look on teh color ffor th regiosn tab of the simpel research map i * regions dashboard painted the same regions in different colours. Two surfaces disagreeing about
* don tsee the you using htes colors on the reigosn dashbarod hwen you shoudl". Two surfaces * what Africa's colour means is a product defect whichever palette is prettier, and the research
* disagreeing about what Africa's colour means is a product defect whichever palette is prettier, * map's palette was chosen to win. Source of truth:
* and he chose which one wins. Source of truth:
* library/colors/doxa-region.js (it lived at plugin-designs/doxa-research-map/src/colors/ until C1a), which the research dashboard and * library/colors/doxa-region.js (it lived at plugin-designs/doxa-research-map/src/colors/ until C1a), which the research dashboard and
* three other consumers already paint from. * three other consumers already paint from.
* *
@ -25,10 +24,10 @@
* seven regions as a TABLE with names and figures. Identity never rests on hue alone on either. * seven regions as a TABLE with names and figures. Identity never rests on hue alone on either.
* The LIGHTNESS FAIL is the part with no relief: three of these read faintly as PINS on a light * The LIGHTNESS FAIL is the part with no relief: three of these read faintly as PINS on a light
* basemap. Darkening those three by ~10% L would enter the band while keeping the hue * basemap. Darkening those three by ~10% L would enter the band while keeping the hue
* recognisable, and that is the Driver's call to make, not mine to make quietly — asked, not done. * recognisable. That is provisional, pending a product decision — asked, not done quietly.
* *
* WHY THIS EXISTS. The regions element's no-selection state is a CHOOSER: the Driver's brief was * WHY THIS EXISTS. The regions element's no-selection state is a CHOOSER: a map that shows the
* "some type of map that would show the regions and countries and let you select one". For the * regions and countries and lets you select one. For the
* seven regions to read AS regions the moment the map loads, the pins have to be colored by * seven regions to read AS regions the moment the map loads, the pins have to be colored by
* region — which is a categorical encoding no existing strategy provides. (`_registry.js` and * region — which is a categorical encoding no existing strategy provides. (`_registry.js` and
* `religion.js` both mention a `doxa-region.js`; it does not exist in this tree.) * `religion.js` both mention a `doxa-region.js`; it does not exist in this tree.)
@ -66,7 +65,7 @@ export const PROPERTY_KEY = 'region'
// DX-life --color-brand-lighter: an unknown region reads as chrome, not as an eighth region. // DX-life --color-brand-lighter: an unknown region reads as chrome, not as an eighth region.
// ★ DELIBERATELY NOT THEIRS. doxa-region.js uses '#0098FF' for null/undefined and keys // ★ DELIBERATELY NOT THEIRS. doxa-region.js uses '#0098FF' for null/undefined and keys
// 'No WAGF Region/Bloc' to '#1a1a2e'. Neither is a REGION colour — they are what an absent value // 'No WAGF Region/Bloc' to '#1a1a2e'. Neither is a REGION colour — they are what an absent value
// looks like — and the Driver's instruction was about the seven regions. A bright blue for // looks like — and the instruction was about the seven regions. A bright blue for
// "unknown" would also read as a region on a map whose every other pin is a region. Every row in // "unknown" would also read as a region on a map whose every other pin is a region. Every row in
// this feed carries a wagf_region, so this value is almost never painted; it is chrome on purpose. // this feed carries a wagf_region, so this value is almost never painted; it is chrome on purpose.
const DEFAULT_COLOR = '#6d766e' const DEFAULT_COLOR = '#6d766e'

View file

@ -3,7 +3,7 @@
* ShareCalendar — the same 5,200-cell grid, shaded by a population-weighted share. * ShareCalendar — the same 5,200-cell grid, shaded by a population-weighted share.
* *
* ★ shareLabel IS REQUIRED AND HAS NO DEFAULT, on purpose. The shipped figure is the EVANGELICAL * ★ shareLabel IS REQUIRED AND HAS NO DEFAULT, on purpose. The shipped figure is the EVANGELICAL
* share (christian-share.json: pctEvangelical 0.6133), and the brief and the Driver both say * share (christian-share.json: pctEvangelical 0.6133), and the brief says
* "% Christian". IMB publishes no %-Christian column at all, so a true "% Christian" is not * "% Christian". IMB publishes no %-Christian column at all, so a true "% Christian" is not
* computable from repo data (UPG100-RESEARCH-FINDINGS.md §3). This project has already retracted one * computable from repo data (UPG100-RESEARCH-FINDINGS.md §3). This project has already retracted one
* P0 that was two metrics wearing one name. A default here would let this component be the thing * P0 that was two metrics wearing one name. A default here would let this component be the thing

View file

@ -246,7 +246,7 @@ export function scatterCells(total, n, seed = '') {
* *
* `rows` is how many distinct grid rows the shaded cells touch. A contiguous block of 32 in a * `rows` is how many distinct grid rows the shaded cells touch. A contiguous block of 32 in a
* 52-wide grid touches 1; a scatter of 32 over 5,200 touches close to 32. That single number is the * 52-wide grid touches 1; a scatter of 32 over 5,200 touches close to 32. That single number is the
* difference between the page the Driver asked for and the one that shipped. * difference between the page that was asked for and the one that shipped.
*/ */
export function scatterSpread(picked, columns) { export function scatterSpread(picked, columns) {
const cols = Number(columns) || 1 const cols = Number(columns) || 1

View file

@ -90,8 +90,8 @@ function rowsExcluding(exclude) {
} }
// ── SECTION COLLAPSE STATE (BUG-DASHBOARD-BAND-GROWS-ENORMOUS-WITH-LIVE-DATA) ──────────────── // ── SECTION COLLAPSE STATE (BUG-DASHBOARD-BAND-GROWS-ENORMOUS-WITH-LIVE-DATA) ────────────────
// Driver: "once the data fills all the sidebars, it causes the dashboard to become really tall... // Once live data filled the sidebars, the dashboard grew very tall and every section had to be
// you have to like collapse all of the data." MEASURED live at 1440x900 in page-scroll mode // collapsed by hand. MEASURED live at 1440x900 in page-scroll mode
// (dashboard-plus-grid): all five sections open by default rendered 69 rows total (16 People / 7 // (dashboard-plus-grid): all five sections open by default rendered 69 rows total (16 People / 7
// Place / 31 Language / 10 Religion / 5 Gospel Access), and the rail's OWN scroller never engaged // Place / 31 Language / 10 Religion / 5 Gospel Access), and the rail's OWN scroller never engaged
// — frBody clientHeight === scrollHeight === 2117px — because a fully-open rail is simply that // — frBody clientHeight === scrollHeight === 2117px — because a fully-open rail is simply that
@ -103,12 +103,9 @@ function rowsExcluding(exclude) {
// header, same interaction as before. // header, same interaction as before.
// ★ OPEN BY DEFAULT — and that is NOT a revert to the bug, it is the correct reading of it. // ★ OPEN BY DEFAULT — and that is NOT a revert to the bug, it is the correct reading of it.
// //
// The previous pass set every section CLOSED, because the Driver said the rail "shouldn't be fully // The previous pass set every section CLOSED, a literal reading of "the rail should not be fully
// expanded". That was a literal reading of a symptom report. His correction, verbatim: "when I told // expanded". The intent was different: People, Place, Language and Religion stay OPEN, but open
// you they shouldn't be fully expanded, I didn't mean collapse all the sidebars. I want people, // inside a bounded scroller, so they never grow the dashboard's height.
// place, language, religion EXPANDED. But when I say expanded I just mean OPEN — that doesn't mean
// fully expanded without a scroll bar. They should be open, but still have a scroll bar so they
// don't fully expand and cause the height of the dashboard to be super tall."
// //
// So the defect was never "the sections are open". It was that opening them grew the PAGE instead of // So the defect was never "the sections are open". It was that opening them grew the PAGE instead of
// filling a bounded scroller — 69 rows of content pushing the band to 2325px against a 900px // filling a bounded scroller — 69 rows of content pushing the band to 2325px against a 900px
@ -170,7 +167,7 @@ function toRows(agg, facet, level) {
} }
// ── THE ROWS THAT WENT TO ZERO (card BUG-TREE-LEGEND-BUTTONS-GO-DEAD-AFTER-TOGGLING) ──────── // ── THE ROWS THAT WENT TO ZERO (card BUG-TREE-LEGEND-BUTTONS-GO-DEAD-AFTER-TOGGLING) ────────
// THE DRIVER'S REPORT: "after clicking around, the buttons started being unresponsive." The // THE REPORT: after some clicking around, the buttons stopped responding. The
// buttons did not break — THEY LEFT. aggregate() only ever returns keys that survive the current // buttons did not break — THEY LEFT. aggregate() only ever returns keys that survive the current
// filterSet, so a filtered-out sibling is simply ABSENT from the row list and is REMOVED FROM THE // filterSet, so a filtered-out sibling is simply ABSENT from the row list and is REMOVED FROM THE
// DOM. Measured on the live composite (2026-09-22): selecting Place > Oceania took the rail from // DOM. Measured on the live composite (2026-09-22): selecting Place > Oceania took the rail from
@ -192,10 +189,10 @@ function toRows(agg, facet, level) {
// unexpanded branch), which would ADD rows on filtering — the opposite surprise. The snapshot is // unexpanded branch), which would ADD rows on filtering — the opposite surprise. The snapshot is
// keyed per section id and only ever taken while the dashboard is unfiltered. // keyed per section id and only ever taken while the dashboard is unfiltered.
// ── WHICH LIST OWNS THE ORDER (card BUG-TREE-EXPANDS-AT-THE-BOTTOM-NOT-UNDER-THE-SELECTED-NODE) ── // ── WHICH LIST OWNS THE ORDER (card BUG-TREE-EXPANDS-AT-THE-BOTTOM-NOT-UNDER-THE-SELECTED-NODE) ──
// THE DRIVER'S REPORT: "it expands the affinity block not underneath the selected affinity but // THE REPORT: the affinity block expanded not underneath the selected affinity but underneath
// underneath the very final affinity group at the very bottom of the semantic tree legend." He // the very last affinity group at the bottom of the semantic tree legend. The trigger was
// also isolated the trigger exactly: expanding with the caret ALONE is correct; SELECTING a row // isolated exactly: expanding with the caret ALONE is correct; SELECTING a row and THEN expanding
// and THEN expanding it is broken. // it is broken.
// //
// ROOT CAUSE — this function used to let the BASELINE own the row order. It emitted every baseline // ROOT CAUSE — this function used to let the BASELINE own the row order. It emitted every baseline
// row first (survivor or zero), and only then appended whatever the live list had that the baseline // row first (survivor or zero), and only then appended whatever the live list had that the baseline
@ -301,8 +298,8 @@ function buildDrillRows(drill, topN) {
} }
// ── TREE TRUNKS (card BUILD-TREE-LEGEND-SEMANTIC-INDENTED) ──────────────────────────────── // ── TREE TRUNKS (card BUILD-TREE-LEGEND-SEMANTIC-INDENTED) ────────────────────────────────
// Driver: "it is very hard for me to read the content because I cannot see that it is INDENTED // The content was hard to read because nothing showed it is INDENTED like a tree; the rail uses the
// like semantic trees. I want the semantic tree legend on the left just like on the research maps." // same nested legend as the research maps.
// //
// Root cause: the rail conveyed depth with padding-left ALONE (.l0/.l1/.l2). Whitespace does not // Root cause: the rail conveyed depth with padding-left ALONE (.l0/.l1/.l2). Whitespace does not
// read as hierarchy — nothing visually ties a child row to its parent, so People > bloc > cluster // read as hierarchy — nothing visually ties a child row to its parent, so People > bloc > cluster
@ -453,15 +450,15 @@ const scopeCount = computed(() => {
// expand work above rewrites `value`; the People axis's label swap writes `label` only, and the // expand work above rewrites `value`; the People axis's label swap writes `label` only, and the
// 'Other' bucket renders a null data-facet so it can never be filtered on. // 'Other' bucket renders a null data-facet so it can never be filtered on.
// ── AFFORDANCE (card BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-WITH-GRID) ───────────────── // ── AFFORDANCE (card BUG-SEMANTIC-TREE-SIDEBAR-NOT-INTEROPERABLE-WITH-GRID) ─────────────────
// DRIVER: "when I click on any of the people place language or religion semantic tree drop downs // THE REPORT: clicking any of the people, place, language or religion semantic tree dropdowns
// nothing happens." Measured, that report was TRUE-AS-EXPERIENCED and the wiring was FINE: the // did nothing. Measured, that report was TRUE-AS-EXPERIENCED and the wiring was FINE: the
// rail split its hit targets so that the caret NAVIGATED and only the row body FILTERED. Clicking // rail split its hit targets so that the caret NAVIGATED and only the row body FILTERED. Clicking
// a caret grew the row count 66 -> 73 and correctly did not filter. Correct by design — and // a caret grew the row count 66 -> 73 and correctly did not filter. Correct by design — and
// completely undiscoverable, because nothing on screen said the two halves of one row did two // completely undiscoverable, because nothing on screen said the two halves of one row did two
// different things. A user must never click something that silently does nothing. // different things. A user must never click something that silently does nothing.
// //
// SO THE HIT TARGETS ARE NOW EXPLICIT, not positional: // SO THE HIT TARGETS ARE NOW EXPLICIT, not positional:
// the WHOLE ROW filters — the Driver's mental model ("I clicked the row, select it") // the WHOLE ROW filters — the reader's mental model: clicking a row selects it
// an explicit CHEVRON BUTTON expands — a real <button> with its own hit area, focus ring and // an explicit CHEVRON BUTTON expands — a real <button> with its own hit area, focus ring and
// aria-label/aria-expanded, so "open this branch" is a thing you can // aria-label/aria-expanded, so "open this branch" is a thing you can
// SEE and tab to rather than a 12px strip you must know about. // SEE and tab to rather than a 12px strip you must know about.
@ -530,9 +527,8 @@ useShadowStyles(`
.rd-facets .fr-body::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; } .rd-facets .fr-body::-webkit-scrollbar-thumb{ background:var(--dx-scroll-thumb, #c4c8c0); border-radius:8px; }
.rd-facets .fr-body::-webkit-scrollbar-thumb:hover{ background:#9aa197; } .rd-facets .fr-body::-webkit-scrollbar-thumb:hover{ background:#9aa197; }
.fr-sec{ border-bottom:1px solid var(--rd-line, #d1d4cd); } .fr-sec{ border-bottom:1px solid var(--rd-line, #d1d4cd); }
/* ★ STICKY SECTION HEADERS, GITHUB-MARKDOWN STYLE (Driver 2026-09-23: "when I am scrolling /* ★ STICKY SECTION HEADERS, GITHUB-MARKDOWN STYLE: while scrolling, each section's header stays
down it needs to keep the section headers visible until you scroll into the next section -- visible until the next section scrolls in, as in a rendered markdown file. The conventional mechanism does all of it natively: position:sticky
like markdown files"). The conventional mechanism does all of it natively: position:sticky
top:0 inside the scrolling .fr-body pins the current header, and the NEXT header pushes the top:0 inside the scrolling .fr-body pins the current header, and the NEXT header pushes the
previous one away as it arrives, because two sticky siblings at the same top contend for the previous one away as it arrives, because two sticky siblings at the same top contend for the
slot. The opaque background is required -- rows scroll UNDER the pinned header, and sticky slot. The opaque background is required -- rows scroll UNDER the pinned header, and sticky
@ -549,12 +545,11 @@ useShadowStyles(`
spacing that the gap used to provide is restored by margin-left on .fr-lbl below. */ spacing that the gap used to provide is restored by margin-left on .fr-lbl below. */
/* min-height replaces an implicit one-line height so a WRAPPED label (see .fr-lbl) grows the /* min-height replaces an implicit one-line height so a WRAPPED label (see .fr-lbl) grows the
row instead of overflowing it; single-line rows keep their previous rhythm. */ row instead of overflowing it; single-line rows keep their previous rhythm. */
/* ★ TIGHTENED 2026-09-23 (Driver: "the spacing between the rows is too thick, they take up way /* ★ TIGHTENED: the rows were too tall, so the per-row padding came down. Vertical padding 3px -> 1px and
too much height -- the padding should be decreased per row"). Vertical padding 3px -> 1px and
min-height 22px -> 18px, which at 4px saved per row is ~276px across the 69 rows the rail min-height 22px -> 18px, which at 4px saved per row is ~276px across the 69 rows the rail
renders with its four sections open -- most of a viewport. renders with its four sections open -- most of a viewport.
★ FONT-SIZE IS NOT A LEVER HERE and must not become one: the Driver explicitly rejected ★ FONT-SIZE IS NOT A LEVER HERE and must not become one: text too small to read is not an
shrinking it ("super tiny text that cannot be read"), and the note below at .fr-lbl records option, and the note below at .fr-lbl records
that the width for the count came from the BAR and the gutters for the same reason. 12px that the width for the count came from the BAR and the gutters for the same reason. 12px
stays; only the whitespace around it moves. stays; only the whitespace around it moves.
min-height 18px still clears a 12px glyph at line-height 1.3 (15.6px), so this tightens the min-height 18px still clears a 12px glyph at line-height 1.3 (15.6px), so this tightens the
@ -582,7 +577,7 @@ useShadowStyles(`
.fr-row.on .fr-trunk-elbow::before, .fr-row.on .fr-trunk-elbow::before,
.fr-row.on .fr-trunk-elbow::after{ background:var(--rd-primary,#92b195); } .fr-row.on .fr-trunk-elbow::after{ background:var(--rd-primary,#92b195); }
/* ── SELECTION EASES IN, IT DOES NOT SNAP (card BUG-TREE-SELECTION-SNAPS-NO-TRANSITION) ──── /* ── SELECTION EASES IN, IT DOES NOT SNAP (card BUG-TREE-SELECTION-SNAPS-NO-TRANSITION) ────
DRIVER: the tree selection "snaps" — it arrives in one frame with no sense of movement. THE REPORT: the tree selection snaps — it arrives in one frame with no sense of movement.
MEASURED CAUSE, and it was NOT the one the card guessed. The card's lead was that MEASURED CAUSE, and it was NOT the one the card guessed. The card's lead was that
keepZeroRows rebuilds the row array and Vue recreates the nodes, leaving a CSS transition keepZeroRows rebuilds the row array and Vue recreates the nodes, leaving a CSS transition
with nothing to animate from. That is not what happens: the template keys each row on with nothing to animate from. That is not what happens: the template keys each row on
@ -662,8 +657,8 @@ useShadowStyles(`
box-shadow:inset 0 0 0 1px rgba(59,70,61,.18); } box-shadow:inset 0 0 0 1px rgba(59,70,61,.18); }
/* re-introduce the inter-column spacing the row's gap used to supply (trunks stay flush) */ /* re-introduce the inter-column spacing the row's gap used to supply (trunks stay flush) */
/* WRAP, DON'T ELLIPSISE — the COUNT is the information, and nowrap was hiding it. /* WRAP, DON'T ELLIPSISE — the COUNT is the information, and nowrap was hiding it.
Driver-reported 2026-09-22: "North America and non-Spanish Caribbean is way too long... I Reported: long labels such as "North America and non-Spanish Caribbean" and "Chinese folk
can't even scroll right to see the numbers", same for "Chinese folk religion". The old rule religion" pushed the numbers out of reach, with no way to scroll right to them. The old rule
was white-space:nowrap + overflow:hidden + text-overflow:ellipsis, which let a long label was white-space:nowrap + overflow:hidden + text-overflow:ellipsis, which let a long label
eat the row; because .fr-body is overflow-x:hidden (vertical scroll only, by design), there eat the row; because .fr-body is overflow-x:hidden (vertical scroll only, by design), there
was NO way to scroll right and recover the number. An ellipsis hid the label AND cost the count. was NO way to scroll right and recover the number. An ellipsis hid the label AND cost the count.
@ -672,11 +667,11 @@ useShadowStyles(`
ordinary labels still break at spaces. The trunk glyphs stay aligned because .fr-trunk is ordinary labels still break at spaces. The trunk glyphs stay aligned because .fr-trunk is
align-self:stretch — it grows with the row instead of assuming one text line. */ align-self:stretch — it grows with the row instead of assuming one text line. */
/* ── ONE LINE PER ROW (card BUILD-TREE-LEGEND-ONE-LINE-ROWS-NARROWER-PROGRESS-BARS) ──────── /* ── ONE LINE PER ROW (card BUILD-TREE-LEGEND-ONE-LINE-ROWS-NARROWER-PROGRESS-BARS) ────────
DRIVER: the tree legend rows should each be ONE line, with narrower progress bars. The tree legend rows should each be ONE line, with narrower progress bars.
THIS CARD AND THE WRAP NOTE ABOVE PULL IN OPPOSITE DIRECTIONS, and both Driver reports are THIS CARD AND THE WRAP NOTE ABOVE PULL IN OPPOSITE DIRECTIONS, and both reports are real, so
real, so the resolution has to satisfy BOTH rather than simply reverting the earlier one: the resolution has to satisfy BOTH rather than simply reverting the earlier one:
- the wrap rule exists because nowrap+ellipsis ATE THE COUNT ("I can't even scroll right - the wrap rule exists because nowrap+ellipsis ATE THE COUNT (no way to scroll right to
to see the numbers"). Any return to a plain ellipsis re-breaks that, because .fr-body is the numbers). Any return to a plain ellipsis re-breaks that, because .fr-body is
overflow-x:hidden by design and there is no way to scroll the number back into view. overflow-x:hidden by design and there is no way to scroll the number back into view.
- this card wants the scannable one-line rhythm back. - this card wants the scannable one-line rhythm back.
SO: clamp to ONE LINE, but buy the width for it instead of taking it out of the label. SO: clamp to ONE LINE, but buy the width for it instead of taking it out of the label.
@ -686,8 +681,7 @@ useShadowStyles(`
the count can never be pushed out of the row. That is the specific failure being avoided: the count can never be pushed out of the row. That is the specific failure being avoided:
the count stays on screen at every width, which was the whole point of the wrap fix. the count stays on screen at every width, which was the whole point of the wrap fix.
The width to afford it comes from the BAR (34px -> 18px below) and the tightened gutters — The width to afford it comes from the BAR (34px -> 18px below) and the tightened gutters —
NOT from the font, which the Driver explicitly rejected shrinking ("super tiny text that NOT from the font, because text too small to read is not an option. overflow-wrap:anywhere is kept so a single unbreakable token still yields
cannot be read"). overflow-wrap:anywhere is kept so a single unbreakable token still yields
rather than overflowing the rail. rather than overflowing the rail.
min-height on .fr-row stays: it is now an exact one-line height rather than a floor. */ min-height on .fr-row stays: it is now an exact one-line height rather than a floor. */
.fr-lbl{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; .fr-lbl{ flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis;
@ -697,20 +691,19 @@ useShadowStyles(`
were flex:1 1 auto and the bar's 24px floor competed with the text for the same row; with a were flex:1 1 auto and the bar's 24px floor competed with the text for the same row; with a
wrapping label the bar must be the element that gives way, so it is capped and allowed to wrapping label the bar must be the element that gives way, so it is capped and allowed to
shrink to zero-ish on a narrow rail. The COUNT (.fr-cnt, flex:0 0 auto) never shrinks — it is shrink to zero-ish on a narrow rail. The COUNT (.fr-cnt, flex:0 0 auto) never shrinks — it is
the number the Driver could not previously see. the number that could not previously be seen.
── 56px -> 34px (card BUILD-REBALANCE-THE-TWO-SIDEBARS) ────────────────────────────────── ── 56px -> 34px (card BUILD-REBALANCE-THE-TWO-SIDEBARS) ──────────────────────────────────
Driver: "the little progress bars need to be less wide... I am fine with smaller progress The little progress bars needed to be narrower. Measured on the live composite before this change: the MEDIAN tree label had 85px
bars." Measured on the live composite before this change: the MEDIAN tree label had 85px
while the bar next to it had 56px — the bar was taking 40% of the label's budget to convey while the bar next to it had 56px — the bar was taking 40% of the label's budget to convey
a magnitude that .fr-cnt already states EXACTLY, in numbers, on the same row. The bar is a magnitude that .fr-cnt already states EXACTLY, in numbers, on the same row. The bar is
redundant precision; the label is the information. Combined with the wider rail this is redundant precision; the label is the information. Combined with the wider rail this is
~50px handed back to every row's text, which is what drives the wrap count down. ~50px handed back to every row's text, which is what drives the wrap count down.
The bar is kept (not deleted) because the at-a-glance MAGNITUDE ranking is genuinely useful The bar is kept (not deleted) because the at-a-glance MAGNITUDE ranking is genuinely useful
when scanning; 34px still resolves ~10 distinguishable steps, which is enough to rank rows. when scanning; 34px still resolves ~10 distinguishable steps, which is enough to rank rows.
Do NOT solve a future width squeeze by shrinking the FONT — the Driver explicitly rejected Do NOT solve a future width squeeze by shrinking the FONT: text too small to read is not an
"super tiny text that cannot be read". Shrink this bar further first. option. Shrink this bar further first.
── 34px -> 18px, AND THE NOTE ABOVE IS THE REASON ──────────────────────────────────────── ── 34px -> 18px, AND THE NOTE ABOVE IS THE REASON ────────────────────────────────────────
Driver, again, on this card: "narrower progress bars". The previous author left the exact The same card asked again for narrower progress bars. The previous author left the exact
instruction for this situation one line up — when the rail needs width, the BAR yields instruction for this situation one line up — when the rail needs width, the BAR yields
before the type does — so this takes it, rather than touching the font size. before the type does — so this takes it, rather than touching the font size.
Those 16px per row are what pay for the one-line clamp on .fr-lbl: the label's budget goes Those 16px per row are what pay for the one-line clamp on .fr-lbl: the label's budget goes
@ -738,9 +731,8 @@ useShadowStyles(`
<template> <template>
<aside class="rd-facets" data-region="facets"> <aside class="rd-facets" data-region="facets">
<div class="fr-head"> <div class="fr-head">
<!-- "Vertical Legend" — the Driver's naming ruling (2026-09-23): "it shouldn't say tree <!-- "Vertical Legend" — the naming rule: not "tree legend"; the component is Vertical, and
legend... rename the component to be called Vertical... use the term nesting or the vocabulary is nesting or folders. The visible title says VERTICAL; the drill-down vocabulary in code and
folders". The visible title says VERTICAL; the drill-down vocabulary in code and
comments is NESTING/FOLDERS, not tree/semantic. Earlier ruling, kept: --> comments is NESTING/FOLDERS, not tree/semantic. Earlier ruling, kept: -->
<!-- (prior note) the word "semantic" is dropped from the <!-- (prior note) the word "semantic" is dropped from the
user-facing label (the component/docs still describe the semantic-tree MODEL). user-facing label (the component/docs still describe the semantic-tree MODEL).
@ -769,8 +761,8 @@ useShadowStyles(`
research-map's SemanticTreeLegend so the hierarchy is DRAWN, not just padded. --> research-map's SemanticTreeLegend so the hierarchy is DRAWN, not just padded. -->
<span v-for="(seg, i) in r.trunks" :key="'tr' + i" class="fr-trunk" :class="'fr-trunk-' + seg"></span> <span v-for="(seg, i) in r.trunks" :key="'tr' + i" class="fr-trunk" :class="'fr-trunk-' + seg"></span>
<!-- CHEVRON = EXPAND, a real <button> with its own hit area, focus ring and label. <!-- CHEVRON = EXPAND, a real <button> with its own hit area, focus ring and label.
Previously a 12px <span> that merely LOOKED like part of the row — the Driver Previously a 12px <span> that merely LOOKED like part of the row — clicking it
clicked it expecting a selection and got navigation with no visible feedback. expected a selection and got navigation with no visible feedback.
Leaf rows render an empty slot so every label in a tree stays left-aligned. --> Leaf rows render an empty slot so every label in a tree stays left-aligned. -->
<button v-if="r.hasChildren" type="button" class="fr-caret-btn" :class="{ open: r.expanded }" <button v-if="r.hasChildren" type="button" class="fr-caret-btn" :class="{ open: r.expanded }"
:data-expand="r.facet" :data-expand-value="r.value" :data-expand="r.facet" :data-expand-value="r.value"

View file

@ -93,7 +93,7 @@ const removeLabel = (c) => T('aria.removeFilter', 'Remove filter {name}').replac
useShadowStyles(` useShadowStyles(`
/* BOUNDED so the chips never slide under the centred search bar (BUG-SELECTION-CHIPS-UNDER-SEARCHBAR). /* BOUNDED so the chips never slide under the centred search bar (BUG-SELECTION-CHIPS-UNDER-SEARCHBAR).
MEASURED live at 1440x900 before the fix: search x 540..900, first chip x 482..718 — ~178px of MEASURED live at 1440x900 before the fix: search x 540..900, first chip x 482..718 — ~178px of
overlap, which is the Driver's "renders badly under the TOP search bar". overlap, which is why the chips rendered badly under the top search bar.
WHY THE CHIPS MOVE AND NOT THE SEARCH: DashboardTopBar deliberately absolute-centres the search WHY THE CHIPS MOVE AND NOT THE SEARCH: DashboardTopBar deliberately absolute-centres the search
(left:50% + translateX(-50%)) so its centre stays fixed "regardless of the left (title/breadcrumb/ (left:50% + translateX(-50%)) so its centre stays fixed "regardless of the left (title/breadcrumb/
chips) or right content widths" — a flow-based margin:auto would drift as chips grow. It also chips) or right content widths" — a flow-based margin:auto would drift as chips grow. It also
@ -102,8 +102,8 @@ useShadowStyles(`
50% - min(180px, 21%). The chip strip starts after the brand/breadcrumb and must STOP SHORT of 50% - min(180px, 21%). The chip strip starts after the brand/breadcrumb and must STOP SHORT of
that edge. min(180px,21%) + a 14px gutter gives the max-width below; 34% is the conservative that edge. min(180px,21%) + a 14px gutter gives the max-width below; 34% is the conservative
floor for narrow viewports where the 42% clamp binds instead of the 360px one. floor for narrow viewports where the 42% clamp binds instead of the 360px one.
nowrap + the per-chip cap keeps the bar at 52px — the Driver has repeatedly said NEVER add a nowrap + the per-chip cap keeps the bar at 52px. A second header row is ruled out, repeatedly
second header row, so growing the bar is not an option. Overflowing chips scroll horizontally and firmly, so growing the bar is not an option. Overflowing chips scroll horizontally
and every truncated label keeps its full text in a title tooltip. */ and every truncated label keeps its full text in a title tooltip. */
/* NO max-width here ON PURPOSE. The bound lives ONCE, on .rd-left in DashboardTopBar (which wraps /* NO max-width here ON PURPOSE. The bound lives ONCE, on .rd-left in DashboardTopBar (which wraps
brand + breadcrumb + chips together). I briefly had a second cap here and it was both redundant brand + breadcrumb + chips together). I briefly had a second cap here and it was both redundant

View file

@ -67,7 +67,7 @@ const { t, te } = useOptionalI18n('', { fallback: (key) => PINHINT_FALLBACK[key]
const uiStore = inject('uiStore', null) const uiStore = inject('uiStore', null)
const profileConfig = inject('profileConfig', null) const profileConfig = inject('profileConfig', null)
const isDark = computed(() => uiStore?.theme === 'dark') const isDark = computed(() => uiStore?.theme === 'dark')
// ★ THE DEFAULT MUST NOT NAME A COLOUR (Driver, reported 5+ times). // ★ THE DEFAULT MUST NOT NAME A COLOUR (reported 5+ times).
// The old default was "Tap a red dot — someone still needs prayer". It shipped on EVERY map, // The old default was "Tap a red dot — someone still needs prayer". It shipped on EVERY map,
// because `pinHintLabel` is an override no profile has ever set and `pinHint:false` is an opt-out // because `pinHintLabel` is an override no profile has ever set and `pinHint:false` is an opt-out
// no profile has ever used — verified: zero occurrences of either across plugin-designs/. // no profile has ever used — verified: zero occurrences of either across plugin-designs/.

View file

@ -40,7 +40,7 @@ useShadowStyles(`
clicks a pin and the detail panel swaps in over the language-family legend clicks a pin and the detail panel swaps in over the language-family legend
(qa: 2026-05-02). */ (qa: 2026-05-02). */
.legend-container{position:absolute;top:60px;inset-inline-start:8px;width:var(--map-legend-width,380px);max-height:calc(100vh - 80px);background:#fff;border-radius:var(--map-radius,16px);box-shadow:0 2px 8px rgba(0,0,0,0.12),0 1px 3px rgba(0,0,0,0.08);z-index:1000;overflow:hidden;display:flex;flex-direction:column;transition:width 200ms cubic-bezier(0.2,0,0,1),max-height 200ms cubic-bezier(0.2,0,0,1),min-width 200ms cubic-bezier(0.2,0,0,1),border-radius 200ms linear,box-shadow 200ms linear;will-change:width,max-height;} .legend-container{position:absolute;top:60px;inset-inline-start:8px;width:var(--map-legend-width,380px);max-height:calc(100vh - 80px);background:#fff;border-radius:var(--map-radius,16px);box-shadow:0 2px 8px rgba(0,0,0,0.12),0 1px 3px rgba(0,0,0,0.08);z-index:1000;overflow:hidden;display:flex;flex-direction:column;transition:width 200ms cubic-bezier(0.2,0,0,1),max-height 200ms cubic-bezier(0.2,0,0,1),min-width 200ms cubic-bezier(0.2,0,0,1),border-radius 200ms linear,box-shadow 200ms linear;will-change:width,max-height;}
/* ── DESKTOP TYPE SCALE for the LegendRows-based card (Driver, 2026-09-16: larger, more /* ── DESKTOP TYPE SCALE for the LegendRows-based card (2026-09-16: larger, more
readable desktop legends). The semantic-tree panel's scale lives in SemanticTreeLegend.vue readable desktop legends). The semantic-tree panel's scale lives in SemanticTreeLegend.vue
(it renders without this card on some maps). Size only, never layout. ── */ (it renders without this card on some maps). Size only, never layout. ── */
.legend-container .lrg-title{font-size:15px;} .legend-container .lrg-title{font-size:15px;}

View file

@ -43,7 +43,7 @@ useShadowStyles(`
nothing tappable in it; stated here, after the rule that would otherwise override it. */ nothing tappable in it; stated here, after the rule that would otherwise override it. */
.legend-sheet-root>.approx-pin-note--floating{pointer-events:none;} .legend-sheet-root>.approx-pin-note--floating{pointer-events:none;}
/* ── Attribution stays visible (driver bug 2026-09-16) ── /* ── Attribution stays visible (bug 2026-09-16) ──
The sheet is a bottom overlay, so the engine's bottom-left logo / bottom-right The sheet is a bottom overlay, so the engine's bottom-left logo / bottom-right
attribution corners ended up UNDER it in every state (even the 48px collapsed attribution corners ended up UNDER it in every state (even the 48px collapsed
footer fully covered the 19px logo). The sheet observes its own height footer fully covered the 19px logo). The sheet observes its own height
@ -343,9 +343,8 @@ const props = defineProps({
/** Which tab the sheet OPENS on, 0-based. Forwarded to SemanticTreeLegend below. /** Which tab the sheet OPENS on, 0-based. Forwarded to SemanticTreeLegend below.
* *
* ★ WHY IT HAD TO BE ADDED. The desktop legend has taken :default-tab for a while, and * ★ WHY IT HAD TO BE ADDED. The desktop legend has taken :default-tab for a while, and
* my-upg-100-list passes 1 (the UPG-100 Lists tab) because the Driver asked for exactly * my-upg-100-list passes 1 (the UPG-100 Lists tab), because opening on Regions left the
* that — "it shouldn't have me sitting there on Regions, I don't know what to do, it's * reader not knowing what to do. The mobile sheet had no such prop, so a phone opened on Region no matter
* confusing". The mobile sheet had no such prop, so a phone opened on Region no matter
* what the profile declared: the same complaint, unfixed, on the surface prayer * what the profile declared: the same complaint, unfixed, on the surface prayer
* mobilisation most uses. Default 0 keeps every other consumer where it is. */ * mobilisation most uses. Default 0 keeps every other consumer where it is. */
defaultTab: { defaultTab: {
@ -416,8 +415,8 @@ const onSemanticTreeSelect = inject('onSemanticTreeSelect', () => {})
// owns the Export menu — but passed neither :export-enabled nor @export. exportEnabled // owns the Export menu — but passed neither :export-enabled nor @export. exportEnabled
// defaults to FALSE, so the control's v-if was never satisfied and a phone reader could // defaults to FALSE, so the control's v-if was never satisfied and a phone reader could
// browse the UPG-100 lists and not export a packet or a poster. The packet IS the product // browse the UPG-100 lists and not export a packet or a poster. The packet IS the product
// for that profile; the Driver asked for "print options at the top right of the legend... // for that profile, so the print options sit at the top right of the legend: without them
// that's the only way that map would be usable". // that map is not usable.
// //
// ⚠ IT WAS NOT A DESIGN QUESTION, WHICH IS WHAT I FIRST RECORDED. The 0x0 elements that // ⚠ IT WAS NOT A DESIGN QUESTION, WHICH IS WHAT I FIRST RECORDED. The 0x0 elements that
// measurement found belong to the OTHER, hidden SemanticTreeLegend instance — the desktop // measurement found belong to the OTHER, hidden SemanticTreeLegend instance — the desktop
@ -626,7 +625,7 @@ const miniImageUrl = computed(() => {
}) })
// ── Touch resize — handlers live on the SHEET, not the 12px strip ────────── // ── Touch resize — handlers live on the SHEET, not the 12px strip ──────────
// Driver bug 2026-09-16 ("moving the informational page up and down has bugs"), // The bug: dragging the information sheet up and down misbehaved,
// reproduced by internal/mobile-sheet-probe.mjs. Three real causes, all fixed // reproduced by internal/mobile-sheet-probe.mjs. Three real causes, all fixed
// here: (1) the only grab surface was the 12px strip, so a thumb on the title // here: (1) the only grab surface was the 12px strip, so a thumb on the title
// row scrolled the content instead of resizing; (2) the 0.3s height transition // row scrolled the content instead of resizing; (2) the 0.3s height transition

View file

@ -2,12 +2,12 @@
/** /**
* LegendProgressBar — the prayer legend's header progress bar. * LegendProgressBar — the prayer legend's header progress bar.
* *
* WHY (Driver, 2026-09-16, README story): a global prayer movement needs to SEE * WHY (the README story): a global prayer movement needs to SEE
* progress — how many people groups are covered in prayer out of the total, changing * progress — how many people groups are covered in prayer out of the total, changing
* live as people sign up. ONE shared component so every prayer map (simple map * live as people sign up. ONE shared component so every prayer map (simple map
* prayer tab, research map Prayer tab, and embeds) shows the same bar. * prayer tab, research map Prayer tab, and embeds) shows the same bar.
* *
* SPEC (Driver refinement, 2026-09-16 — keep this in sync with the behaviour): * SPEC (refined 2026-09-16 — keep this in sync with the behaviour):
* 1. The bar lives in the HEADER of the prayer legend, and the prayer legend's * 1. The bar lives in the HEADER of the prayer legend, and the prayer legend's
* title becomes "Progress" (desktop title bar and the mobile sheet title both — * title becomes "Progress" (desktop title bar and the mobile sheet title both —
* via the shared components SemanticTreeLegend / LegendRows, never "Legend"). * via the shared components SemanticTreeLegend / LegendRows, never "Legend").

View file

@ -249,7 +249,7 @@ defineEmits(['filter-click'])
// Progress payload: explicit prop wins, else the provided one (may be a ref/computed). // Progress payload: explicit prop wins, else the provided one (may be a ref/computed).
const injectedProgress = inject('legendProgress', null) const injectedProgress = inject('legendProgress', null)
const progressData = computed(() => props.progress ?? unref(injectedProgress) ?? null) const progressData = computed(() => props.progress ?? unref(injectedProgress) ?? null)
// Driver 2026-09-16: the prayer legend's header says "Progress" (never "Legend"). // Since 2026-09-16 the prayer legend's header says "Progress" (never "Legend").
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title) const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
// Scroll-to-selected — universal across flat-legend tabs (prayer / engagement / // Scroll-to-selected — universal across flat-legend tabs (prayer / engagement /

View file

@ -67,9 +67,9 @@ function tabLabel(tab) {
// 2026-09-24 on the served upg-100-list leaf: ?lang=es changed 2 of 76 rendered strings // 2026-09-24 on the served upg-100-list leaf: ?lang=es changed 2 of 76 rendered strings
// ("POPULATION" -> "POBLACIÓN") and left all three legend tabs AND all three info // ("POPULATION" -> "POBLACIÓN") and left all three legend tabs AND all three info
// popovers in English — the locale switch works, the strings were simply never in a // popovers in English — the locale switch works, the strings were simply never in a
// catalog. The Driver named this affordance specifically ("the tab itself gives you a // catalog. This affordance was asked for specifically (the tab's own small information
// little information button that will explain what a UPG-100 list is"), which is exactly // button explaining what a UPG-100 list is), which is exactly the sentence a reader who
// the sentence a reader who does not read English cannot use. // does not read English cannot use.
// //
// Same shape as tabLabel: `legend.tabInfo.<i18nKey|id>`, te() guard, falling back to the // Same shape as tabLabel: `legend.tabInfo.<i18nKey|id>`, te() guard, falling back to the
// hardcoded English `info` so a tab with no catalog entry renders unchanged rather than // hardcoded English `info` so a tab with no catalog entry renders unchanged rather than
@ -81,7 +81,7 @@ function tabInfo(tab) {
const props = defineProps({ const props = defineProps({
nodes: { type: Array, default: () => [] }, nodes: { type: Array, default: () => [] },
// APPROXPIN standing note (Driver card BUILD-APPROXIMATE-PIN-NOTICE: every map that shows people-group // APPROXPIN standing note (card BUILD-APPROXIMATE-PIN-NOTICE: every map that shows people-group
// pins says plainly that a dot is a locality). null = decide from the map's own data: shown when the // pins says plainly that a dot is a locality). null = decide from the map's own data: shown when the
// injected dataStore holds named Point features. true/false overrides, for a leaf that already says it. // injected dataStore holds named Point features. true/false overrides, for a leaf that already says it.
pinNote: { type: Boolean, default: null }, pinNote: { type: Boolean, default: null },
@ -122,7 +122,7 @@ const instance = useInstance()
// Explicit prop wins, else the provided payload (a ref/computed from the profile). // Explicit prop wins, else the provided payload (a ref/computed from the profile).
const injectedProgress = inject('legendProgress', null) const injectedProgress = inject('legendProgress', null)
const progressData = computed(() => props.progress ?? unref(injectedProgress) ?? null) const progressData = computed(() => props.progress ?? unref(injectedProgress) ?? null)
// Driver 2026-09-16: the prayer legend's header says "Progress" (never "Legend") — // The prayer legend's header says "Progress" (never "Legend") —
// on the desktop title bar, the mobile sheet title and the collapsed reopen pill. // on the desktop title bar, the mobile sheet title and the collapsed reopen pill.
const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title) const displayTitle = computed(() => progressData.value ? t('legend.progress.title') : props.title)
const stlIsDark = computed(() => (instance?.theme.value || 'dark') === 'dark') const stlIsDark = computed(() => (instance?.theme.value || 'dark') === 'dark')
@ -249,8 +249,8 @@ const sortDir = ref('desc') // 'desc' | 'asc'
// ⚠ THE STORE USED TO WIN UNCONDITIONALLY, which made `defaultTab` dead for every profile // ⚠ THE STORE USED TO WIN UNCONDITIONALLY, which made `defaultTab` dead for every profile
// that shares an instance. instance.activeTab starts at 0 and 0 is indistinguishable from // that shares an instance. instance.activeTab starts at 0 and 0 is indistinguishable from
// "the user chose tab 0", so a profile passing :default-tab="1" still opened on tab 0. // "the user chose tab 0", so a profile passing :default-tab="1" still opened on tab 0.
// MEASURED 2026-09-24: my-upg-100-list opened on Region at desktop AND mobile, which is the // MEASURED 2026-09-24: my-upg-100-list opened on Region at desktop AND mobile, which is exactly
// Driver's first complaint about this legend word for word. // the first reported problem with this legend.
// Now the store only wins once something has ACTUALLY chosen a tab — a tab click, or a node // Now the store only wins once something has ACTUALLY chosen a tab — a tab click, or a node
// selection that moves the legend to that node's generation (usePplrInstance sets the flag). // selection that moves the legend to that node's generation (usePplrInstance sets the flag).
// Until then the profile's declared landing tab applies, which is what the prop always meant. // Until then the profile's declared landing tab applies, which is what the prop always meant.
@ -294,8 +294,8 @@ watch(() => props.open, (v) => {
watch(() => safeNodes.value, () => { watch(() => safeNodes.value, () => {
// ★ A DATA REFRESH IS NOT A DESELECTION (rega, WAGF Regions on Simple Research, 2026-09-28). The // ★ A DATA REFRESH IS NOT A DESELECTION (rega, WAGF Regions on Simple Research, 2026-09-28). The
// polygon highlight landed 51-85 ms after the click and was wiped 433-667 ms later, leaving no row // polygon highlight landed 51-85 ms after the click and was wiped 433-667 ms later, leaving no row
// marked and every region at full colour — which reads exactly like the Driver's "the legend isn't // marked and every region at full colour — which reads exactly like a legend not connecting with
// connecting with the map", and is worse, because it connected and then undid itself. // the map, and is worse, because it connected and then undid itself.
// //
// This watcher fires on the IDENTITY of the nodes array, and every tree here is a computed that // This watcher fires on the IDENTITY of the nodes array, and every tree here is a computed that
// returns a NEW array on each evaluation. So any late async that touches a dependency — pins still // returns a NEW array on each evaluation. So any late async that touches a dependency — pins still
@ -663,8 +663,8 @@ useShadowStyles(`
/* --stl-top: the panel's offset from the map's top edge. The 60px default reserves room for a /* --stl-top: the panel's offset from the map's top edge. The 60px default reserves room for a
search bar ABOVE the legend, which most consumers (research-map's geocoder) still have. A search bar ABOVE the legend, which most consumers (research-map's geocoder) still have. A
profile that has REMOVED its search bar sets --stl-top on the legend's wrapper so the panel profile that has REMOVED its search bar sets --stl-top on the legend's wrapper so the panel
rises into the vacated space instead of keeping a dead gap (Driver, 2026-09-23, UPG-100: rises into the vacated space instead of keeping a dead gap (UPG-100 removed its search bar,
"the search bar is removed, that means the legend needs to be moved up"). The max-height so its legend moves up). The max-height
mirrors it so a raised panel gains the same room at the bottom it gained at the top. */ mirrors it so a raised panel gains the same room at the bottom it gained at the top. */
position: absolute; inset-inline-start: 8px; top: var(--stl-top, 60px); width: var(--map-legend-width, 380px); z-index: 4; position: absolute; inset-inline-start: 8px; top: var(--stl-top, 60px); width: var(--map-legend-width, 380px); z-index: 4;
display: flex; flex-direction: column; display: flex; flex-direction: column;
@ -1022,7 +1022,7 @@ useShadowStyles(`
both, so they cannot drift apart again. both, so they cannot drift apart again.
MEASURED 2026-09-24 at 390px in a 420px-tall host: the panel ran 38px past the bottom of the MEASURED 2026-09-24 at 390px in a 420px-tall host: the panel ran 38px past the bottom of the
map. Driver: "the upg 100 list legend on the map is overflowing beyond the height of the map." */ map: the UPG-100 list's legend overflowed the map's height. */
.stl-panel { width: 92vw; max-width: 380px; --stl-top: 64px; } .stl-panel { width: 92vw; max-width: 380px; --stl-top: 64px; }
.stl-reopen { top: 64px; } .stl-reopen { top: 64px; }
} }
@ -1163,8 +1163,8 @@ useShadowStyles(`
.stl-panel[data-theme="light"] .stl-export-item { color: #57606a; } .stl-panel[data-theme="light"] .stl-export-item { color: #57606a; }
.stl-panel[data-theme="light"] .stl-export-lock { color: #57606a; } .stl-panel[data-theme="light"] .stl-export-lock { color: #57606a; }
.stl-panel[data-theme="light"] .stl-export-soon { color: #57606a; background: rgba(208,215,222,0.5); } .stl-panel[data-theme="light"] .stl-export-soon { color: #57606a; background: rgba(208,215,222,0.5); }
/* ── DESKTOP TYPE SCALE (Driver, 2026-09-16: "the desktop font sizes in the legends need to /* ── DESKTOP TYPE SCALE: larger legend type on desktop, so the data reads more easily.
be increased … larger data to be more readable on desktop"). Desktop panel only — the Desktop panel only — the
mobile sheet (.legend-mobile-sheet) keeps its own compact sizes. Size only, never layout; mobile sheet (.legend-mobile-sheet) keeps its own compact sizes. Size only, never layout;
the base rules above stay as the mobile/default scale. ── */ the base rules above stay as the mobile/default scale. ── */
.stl-panel:not(.legend-mobile-sheet *) .stl-tb-title, .stl-panel:not(.legend-mobile-sheet *) .stl-tb-title,
@ -1177,7 +1177,7 @@ useShadowStyles(`
.stl-panel:not(.legend-mobile-sheet *) .stl-num { font-size: 13px; } .stl-panel:not(.legend-mobile-sheet *) .stl-num { font-size: 13px; }
/* The numeric columns must grow with the type: "POPULATION" at 10px is ~65px, ~76px with /* The numeric columns must grow with the type: "POPULATION" at 10px is ~65px, ~76px with
its sort arrow; "139.0M" at 13.5px is ~49px. A fixed 44px column let the header collide its sort arrow; "139.0M" at 13.5px is ~49px. A fixed 44px column let the header collide
with "UUPGs" and let values spill into the neighbouring column (Driver, 2026-09-16). */ with "UUPGs" and let values spill into the neighbouring column. */
.stl-panel:not(.legend-mobile-sheet *) .stl-inner { --stl-num-w: 70px; } /* the vars live on .stl-inner */ .stl-panel:not(.legend-mobile-sheet *) .stl-inner { --stl-num-w: 70px; } /* the vars live on .stl-inner */
.stl-panel:not(.legend-mobile-sheet *) .stl-hdr-num, .stl-panel:not(.legend-mobile-sheet *) .stl-hdr-num,
.stl-panel:not(.legend-mobile-sheet *) .stl-num { white-space: nowrap; } .stl-panel:not(.legend-mobile-sheet *) .stl-num { white-space: nowrap; }

View file

@ -407,7 +407,7 @@ function injectGeocoderCSS(shadowRoot, prov) {
/** /**
* Load a geocoder UMD bundle that the HOST PAGE did not preload. * Load a geocoder UMD bundle that the HOST PAGE did not preload.
* *
* ★ WHY MAPBOX NEEDS THIS TOO (Driver, 2026-09-23 — the search bar missing from a nested map). * ★ WHY MAPBOX NEEDS THIS TOO (2026-09-23 — the search bar missing from a nested map).
* MapboxGeocoder was documented as "preloaded via index.html", and for a LEAF page that is true. * MapboxGeocoder was documented as "preloaded via index.html", and for a LEAF page that is true.
* It is NOT true for a COMPOSITE leaf: generate-tree classifies that page as token-free and emits * It is NOT true for a COMPOSITE leaf: generate-tree classifies that page as token-free and emits
* no geocoder script, while the composite goes on to nest a REAL map inside itself. MEASURED on the * no geocoder script, while the composite goes on to nest a REAL map inside itself. MEASURED on the

View file

@ -88,7 +88,7 @@ const style = ref({})
// That is the identical failure the 2026-09-22 dashboard fix describes below, returning through a // That is the identical failure the 2026-09-22 dashboard fix describes below, returning through a
// different door: not "a wrong side was chosen" but "nothing chose anything". // different door: not "a wrong side was chosen" but "nothing chose anything".
// MEASURED 2026-09-28: cities-internet, gospel-resources, key-cities and upg-neighbor carry NO map-area // MEASURED 2026-09-28: cities-internet, gospel-resources, key-cities and upg-neighbor carry NO map-area
// class and no data-map-area, so all four were unanchored. The Driver reported two of them; the list is // class and no data-map-area, so all four were unanchored. Two of them were reported; the list is
// what made it four. // what made it four.
const AREA = '.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]' const AREA = '.dsm-map-area, .rm-map-area, .dm-map-area, [data-map-area]'

View file

@ -29,7 +29,7 @@ const props = defineProps({
/** /**
* 'round' (default) keeps the separate floating circles every map ships today. * 'round' (default) keeps the separate floating circles every map ships today.
* 'square' is the ENGINE'S OWN grouped card -- one white panel, 4px corners, a 2px ring and 1px * 'square' is the ENGINE'S OWN grouped card -- one white panel, 4px corners, a 2px ring and 1px
* dividers -- which is the look the Driver asked for by name. Measured from * dividers -- which is the look asked for by name. Measured from
* node_modules/maplibre-gl/dist/maplibre-gl.css, not invented: * node_modules/maplibre-gl/dist/maplibre-gl.css, not invented:
* .maplibregl-ctrl-group { background:#fff; border-radius:4px } * .maplibregl-ctrl-group { background:#fff; border-radius:4px }
* .maplibregl-ctrl-group:not(:empty) { box-shadow:0 0 0 2px rgba(0,0,0,.1) } * .maplibregl-ctrl-group:not(:empty) { box-shadow:0 0 0 2px rgba(0,0,0,.1) }
@ -153,7 +153,7 @@ useShadowStyles(`
specificity, so the .map-toolbar gap:6px rule above is (0,1,0) exactly like the specificity, so the .map-toolbar gap:6px rule above is (0,1,0) exactly like the
.map-toolbar--square gap:0 rule -- and it comes LATER in the sheet, so it won. On any phone a .map-toolbar--square gap:0 rule -- and it comes LATER in the sheet, so it won. On any phone a
square toolbar would have fallen back into six separate buttons inside one ringed panel: the square toolbar would have fallen back into six separate buttons inside one ringed panel: the
shape breaking precisely where the Driver is most likely to look at it. shape breaking precisely where a reader is most likely to look at it.
The --no-geocoder pair is (0,2,0) and outranked the variant regardless of order, so it needs the The --no-geocoder pair is (0,2,0) and outranked the variant regardless of order, so it needs the
two-class form to answer it. Same lesson as the goal cards' gap-vs-margin: a ported mechanism is two-class form to answer it. Same lesson as the goal cards' gap-vs-margin: a ported mechanism is
only ported if it still WINS. */ only ported if it still WINS. */

View file

@ -257,8 +257,8 @@ useShadowStyles(`
color:#a0aab8; color:#a0aab8;
word-break:break-all; word-break:break-all;
white-space:pre-wrap; white-space:pre-wrap;
/* ★ NO SCROLLBAR — THE MENU SHOWS THE WHOLE SNIPPET (Driver 2026-09-23: "the share menu that /* ★ NO SCROLLBAR — THE MENU SHOWS THE WHOLE SNIPPET (2026-09-23: the share menu opens fully
opens up should be fully expanded; it shouldn't have a scroll bar, but it currently has one"). expanded, with no scroll bar).
Was max-height:90px + overflow-y:auto, which clipped a ~4-line embed snippet to about two and Was max-height:90px + overflow-y:auto, which clipped a ~4-line embed snippet to about two and
a half lines and put a scrollbar inside a popover. That is the wrong trade for THIS content: a half lines and put a scrollbar inside a popover. That is the wrong trade for THIS content:
the box exists to be READ and COPIED in one glance, and user-select:all below means the whole the box exists to be READ and COPIED in one glance, and user-select:all below means the whole
@ -435,9 +435,9 @@ async function copyEmbed() {
/* Anchoring OFF a map — the "menu opens off the right edge" fix */ /* Anchoring OFF a map — the "menu opens off the right edge" fix */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* /*
★ DRIVER-REPORTED TWICE (2026-09-22): "when you click the share button on the research ★ REPORTED TWICE (2026-09-22): on the research dashboard the share menu opened down and to the
dashboard, the menu is showing down to the RIGHT of the share button, and the share button is at RIGHT of the share button, and the share button sits at the very top right of the page, so
the very top right of the page. So down-to-the-right is actually OFF the page." down-to-the-right is OFF the page.
THE ROOT CAUSE IS AN ABSENT ANCHOR, NOT A WRONG ALIGNMENT CHOICE — measured, and it is a THE ROOT CAUSE IS AN ABSENT ANCHOR, NOT A WRONG ALIGNMENT CHOICE — measured, and it is a
DIFFERENT bug from the FilterDropdown one it superficially resembles. That one was a DIFFERENT bug from the FilterDropdown one it superficially resembles. That one was a
@ -459,7 +459,7 @@ async function copyEmbed() {
containing area. MapControlMenu's existing measured logic then does the whole job unchanged: containing area. MapControlMenu's existing measured logic then does the whole job unchanged:
it compares the anchor's centre to the area's centre, opens on the INNER side, and clamps the it compares the anchor's centre to the area's centre, opens on the INNER side, and clamps the
width to what fits. A share button at the top right is right-of-centre, so the menu opens to the width to what fits. A share button at the top right is right-of-centre, so the menu opens to the
LEFT — which is what the Driver asked for, arrived at by measurement rather than by hardcoding a LEFT — which is what was asked for, arrived at by measurement rather than by hardcoding a
side that would then be wrong for a left-hand placement. side that would then be wrong for a left-hand placement.
WHY `inject` FIRST AND `provide` ONLY AS A FALLBACK: on a real map this component must keep WHY `inject` FIRST AND `provide` ONLY AS A FALLBACK: on a real map this component must keep

View file

@ -129,7 +129,7 @@ const T = (key, en) => tStatic(key) || en;
const props = defineProps({ const props = defineProps({
/** /**
* What the Render button does. 'print' (default, Driver rule 2026-09-28: an export-poster button opens * What the Render button does. 'print' (default, the rule since 2026-09-28: an export-poster button opens
* the PRINT DIALOG with a preview, never a direct download) prints the rendered poster in place; a PDF * the PRINT DIALOG with a preview, never a direct download) prints the rendered poster in place; a PDF
* opens in the browser's viewer, where it can be printed or saved. 'download' saves the file, and a * opens in the browser's viewer, where it can be printed or saved. 'download' saves the file, and a
* consumer that opts in must say why in a `posterAction-download:` comment (check:poster-action-print). * consumer that opts in must say why in a `posterAction-download:` comment (check:poster-action-print).

View file

@ -120,29 +120,28 @@ const props = defineProps({
// comment saying they "come from the renderer's own STOCK table so this list cannot // comment saying they "come from the renderer's own STOCK table so this list cannot
// drift" — which was the intent, not the implementation. Deriving them makes it true. // drift" — which was the intent, not the implementation. Deriving them makes it true.
cardStocks: { type: [Boolean, Array], default: false }, cardStocks: { type: [Boolean, Array], default: false },
// ★ DOWNLOAD OR PRINT? A DRIVER RULING, NOT A PREFERENCE — and the reason the // ★ DOWNLOAD OR PRINT? A PRODUCT RULING, NOT A PREFERENCE — and the reason the
// 100-list could not simply adopt this component. // 100-list could not simply adopt this component.
// Default 'download' is what country-posters and key-cities do today and keeps // Default 'download' is what country-posters and key-cities do today and keeps
// them byte-for-byte unchanged. 'print' opens the OS print dialog on the poster // them byte-for-byte unchanged. 'print' opens the OS print dialog on the poster
// instead, which is what my-upg-100-list/poster-wall.vue:280 records as the // instead, which is what my-upg-100-list/poster-wall.vue:280 records as the
// Driver's explicit brief (2026-09-23): "three print buttons, each opening an // brief: three print buttons, each opening an in-browser print dialog, and no
// in-browser print dialog, no silent file download as the default action. A raw // silent file download as the default action (a raw 'Download' button and a
// 'Download' button and a 'Prayer List: coming soon' placeholder were exactly the // 'Prayer List: coming soon' placeholder were the two rejected). Printing to PDF
// two buttons he objected to." Printing to PDF from that dialog covers the // from that dialog covers the get-a-file need without a fourth competing button.
// get-a-file need without a fourth competing button.
// Migrating the 100-list onto this component WITHOUT this prop would have // Migrating the 100-list onto this component WITHOUT this prop would have
// reintroduced both rejected buttons — the capability gap was never the whole // reintroduced both rejected buttons — the capability gap was never the whole
// blocker; the shared component still encoded the pre-brief design. // blocker; the shared component still encoded the pre-brief design.
// ★ DEFAULT 'print' (Driver rule 2026-09-28: an export-poster button opens the PRINT DIALOG with a // ★ DEFAULT 'print': an export-poster button opens the PRINT DIALOG with a preview, never a direct
// preview, never a direct download). country-posters and key-cities set nothing and downloaded. // download. country-posters and key-cities set nothing and downloaded.
// 'download' is an explicit opt-in, and a consumer that opts in must say why in a comment carrying // 'download' is an explicit opt-in, and a consumer that opts in must say why in a comment carrying
// `posterAction-download:` (check:poster-action-print). Multi-page PDF packets and CSV exports are not // `posterAction-download:` (check:poster-action-print). Multi-page PDF packets and CSV exports are not
// poster buttons and stay downloads. // poster buttons and stay downloads.
posterAction: { type: String, default: 'print' }, // 'print' | 'download' posterAction: { type: String, default: 'print' }, // 'print' | 'download'
// ★ IS THE PREVIEW ITSELF A CONTROL? Default 'none' keeps the image inert, which is // ★ IS THE PREVIEW ITSELF A CONTROL? Default 'none' keeps the image inert, which is
// what country-posters and key-cities render today. 'print' makes clicking the // what country-posters and key-cities render today. 'print' makes clicking the
// poster preview open it for printing — a Driver-named behaviour ("show the poster // poster preview open it for printing (the poster shown in the browser to help
// in the browser to help print it"), and the LAST capability the my-upg-100-list // print it), and the LAST capability the my-upg-100-list
// migration had not carried across. // migration had not carried across.
// ⚠ WHEN IT IS A CONTROL IT MUST BE A KEYBOARD CONTROL. The original carried // ⚠ WHEN IT IS A CONTROL IT MUST BE A KEYBOARD CONTROL. The original carried
// role="button", tabindex="0" and Enter/Space handlers alongside the click; an // role="button", tabindex="0" and Enter/Space handlers alongside the click; an
@ -153,7 +152,7 @@ const props = defineProps({
previewAction: { type: String, default: 'none' }, // 'none' | 'print' previewAction: { type: String, default: 'none' }, // 'none' | 'print'
// Hide the Prayer List button entirely, rather than showing it as a // Hide the Prayer List button entirely, rather than showing it as a
// "coming soon" placeholder. The placeholder is honest where the feature is // "coming soon" placeholder. The placeholder is honest where the feature is
// genuinely unbuilt, and it is the OTHER button the Driver objected to on a wall // genuinely unbuilt, and it is the OTHER rejected button on a wall
// where the list DOES print — so a wall with a records provider can suppress it. // where the list DOES print — so a wall with a records provider can suppress it.
hidePrayerListWhenSoon: { type: Boolean, default: false }, hidePrayerListWhenSoon: { type: Boolean, default: false },
// EMBEDDED mode (added 2026-09-24 for the regions-tab poster dropdown). The wall's root is // EMBEDDED mode (added 2026-09-24 for the regions-tab poster dropdown). The wall's root is
@ -168,8 +167,8 @@ const props = defineProps({
// group in its own chrome (the region card's header and summary) would otherwise repeat it, // group in its own chrome (the region card's header and summary) would otherwise repeat it,
// and with a single unnamed group the head renders the literal "Other". Default true. // and with a single unnamed group the head renders the literal "Other". Default true.
sectionHeads:{ type: Boolean, default: true }, sectionHeads:{ type: Boolean, default: true },
// ★ HOW MANY POSTERS SIT IN A ROW. The Driver, on the regions posters: "i need two posters per // ★ HOW MANY POSTERS SIT IN A ROW. The regions posters want two per row, with a setting to raise
// row on the regiosn posters and witha setitn got icnreat eh number of poswers per row". // the number.
// 0 keeps the wall's existing auto-fill (repeat(auto-fill, minmax(210px, 1fr))), so the two // 0 keeps the wall's existing auto-fill (repeat(auto-fill, minmax(210px, 1fr))), so the two
// panes that already ship — country-posters and my-upg-100-list — are byte-identical unless // panes that already ship — country-posters and my-upg-100-list — are byte-identical unless
// their owner opts in. A positive number fixes the track count instead. // their owner opts in. A positive number fixes the track count instead.
@ -192,9 +191,8 @@ watch(() => props.columns, (n) => { if (readStoredPerRow() == null) perRow.value
watch(perRow, (n) => { watch(perRow, (n) => {
if (n === PER_ROW_AUTO || (Number.isFinite(n) && n >= PER_ROW_MIN && n <= PER_ROW_MAX)) storePerRow(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 // The choices the control offers: a minimum of 2, a maximum of 5, a default of 2; the ceiling is the
// auto-fill behaviour, offered as "fit" so the knob can always get back to where it started. // 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".
// ★ AUTO IS 0, AND 0 IS NOT A COUNT — it is the absence of an override. gridStyle already returns // ★ 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 // {} 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. // new branch in the layout: it needed to become REACHABLE from the control and from storage.
@ -203,9 +201,8 @@ watch(perRow, (n) => {
const PER_ROW_AUTO = 0 const PER_ROW_AUTO = 0
const PER_ROW_CHOICES = [PER_ROW_AUTO, 2, 3, 4, 5] const PER_ROW_CHOICES = [PER_ROW_AUTO, 2, 3, 4, 5]
const PER_ROW_MIN = 2, PER_ROW_MAX = 5, PER_ROW_DEFAULT = 2 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 // The choice persists in localStorage, so a number the reader sets survives a reload. One key for the
// storage so you can reload and your default you set is persistent". One key for the whole wall, // whole wall, not per instance: the reader is setting a PREFERENCE, and a preference that only survives on the
// not per instance: the Driver is setting a PREFERENCE, and a preference that only survives on the
// page you set it on is not one. Every access is wrapped because localStorage THROWS outright in a // page you set it on is not one. Every access is wrapped because localStorage THROWS outright in a
// private window or with site data blocked -- a preference must never be able to break the wall. // private window or with site data blocked -- a preference must never be able to break the wall.
const PER_ROW_KEY = 'doxa-maps:poster-wall:per-row' const PER_ROW_KEY = 'doxa-maps:poster-wall:per-row'
@ -232,7 +229,7 @@ function storePerRow(n) {
// An inline style beats the stylesheet, so `repeat(2, minmax(0,1fr))` overrode the CSS rule's // An inline style beats the stylesheet, so `repeat(2, minmax(0,1fr))` overrode the CSS rule's
// `minmax(210px,1fr)` at EVERY width — including 390px, where two columns give a ~162px card. // `minmax(210px,1fr)` at EVERY width — including 390px, where two columns give a ~162px card.
// Reported from a 390x844 phone shot: two posters per row, where the CSS alone would have // Reported from a 390x844 phone shot: two posters per row, where the CSS alone would have
// given one. The stored preference is the Driver's and must survive; it is a preference for // given one. The stored preference is the reader's and must survive; it is a preference for
// DESKTOP density, not an instruction to overflow a phone. // DESKTOP density, not an instruction to overflow a phone.
// 210px is the same minimum the stylesheet's auto-fill uses, so the clamp agrees with the rule // 210px is the same minimum the stylesheet's auto-fill uses, so the clamp agrees with the rule
// it is overriding rather than inventing a second breakpoint. // it is overriding rather than inventing a second breakpoint.
@ -261,7 +258,7 @@ const itemCount = (n) => (props.itemCountKey ? countOf(props.itemCountKey, n) :
const base = computed(() => String(props.assetsBase || './assets').replace(/\/+$/, '')) const base = computed(() => String(props.assetsBase || './assets').replace(/\/+$/, ''))
// ★ A SECOND PLACE TO LOOK, because "poster not generated yet" was being shown for posters that // ★ A SECOND PLACE TO LOOK, because "poster not generated yet" was being shown for posters that
// ARE generated. The DRIVER reported it on Ethiopia, whose country-eth.webp exists (174 of the 175 // ARE generated. It was reported on Ethiopia, whose country-eth.webp exists (174 of the 175
// records have their .webp; India is the one that legitimately does not and carries its own // records have their .webp; India is the one that legitimately does not and carries its own
// reason). MEASURED here — static build AND dev server, every request 200, zero cards in that // reason). MEASURED here — static build AND dev server, every request 200, zero cards in that
// branch — so the file is not missing; a HOST LAYOUT is. resolveAssetsBase points at // branch — so the file is not missing; a HOST LAYOUT is. resolveAssetsBase points at
@ -519,14 +516,13 @@ const previewIsControl = computed(() => props.previewAction === 'print')
// Which card currently has its size row revealed, by item id. Holding an ID // Which card currently has its size row revealed, by item id. Holding an ID
// rather than a boolean-per-card means opening one CLOSES the others for free: // rather than a boolean-per-card means opening one CLOSES the others for free:
// two size rows open at once would overlap in the grid gap, and there is no // two size rows open at once would overlap in the grid gap, and there is no
// reading of the Driver's request where two are wanted. // reading of the request where two are wanted.
// ★ NOW A COMPOUND KEY, `${id}:${which}` — the Driver, 2026-09-28, asked for the SAME caret on the // ★ NOW A COMPOUND KEY, `${id}:${which}` — the Prayer List gets the SAME caret, with two entries
// Prayer List with two entries (Full detail / Lightweight), and explicitly not a merged menu and not // (Full detail / Lightweight), and explicitly not a merged menu and not a second dropdown component. There is no dropdown COMPONENT here to reuse: this is an inline
// a second dropdown component. There is no dropdown COMPONENT here to reuse: this is an inline
// disclosure, one ref plus a sibling tape of plain buttons. So the PATTERN is generalized instead of // disclosure, one ref plus a sibling tape of plain buttons. So the PATTERN is generalized instead of
// copied — copy/paste would give two independent open-states, and then opening the Prayer List would // copied — copy/paste would give two independent open-states, and then opening the Prayer List would
// NOT close the Cards, so two tapes could sit open at once and overlap in the grid gap. That is a // NOT close the Cards, so two tapes could sit open at once and overlap in the grid gap. That is a
// visible regression on the Cards row the Driver said to leave exactly as it is. // visible regression on the Cards row, which is to stay exactly as it is.
// Holding ONE key keeps "opening one closes the others" free, which is why it was an id to begin with. // Holding ONE key keeps "opening one closes the others" free, which is why it was an id to begin with.
const deckOpenFor = ref(null) const deckOpenFor = ref(null)
const deckKey = (id, which) => `${id}:${which}` const deckKey = (id, which) => `${id}:${which}`
@ -579,8 +575,8 @@ const packetVariants = [
// entry is picked. Opening a variant closes the row, like printDeckAt. // entry is picked. Opening a variant closes the row, like printDeckAt.
// ★ THIS CALLS printPacket, NOT printList — and that difference is a DEFECT FIX, not a refactor. // ★ THIS CALLS printPacket, NOT printList — and that difference is a DEFECT FIX, not a refactor.
// MEASURED: downloadPacket() above calls prints.printList(item), which renders a COLUMNAR TABLE // MEASURED: downloadPacket() above calls prints.printList(item), which renders a COLUMNAR TABLE
// (name / country / population / religion — usePosterPrints.js:150). The PRAYER PACKET the Driver // (name / country / population / religion — usePosterPrints.js:150). The PRAYER PACKET as specified
// specified — map, life calendar, %-evangelical grid, triangle, profiles, wheel, points — is // — map, life calendar, %-evangelical grid, triangle, profiles, wheel, points — is
// prints.printPacket, and this wall never called it. Every OTHER surface does // prints.printPacket, and this wall never called it. Every OTHER surface does
// (upg-100-list.vue:1430 and :1671, research-map.vue:1597, countries-map.vue:1360); the poster wall // (upg-100-list.vue:1430 and :1671, research-map.vue:1597, countries-map.vue:1360); the poster wall
// was the sole exception. So the button labelled "Prayer List" here has been printing a list, which // was the sole exception. So the button labelled "Prayer List" here has been printing a list, which
@ -741,9 +737,9 @@ useShadowStyles(
'.is-dark .tape{background:#1d2b23;color:#cfe9d9;border-color:rgba(255,255,255,.10)}' + '.is-dark .tape{background:#1d2b23;color:#cfe9d9;border-color:rgba(255,255,255,.10)}' +
// ★ THE HEADER STICKER STRADDLES THE BANNER'S TOP EDGE, for the same reason the footer one // ★ THE HEADER STICKER STRADDLES THE BANNER'S TOP EDGE, for the same reason the footer one
// straddles the bottom. MEASURED before: 35px above the banner's top edge, -1px below — it // straddles the bottom. MEASURED before: 35px above the banner's top edge, -1px below — it
// floated over the card, touching the banner nowhere. The Driver reads the rounded top // floated over the card, touching the banner nowhere. The rounded top sticker is the one
// sticker as the one that "looks beautiful and is really well spaced", so this keeps its // that reads as well spaced, so this keeps its size and shape exactly and only corrects
// size and shape exactly and only corrects WHERE it sits: centred on the edge it is taping. // WHERE it sits: centred on the edge it is taping.
// --banner-pad-top reserves the banner surface it lands on, so it never reaches the map. // --banner-pad-top reserves the banner surface it lands on, so it never reaches the map.
// .tape-h is positioned against .card (it is a direct child), and the banner's top is the // .tape-h is positioned against .card (it is a direct child), and the banner's top is the
// card's padding-top — hence top:24px, read from the card rather than restated. // card's padding-top — hence top:24px, read from the card rather than restated.
@ -766,7 +762,7 @@ useShadowStyles(
// four controls (Prayer List · Card Deck · stock select · Poster Map) burst the box regardless. // four controls (Prayer List · Card Deck · stock select · Poster Map) burst the box regardless.
// MEASURED 2026-09-24 at 1440px: all 27 cards overflowing, worst 110px past a 212px card. // MEASURED 2026-09-24 at 1440px: all 27 cards overflowing, worst 110px past a 212px card.
// //
// ★ flex-wrap:wrap FIXED THAT AND BROKE THE OTHER AXIS — driver-reported, same day. The row is // ★ flex-wrap:wrap FIXED THAT AND BROKE THE OTHER AXIS — reported the same day. The row is
// absolutely positioned at bottom:-14px, so wrapping it to three lines grew it 35px -> 78px and // absolutely positioned at bottom:-14px, so wrapping it to three lines grew it 35px -> 78px and
// pushed its TOP up from 216px to 173px into a 238px card: straight over the preview image and // pushed its TOP up from 216px to 173px into a 238px card: straight over the preview image and
// the card's outline. Horizontal overflow traded for vertical. min-width:0 alone lets the // the card's outline. Horizontal overflow traded for vertical. min-width:0 alone lets the
@ -774,13 +770,12 @@ useShadowStyles(
// The gate that passed this only measured horizontal edges; it now measures the row's top too. // The gate that passed this only measured horizontal edges; it now measures the row's top too.
// ⚠ ANCHORED AT THE TOP, NOT THE BOTTOM. This was bottom:-14px, so any growth pushed the row // ⚠ ANCHORED AT THE TOP, NOT THE BOTTOM. This was bottom:-14px, so any growth pushed the row
// UPWARD over the preview — which is exactly how wrapping it turned a sideways overflow into // UPWARD over the preview — which is exactly how wrapping it turned a sideways overflow into
// a vertical one the driver saw. Anchoring at top:100% pins it just under the card, so the // a vertical one. Anchoring at top:100% pins it just under the card, so the
// second line grows DOWN into empty space. MEASURED: 92px between the preview's bottom and // second line grows DOWN into empty space. MEASURED: 92px between the preview's bottom and
// the next card, of which the single-line row used 35 — 51px spare. // the next card, of which the single-line row used 35 — 51px spare.
// ★ THE FOOTER STICKER STRADDLES THE BANNER'S BOTTOM EDGE (Driver): "see how the header for // ★ THE FOOTER STICKER STRADDLES THE BANNER'S BOTTOM EDGE. The header (Show on map) sits on the
// show on map is inline on the top of the poster box? but the footer print options are not // top border of the poster box; the footer print options did not sit on the bottom one. The
// inline on the border or outline of the box... we want the footer to imitate the header, by // footer now imitates the header, on the edge like a piece of tape.
// being inline like a piece of tape".
// //
// top:100% + translate(-50%,-50%) centres it on that edge at ANY height, which matters because // top:100% + translate(-50%,-50%) centres it on that edge at ANY height, which matters because
// the row's height is NOT fixed: it holds two to four controls and grows with translated // the row's height is NOT fixed: it holds two to four controls and grows with translated
@ -887,8 +882,8 @@ useShadowStyles(
'.pbtn-deck::before{content:"\\25B8";display:inline-block;margin-right:3px;font-size:8px;' + '.pbtn-deck::before{content:"\\25B8";display:inline-block;margin-right:3px;font-size:8px;' +
'transition:transform .15s}' + 'transition:transform .15s}' +
'.pbtn-deck[aria-expanded="true"]::before{transform:rotate(90deg)}' + '.pbtn-deck[aria-expanded="true"]::before{transform:rotate(90deg)}' +
// Size choices. These are BUTTONS, not <option>s — the Driver ruled out a native // Size choices. These are BUTTONS, not <option>s — a native dropdown here would
// dropdown here because it collides with the other dropdowns on the host page. // collide with the other dropdowns on the host page.
// Each one both picks the size and prints, so it is styled as an action. // Each one both picks the size and prints, so it is styled as an action.
// ⚠ NO nowrap HERE, and this is a MEASURED correction rather than a preference. With // ⚠ NO nowrap HERE, and this is a MEASURED correction rather than a preference. With
// white-space:nowrap the two landscape labels rendered 161px inside a 169px row and CLIPPED // white-space:nowrap the two landscape labels rendered 161px inside a 169px row and CLIPPED
@ -905,7 +900,7 @@ useShadowStyles(
// wall: .pbtn 76x20, .pstock 188x20, .tape-h 289x22 -- 135 controls under the minimum on one // wall: .pbtn 76x20, .pstock 188x20, .tape-h 289x22 -- 135 controls under the minimum on one
// phone screen, and the same components fail on three other maps because they live in library/. // phone screen, and the same components fail on three other maps because they live in library/.
// SCOPED TO (pointer:coarse) ON PURPOSE: 2.5.8 is a TOUCH ergonomics rule, and these three // SCOPED TO (pointer:coarse) ON PURPOSE: 2.5.8 is a TOUCH ergonomics rule, and these three
// controls sit in a row whose height the Driver and I tuned by eye this session -- the tape must // controls sit in a row whose height was tuned by eye -- the tape must
// straddle the border without reaching the poster's blue outline, with ~9px of artwork clearance // straddle the border without reaching the poster's blue outline, with ~9px of artwork clearance
// left. Growing them for the mouse would spend 2px of that clearance to solve a problem a mouse // left. Growing them for the mouse would spend 2px of that clearance to solve a problem a mouse
// does not have, on a wall that is another owner's map. A finger gets the bigger target; the // does not have, on a wall that is another owner's map. A finger gets the bigger target; the
@ -966,9 +961,8 @@ useShadowStyles(
'box-shadow:0 6px 10px -8px rgba(0,0,0,.35);border-radius:4px 4px 0 0}' + 'box-shadow:0 6px 10px -8px rgba(0,0,0,.35);border-radius:4px 4px 0 0}' +
'.is-dark .sec-head{border-bottom-color:rgba(255,255,255,.12);background:#0a1410;' + '.is-dark .sec-head{border-bottom-color:rgba(255,255,255,.12);background:#0a1410;' +
'box-shadow:0 6px 10px -8px rgba(0,0,0,.7)}' + 'box-shadow:0 6px 10px -8px rgba(0,0,0,.7)}' +
// ★ THE TRANSPARENT STRIP ABOVE A PINNED HEADER (Driver 2026-09-25): "there is a transparent // ★ THE TRANSPARENT STRIP ABOVE A PINNED HEADER: while scrolling, the posters showed through a
// section above the header, so when you're scrolling the posters are visible above the header // transparent band between the pinned header and the top of the page.
// ... in the white space between the header and the top of the page."
// //
// CAUSE, MEASURED at 1440px: `position:sticky; top:0` pins to the scroll container's PADDING // CAUSE, MEASURED at 1440px: `position:sticky; top:0` pins to the scroll container's PADDING
// BOX, not its border box. .wall has padding-top:22px, so a pinned header comes to rest 22px // BOX, not its border box. .wall has padding-top:22px, so a pinned header comes to rest 22px
@ -1011,11 +1005,10 @@ useShadowStyles(
// Poster aspect is 3ft x 3ft SQUARE — the real print ratio, so the wall previews // Poster aspect is 3ft x 3ft SQUARE — the real print ratio, so the wall previews
// the actual artefact rather than an arbitrary box. // the actual artefact rather than an arbitrary box.
// ★ THE BANNER IS NOT THE MAP — and until now it effectively was. // ★ THE BANNER IS NOT THE MAP — and until now it effectively was.
// Driver 2026-09-25, the sticker/olive-oil-bottle brief: this box is a BANNER that DISPLAYS // This box is a BANNER that DISPLAYS the map, the way a label displays a design. A banner taped
// the map, the way a label displays a design. A banner taped to a wall gets a sticker at the // to a wall gets a sticker at the top and one at the bottom, each straddling its edge: a slight
// top and one at the bottom, each straddling its edge — "slight overlap over the banner, but // overlap over the banner, never over the banner's content, because tape over the map would
// not over the content of the banner... if I put a piece of tape over the text it would be // spoil it.
// offensive to the map."
// //
// MEASURED before this change, and it is the whole problem: banner 240-426, map 241-425. // MEASURED before this change, and it is the whole problem: banner 240-426, map 241-425.
// whiteSpaceTopOfBanner 1px, whiteSpaceBottomOfBanner 1px. The map filled the banner edge to // whiteSpaceTopOfBanner 1px, whiteSpaceBottomOfBanner 1px. The map filled the banner edge to
@ -1177,13 +1170,13 @@ useShadowStyles(
not arrive after both bases were tried, which says nothing about whether a not arrive after both bases were tried, which says nothing about whether a
poster exists. A record with genuinely no poster carries its own poster exists. A record with genuinely no poster carries its own
unsupportedReason and renders the branch above. Saying "not generated" of a unsupportedReason and renders the branch above. Saying "not generated" of a
poster that is sitting on disk is how the Driver came to report this. poster that is sitting on disk is how this was found.
i18n DEBT: this literal predates t() here and is still untranslated, like i18n DEBT: this literal predates t() here and is still untranslated, like
the two beside it. --> the two beside it. -->
<div class="ph-note">{{ T('poster.preview_unavailable', 'preview unavailable') }}</div> <div class="ph-note">{{ T('poster.preview_unavailable', 'preview unavailable') }}</div>
</div> </div>
<!-- ★ THE PRINT ROW LIVES INSIDE .main, NOT BESIDE IT (Driver 2026-09-25). <!-- ★ THE PRINT ROW LIVES INSIDE .main, NOT BESIDE IT.
It is `position:absolute; top:100%`, which resolves against the nearest It is `position:absolute; top:100%`, which resolves against the nearest
positioned ANCESTOR. As a SIBLING of .main that ancestor was .card, whose positioned ANCESTOR. As a SIBLING of .main that ancestor was .card, whose
bottom sits 26px past the frame — measured: frame bottom 426, row top 452. bottom sits 26px past the frame — measured: frame bottom 426, row top 452.
@ -1192,7 +1185,7 @@ useShadowStyles(
starts exactly on the border it is taping over. --> starts exactly on the border it is taping over. -->
<fieldset class="tape pfs"> <fieldset class="tape pfs">
<legend>{{ t('buttons.printOptions') }}</legend> <legend>{{ t('buttons.printOptions') }}</legend>
<!-- ★ PRAYER LIST IS NOW A DISCLOSURE TOO (Driver 2026-09-28), using the SAME <!-- ★ PRAYER LIST IS NOW A DISCLOSURE TOO, using the SAME
mechanism as Card Deck below — same toggleDeck, same one-open-at-a-time key, mechanism as Card Deck below — same toggleDeck, same one-open-at-a-time key,
same revealed .tape of plain buttons. NOT a <select> and NOT a new component, same revealed .tape of plain buttons. NOT a <select> and NOT a new component,
for the reason already given at the Card Deck comment: a native dropdown for the reason already given at the Card Deck comment: a native dropdown
@ -1204,11 +1197,9 @@ useShadowStyles(
@click.stop="toggleDeck(item.id, $event, 'packet')">{{ t('buttons.prayerList') }}</button> @click.stop="toggleDeck(item.id, $event, 'packet')">{{ t('buttons.prayerList') }}</button>
<button type="button" class="pbtn" @click="exportPoster(item)">{{ t('buttons.posterMap') }}</button> <button type="button" class="pbtn" @click="exportPoster(item)">{{ t('buttons.posterMap') }}</button>
<!-- ★ CARD DECK IS NOT A PEER OPTION, AND ITS SIZE PICKER IS HIDDEN UNTIL ASKED <!-- ★ CARD DECK IS NOT A PEER OPTION, AND ITS SIZE PICKER IS HIDDEN UNTIL ASKED
FOR (Driver 2026-09-25): "card should not be second option, and I don't want FOR: Cards is not the second option, and a visible dropdown for it would make it
to add the dropdown for the card because it makes it hard for others to have hard for the host page's own dropdowns to coexist; the size choice appears only
dropdowns too... only let you click on the dropdown when you click on print once Print cards is clicked.
the cards, which will show you a hidden dropdown to let you choose the print
size option you want."
So the deck is a DISCLOSURE, not a sibling button: clicking it reveals this So the deck is a DISCLOSURE, not a sibling button: clicking it reveals this
card's own size row, and the size buttons there are what actually print. card's own size row, and the size buttons there are what actually print.
A 162px <select> was the single widest control in a 172px row — removing it A 162px <select> was the single widest control in a 172px row — removing it
@ -1230,7 +1221,7 @@ useShadowStyles(
<!-- The revealed size row. Rendered as SIBLINGS in a second tape rather than a <!-- The revealed size row. Rendered as SIBLINGS in a second tape rather than a
<select> so there is no native dropdown to collide with other dropdowns — <select> so there is no native dropdown to collide with other dropdowns —
which is the constraint the Driver gave, not a styling preference. --> which is the constraint, not a styling preference. -->
<div v-if="canPrintRows && stockOptions.length && deckOpenFor === deckKey(item.id, 'deck')" <div v-if="canPrintRows && stockOptions.length && deckOpenFor === deckKey(item.id, 'deck')"
class="tape pstocks" role="group" :aria-label="t('buttons.cardStockAria')"> class="tape pstocks" role="group" :aria-label="t('buttons.cardStockAria')">
<button v-for="s in stockOptions" :key="s.id" type="button" class="pstock" <button v-for="s in stockOptions" :key="s.id" type="button" class="pstock"

View file

@ -1,7 +1,7 @@
<!-- <!--
GoalCard.vue — a headline figure with a goal line pinned to the bottom of the card. GoalCard.vue — a headline figure with a goal line pinned to the bottom of the card.
The Driver named these directly: "the banners are the cards that tell you the prayer progress". These are the banners: the cards that tell you the prayer progress.
Their RegionDetail.vue renders three of them in a row -- Prayer Coverage, Adoption Progress, Their RegionDetail.vue renders three of them in a row -- Prayer Coverage, Adoption Progress,
Engagement Progress -- identical in structure and different only in their numbers, their bar, and Engagement Progress -- identical in structure and different only in their numbers, their bar, and
one dark variant. That is one component with props, which is why it is here and not three copies. one dark variant. That is one component with props, which is why it is here and not three copies.
@ -67,8 +67,8 @@ useShadowStyles(
// `.region-goal { margin-top: auto }` then OVERRIDES the same property on the last child, // `.region-goal { margin-top: auto }` then OVERRIDES the same property on the last child,
// so the goal line is pushed to the bottom and the 23.526px stack gap above it is GONE. // so the goal line is pushed to the bottom and the 23.526px stack gap above it is GONE.
// This was ported as `gap`, which margin cannot override: gap AND the auto both applied, // This was ported as `gap`, which margin cannot override: gap AND the auto both applied,
// so every goal card carried ~23.5px of space their cards do not have. The DRIVER saw it // so every goal card carried ~23.5px of space their cards do not have. It showed as uneven
// ('spcing is nto ths ame', naming these three cards). Ported as their mechanism. // spacing on exactly these three cards. Ported as their mechanism.
'.rd-goal-card{ display:flex; flex-direction:column; text-align:center; }' + '.rd-goal-card{ display:flex; flex-direction:column; text-align:center; }' +
'.rd-goal-card > * + *{ margin-top:var(--spacing-lg); }' + '.rd-goal-card > * + *{ margin-top:var(--spacing-lg); }' +
@ -109,7 +109,7 @@ useShadowStyles(
// 59.60px that was breaking. The clamp helps the very problem the flat value was chosen for, and // 59.60px that was breaking. The clamp helps the very problem the flat value was chosen for, and
// the nowrap stays as a belt. // the nowrap stays as a belt.
// cqw not vw, consistent with every other clamp in this port (the container-vs-viewport // cqw not vw, consistent with every other clamp in this port (the container-vs-viewport
// substitution the Driver is still ruling on); at 768 that lands 42.4 against their 46.08. // substitution, provisional, pending a product decision); at 768 that lands 42.4 against their 46.08.
'.rd-big{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl));' + '.rd-big{ margin:0; font-size:clamp(var(--font-size-2xl), 6cqw, var(--font-size-4xl));' +
' font-weight:500; }' + ' font-weight:500; }' +
'.rd-goal-card .rd-big{ white-space:nowrap; }' + '.rd-goal-card .rd-big{ white-space:nowrap; }' +

View file

@ -3,9 +3,8 @@
* RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries, * RegionSectionCard.vue — one region as a card: a heading, a summary line, a grid of its countries,
* and a footer link. The DX-life /regions card, made reusable. * and a footer link. The DX-life /regions card, made reusable.
* *
* WHY THIS IS IN library/. The Driver, verbatim: "let's say we want to reuse that on future plugins. * WHY THIS IS IN library/. So future plugins can reuse it: a parameterized component where a caller
* We make it a reusable component, parameterized — you can set the region titles wherever you want * sets the region titles and fills in the countries. The design is the point as much
* them to be, fill in the countries to whatever you want them to be." The design is the point as much
* as the markup: the 156 lines of CSS below are lifted UNCHANGED from the regions plugin, so a caller * as the markup: the 156 lines of CSS below are lifted UNCHANGED from the regions plugin, so a caller
* gets the look, not a lookalike. * gets the look, not a lookalike.
* *

View file

@ -104,8 +104,8 @@ const menuStyle = ref({})
* tab: every trigger read dLeft=256 dTop=111, with the ancestor walk naming the culprit * tab: every trigger read dLeft=256 dTop=111, with the ancestor walk naming the culprit
* `div.vtl-content [contain:layout paint]`. The same dropdowns in the STANDALONE dashboard read * `div.vtl-content [contain:layout paint]`. The same dropdowns in the STANDALONE dashboard read
* dLeft=0 dTop=4. Same component, same CSS, two hosts, opposite results — which is why a probe * dLeft=0 dTop=4. Same component, same CSS, two hosts, opposite results — which is why a probe
* that only ever measured the standalone composite passed six times while the Driver, who uses * that only ever measured the standalone composite passed six times while the SPA kept showing the
* the SPA, kept reporting the menu "in a random spot further down to the right". It was never * menu in what looked like a random spot, further down and to the right. It was never
* random: it was the rail width plus the header height, exactly. * random: it was the rail width plus the header height, exactly.
* *
* ★ WHY NOT JUST DROP `contain` FROM THE LAYOUT. It is load-bearing there for a different reason * ★ WHY NOT JUST DROP `contain` FROM THE LAYOUT. It is load-bearing there for a different reason
@ -165,8 +165,8 @@ function positionMenu() {
// unreachable menu is worse than a detached one. That last case cannot mislead: the // unreachable menu is worse than a detached one. That last case cannot mislead: the
// trigger is not on screen to look detached FROM. // trigger is not on screen to look detached FROM.
// ── ★ THE MENU IS NOT THE TRIGGER'S WIDTH (card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU) ── // ── ★ THE MENU IS NOT THE TRIGGER'S WIDTH (card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU) ──
// DRIVER: "Make the filters very NARROW, but then the dropdown menu that opens up can be as wide // The filters are very NARROW, and the dropdown menu that opens can be as wide as it needs to
// as you want it to be… so you can fit MORE OF THEM ON ONE ROW WITHOUT WORD WRAPPING." // be, so MORE OF THEM FIT ON ONE ROW WITHOUT WORD WRAPPING.
// //
// THE COUPLING THAT MADE THAT IMPOSSIBLE was this one line: the menu was `min(r.width, …)` — the // THE COUPLING THAT MADE THAT IMPOSSIBLE was this one line: the menu was `min(r.width, …)` — the
// TRIGGER's measured width. The two boxes have OPPOSITE constraints and were sharing one number: // TRIGGER's measured width. The two boxes have OPPOSITE constraints and were sharing one number:
@ -371,8 +371,8 @@ onBeforeUnmount(() => {
/* ── ★ THE TRIGGER IS A FIXED BOX — IT MUST NOT GROW ON SELECTION ── /* ── ★ THE TRIGGER IS A FIXED BOX — IT MUST NOT GROW ON SELECTION ──
(card BUILD-FILTERS-FIXED-WIDTH-ELLIPSIS-NOT-GROWING) (card BUILD-FILTERS-FIXED-WIDTH-ELLIPSIS-NOT-GROWING)
DRIVER: "when you select something from the drop down it should not make the filter wider. Selecting something from the dropdown must not make the filter wider: the filter stays the
The filter should stay the same width. They should just use dot dot dot instead." same width and truncates with an ellipsis instead.
MEASURED BEFORE, in BOTH hosts (standalone leaf + cap-5 studio shell), 1600x900: MEASURED BEFORE, in BOTH hosts (standalone leaf + cap-5 studio shell), 1600x900:
empty : [160, 160, 160, 160, 160, 160] total 960 empty : [160, 160, 160, 160, 160, 160] total 960
@ -388,7 +388,7 @@ onBeforeUnmount(() => {
tighter range — it is to stop negotiating: ONE fixed width, and the label ellipsises inside it. tighter range — it is to stop negotiating: ONE fixed width, and the label ellipsises inside it.
WHY THIS IS NOT COSMETIC. The selections row sits UNDERNEATH this one. A trigger that grows WHY THIS IS NOT COSMETIC. The selections row sits UNDERNEATH this one. A trigger that grows
reflows the row below it, so the centred stack the Driver asked for could not hold still even reflows the row below it, so the centred stack could not hold still even
once it was centred. Fixing the width here is what makes the alignment above it STRUCTURAL. once it was centred. Fixing the width here is what makes the alignment above it STRUCTURAL.
--dx-filter-w is a TOKEN, not a literal, for the same reason every colour here is: this --dx-filter-w is a TOKEN, not a literal, for the same reason every colour here is: this
@ -398,14 +398,14 @@ onBeforeUnmount(() => {
second word". second word".
── ★ 168px -> 128px: THE TRIGGER AND THE MENU ARE SIZED SEPARATELY NOW ── ── ★ 168px -> 128px: THE TRIGGER AND THE MENU ARE SIZED SEPARATELY NOW ──
(card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU) DRIVER, four times: "the filters are way too (card BUILD-SLIM-FILTERS-NARROW-TRIGGER-WIDE-MENU) Asked four times: the filters were far too
wide. Make the filters very NARROW… so you can fit MORE OF THEM ON ONE ROW WITHOUT WORD WRAPPING. wide. Very NARROW filters fit MORE OF THEM ON ONE ROW WITHOUT WORD WRAPPING, and that in turn
And that way we can decrease the thickness of the headers." lets the headers get thinner.
The trigger no longer has to carry the SELECTED VALUE legibly, because positionMenu() now floors The trigger no longer has to carry the SELECTED VALUE legibly, because positionMenu() now floors
the open menu at 240px independent of this width (see its MENU_MIN note). That is what unlocks the open menu at 240px independent of this width (see its MENU_MIN note). That is what unlocks
the narrowing: the full value is always readable in the menu, so the closed trigger only has to the narrowing: the full value is always readable in the menu, so the closed trigger only has to
carry the AXIS NAME plus an ellipsis hint — which is all the Driver ever reads it for. carry the AXIS NAME plus an ellipsis hint — which is all a reader uses it for.
128px IS MEASURED, NOT PICKED — and the first number tried (116px) was measured WRONG, which is 128px IS MEASURED, NOT PICKED — and the first number tried (116px) was measured WRONG, which is
why the measurement is recorded here rather than the conclusion alone. The chrome budget is: why the measurement is recorded here rather than the conclusion alone. The chrome budget is:
@ -427,8 +427,8 @@ onBeforeUnmount(() => {
allowed to move (below). If an axis longer than "Engagement" is ever added, re-measure rather allowed to move (below). If an axis longer than "Engagement" is ever added, re-measure rather
than letting it truncate. than letting it truncate.
★ THE FONT-SIZE IS NOT A LEVER. The Driver has rejected shrinking it twice ("super tiny text ★ THE FONT-SIZE IS NOT A LEVER: shrinking it has been rejected twice, because text too small to
that cannot be read"). Width, padding and TRIGGER-label truncation are the only tools; 0.875rem read is not an option. Width, padding and TRIGGER-label truncation are the only tools; 0.875rem
on .filter-dropdown__trigger is load-bearing and must not be traded for density. */ on .filter-dropdown__trigger is load-bearing and must not be traded for density. */
.filter-dropdown { position: relative; flex: 0 0 auto; .filter-dropdown { position: relative; flex: 0 0 auto;
width: var(--dx-filter-w, 128px); min-width: var(--dx-filter-w, 128px); max-width: var(--dx-filter-w, 128px); } width: var(--dx-filter-w, 128px); min-width: var(--dx-filter-w, 128px); max-width: var(--dx-filter-w, 128px); }

View file

@ -190,19 +190,18 @@ function onClearAll() { emit('clear-all') }
<!-- ★ THE SELECTIONS ROW IS PERMANENT — it must never leave the DOM. <!-- ★ THE SELECTIONS ROW IS PERMANENT — it must never leave the DOM.
This was v-if="useFilters && chips.length", so the row DISAPPEARED whenever nothing was This was v-if="useFilters && chips.length", so the row DISAPPEARED whenever nothing was
selected. That is the actual bug, and it is layout shift rather than chip placement: selected. That is the actual bug, and it is layout shift rather than chip placement:
"whenever I deselect it, it removes that space from the second header... the decompression deselecting removed the row's height, so the ENTIRE LOWER APP SHIFTED UPWARD, which is very
of the height causes the ENTIRE LOWER APP TO SHIFT UPWARD, which makes it really hard to hard to track with the eyes.
track with your eyes." (Driver, 2026-09-22)
A row that appears on select and vanishes on clear moves the map, the table and the grid on A row that appears on select and vanishes on clear moves the map, the table and the grid on
every single click. The row now always renders and only its CONTENTS change, so nothing below every single click. The row now always renders and only its CONTENTS change, so nothing below
it can move. it can move.
The empty state is "World" — the Driver's word, and it reads correctly too: no filter really The empty state is "World", and it reads correctly: no filter really
does mean the whole world is in scope. Clear-all is omitted rather than the row collapsing, does mean the whole world is in scope. Clear-all is omitted rather than the row collapsing,
because there is nothing to clear; the row keeps its height either way. --> because there is nothing to clear; the row keeps its height either way. -->
<!-- ★ CLEAR ALL TRAVELS WITH THE CHIPS — it is the LAST CHILD OF THE SCROLL STRIP <!-- ★ CLEAR ALL TRAVELS WITH THE CHIPS — it is the LAST CHILD OF THE SCROLL STRIP
(card BUILD-CLEAR-ALL-SITS-BESIDE-THE-SELECTIONS-NOT-FAR-RIGHT). (card BUILD-CLEAR-ALL-SITS-BESIDE-THE-SELECTIONS-NOT-FAR-RIGHT).
DRIVER: "the clear all button is to look fancy, fancy Nancy. And it doesn't need to be at The Clear All button should look polished, and it belongs right next to the
the far right. It needs to be right next to the selected stuff." selections, not at the far right.
THIS DELIBERATELY REVERSES the previous instruction (kept below for the record, because THIS DELIBERATELY REVERSES the previous instruction (kept below for the record, because
the reasoning was sound and someone will be tempted to re-apply it): Clear All used to be the reasoning was sound and someone will be tempted to re-apply it): Clear All used to be
@ -285,7 +284,7 @@ function onClearAll() { emit('clear-all') }
which is why FilterDropdown's menu is position:FIXED and escapes the clip chain entirely which is why FilterDropdown's menu is position:FIXED and escapes the clip chain entirely
rather than trying to out-z-index it. Do not "restore" an overflow-y here; it cannot work. */ rather than trying to out-z-index it. Do not "restore" an overflow-y here; it cannot work. */
/* ★ CENTRED AS A GROUP, EVENLY SPACED (card BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK). /* ★ CENTRED AS A GROUP, EVENLY SPACED (card BUILD-THE-FOUR-HEADERS-ARE-ONE-CENTRED-STACK).
DRIVER: "evenly spaced out so that everything is centred, not justified left." Evenly spaced, so that everything is centred rather than justified left.
MEASURED BEFORE at 1600x900 standalone: the triggers started 14px from the left edge and ended MEASURED BEFORE at 1600x900 standalone: the triggers started 14px from the left edge and ended
242px from the right — a quarter-screen of dead space on one side only, which is precisely what 242px from the right — a quarter-screen of dead space on one side only, which is precisely what
@ -302,15 +301,15 @@ function onClearAll() { emit('clear-all') }
The unprefixed fallback is listed first for engines without `safe` — they get plain centring, The unprefixed fallback is listed first for engines without `safe` — they get plain centring,
which is the pre-existing behaviour for the fitting case and no worse for the other. */ which is the pre-existing behaviour for the fitting case and no worse for the other. */
/* ★ THE SLACK GOES BETWEEN THE TRIGGERS, NOT INTO TWO EDGE VOIDS /* ★ THE SLACK GOES BETWEEN THE TRIGGERS, NOT INTO TWO EDGE VOIDS
(card BUILD-CENTRE-THE-SEARCH-BAR-AND-SPREAD-THE-FILTER-TOGGLES). DRIVER: "the dropdown (card BUILD-CENTRE-THE-SEARCH-BAR-AND-SPREAD-THE-FILTER-TOGGLES). The filter dropdown
toggles for the filters need to be centred also, so that they FILL UP THE FULL WIDTH, equally toggles are centred too, so that they FILL THE FULL WIDTH of the second header, equally
spaced to fill up the second header." spaced.
MEASURED at 1600x900 standalone BEFORE this change: the row is 1572px wide and its content is MEASURED at 1600x900 standalone BEFORE this change: the row is 1572px wide and its content is
6 triggers x 168px + 5 gaps x 10px = 1058px — so scrollWidth (1572) == clientWidth (1572), 6 triggers x 168px + 5 gaps x 10px = 1058px — so scrollWidth (1572) == clientWidth (1572),
`overflowing: false`, with 514px of slack. Centring alone therefore parks ~257px of dead space `overflowing: false`, with 514px of slack. Centring alone therefore parks ~257px of dead space
at EACH edge, which is what "not filling the width" looks like in numbers. The row is NOT at EACH edge, which is what "not filling the width" looks like in numbers. The row is NOT
overflowing at desktop width, so the horizontal scrollbar the Driver complained about is a overflowing at desktop width, so the reported horizontal scrollbar is a
narrow-viewport artifact, not the desktop case. narrow-viewport artifact, not the desktop case.
`space-between` would push the first and last triggers hard against the edges, breaking the `space-between` would push the first and last triggers hard against the edges, breaking the
@ -328,14 +327,14 @@ function onClearAll() { emit('clear-all') }
The studio host was measured overflowing once already (right gutter -40), so this is a live The studio host was measured overflowing once already (right gutter -40), so this is a live
case, not a hypothetical — do not simplify this to a single unguarded value. */ case, not a hypothetical — do not simplify this to a single unguarded value. */
/* ★ WRAP, DO NOT SCROLL (2026-09-23). This row was flex-wrap:nowrap + overflow-x:auto — a /* ★ WRAP, DO NOT SCROLL (2026-09-23). This row was flex-wrap:nowrap + overflow-x:auto — a
horizontal SCROLLER. The Driver's constraint is explicit: "I shouldn't see any horizontal scroll horizontal SCROLLER. The constraint is explicit: no horizontal scrollbar in the filter section;
bar in the filter section. You need to resize everything, but everything is visible." everything resizes and everything stays visible.
MEASURED with the eighth dropdown (engagement/Status) added: the dropdowns are a fixed 168px on a MEASURED with the eighth dropdown (engagement/Status) added: the dropdowns are a fixed 168px on a
200px pitch, so eight need ~1428px. At a 1440 viewport that just fits (maxRight 1428); at 1280 it 200px pitch, so eight need ~1428px. At a 1440 viewport that just fits (maxRight 1428); at 1280 it
did not, and sfh__filters-row reported scrollWidth 1414 against clientWidth 1252 — a scrollbar, did not, and sfh__filters-row reported scrollWidth 1414 against clientWidth 1252 — a scrollbar,
exactly what he asked not to see. exactly what he asked not to see.
Wrapping is the right lever rather than shrinking the dropdowns: their fixed 168px width is itself Wrapping is the right lever rather than shrinking the dropdowns: their fixed 168px width is itself
a Driver decision ("filters not so wide"), and a flexible width was what made them jump as a a product decision (filters not so wide), and a flexible width was what made them jump as a
selection changed. So the row now flows onto a second line when it must. selection changed. So the row now flows onto a second line when it must.
★ AND START THE WRAPPED LINE ON THE SAME LEFT EDGE (2026-09-23). `space-evenly` was kept here ★ AND START THE WRAPPED LINE ON THE SAME LEFT EDGE (2026-09-23). `space-evenly` was kept here
when wrapping landed, on the reasoning that `safe` made it harmless. That reasoning was about a when wrapping landed, on the reasoning that `safe` made it harmless. That reasoning was about a
@ -384,12 +383,12 @@ function onClearAll() { emit('clear-all') }
set TOGETHER, every time either moves. set TOGETHER, every time either moves.
★ 18px, AND THE OLD 24px MEASUREMENT IS NOT EVIDENCE IT WAS RIGHT ★ 18px, AND THE OLD 24px MEASUREMENT IS NOT EVIDENCE IT WAS RIGHT
(card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED). DRIVER, twice in one message: "the selected (card BUILD-SELECTED-ROW-MUCH-THINNER-AND-CENTRED). Asked twice in one message: the selected
row is way too tall, needs to be thinner, a lot thinner, a lot more subtle." row was far too tall, and needed to be a lot thinner and a lot more subtle.
A previous fix asserted rowH=24 identical across empty / one-chip / many-chip states and cited it A previous fix asserted rowH=24 identical across empty / one-chip / many-chip states and cited it
as done. That measurement was TRUE and it proved the row does not RESIZE — it never said a word as done. That measurement was TRUE and it proved the row does not RESIZE — it never said a word
about whether 24 is the right number, which is a different question that only the Driver can about whether 24 is the right number, which is a different question, a product one. The
answer. He has now answered it: still too tall. Do not re-cite a stability measurement as a answer came back: still too tall. Do not re-cite a stability measurement as a
sizing one. sizing one.
He named THREE height sources, so all three come down together — reducing only the row would He named THREE height sources, so all three come down together — reducing only the row would
have been the same partial fix again: have been the same partial fix again:
@ -398,8 +397,8 @@ function onClearAll() { emit('clear-all') }
3. the CHIP BUTTONS -> .sfh__chip height 22 -> 18, font 0.9375rem -> 0.8125rem 3. the CHIP BUTTONS -> .sfh__chip height 22 -> 18, font 0.9375rem -> 0.8125rem
--dx-sel-row-h keeps the floor tokenized so a consumer can trade density back without forking. --dx-sel-row-h keeps the floor tokenized so a consumer can trade density back without forking.
★ CENTRED: justify-content:center, because the Driver puts the selections directly above the map ★ CENTRED: justify-content:center, because the selections sit directly above the map, centred
— "that needs to be centred... right above the globe, centred, is going to be what is selected." right above the globe.
★ THE ROW ITSELF STILL DOES NOT SCROLL. overflow-x lives on .sfh__chip-strip. That split is now ★ THE ROW ITSELF STILL DOES NOT SCROLL. overflow-x lives on .sfh__chip-strip. That split is now
doing a different job than when it was introduced (it was there to pin Clear All OUTSIDE the doing a different job than when it was introduced (it was there to pin Clear All OUTSIDE the
scroller; Clear All has since moved INSIDE, per its own card) — it is kept because a scroll box scroller; Clear All has since moved INSIDE, per its own card) — it is kept because a scroll box
@ -418,7 +417,7 @@ function onClearAll() { emit('clear-all') }
would widen the row instead of scrolling inside itself. would widen the row instead of scrolling inside itself.
★ flex is now `0 1 auto`, not `1 1 auto`. Growing was only needed while Clear All sat OUTSIDE ★ flex is now `0 1 auto`, not `1 1 auto`. Growing was only needed while Clear All sat OUTSIDE
as a pinned sibling — the strip had to eat all the free space to shove it to the far right. as a pinned sibling — the strip had to eat all the free space to shove it to the far right.
That is the layout the Driver just reversed, so the growth term goes with it: at 0 1 auto the That is the layout that was just reversed, so the growth term goes with it: at 0 1 auto the
strip is only as wide as its chips (shrinking to fit when they overflow), which is what lets strip is only as wide as its chips (shrinking to fit when they overflow), which is what lets
.sfh__chips centre it as a group rather than centring a full-width box that merely CONTAINS .sfh__chips centre it as a group rather than centring a full-width box that merely CONTAINS
left-aligned chips. Keeping flex-grow here would have silently defeated the centring above. left-aligned chips. Keeping flex-grow here would have silently defeated the centring above.
@ -440,8 +439,8 @@ function onClearAll() { emit('clear-all') }
would have been a fixed-width item that the centring had to work around, pushing the chips would have been a fixed-width item that the centring had to work around, pushing the chips
off-centre by its own width. It scrolls with the chips now, which is the same trade Clear All off-centre by its own width. It scrolls with the chips now, which is the same trade Clear All
makes and for the same reason: the label describes the chips, so it belongs with them. makes and for the same reason: the label describes the chips, so it belongs with them.
0.6875rem + subtler colour: this is a LABEL, not content — the Driver asked for the row to read 0.6875rem + subtler colour: this is a LABEL, not content — the row is to read a lot more
"a lot more subtle", and shrinking the all-caps prefix is the cheapest way to drop the row's subtly, and shrinking the all-caps prefix is the cheapest way to drop the row's
visual weight without touching the chips' legibility. */ visual weight without touching the chips' legibility. */
.sfh__sel-label { flex: 0 0 auto; font-size: 0.6875rem; font-weight: 700; letter-spacing: .5px; .sfh__sel-label { flex: 0 0 auto; font-size: 0.6875rem; font-weight: 700; letter-spacing: .5px;
text-transform: uppercase; color: var(--mut, #6d766e); opacity: .75; } text-transform: uppercase; color: var(--mut, #6d766e); opacity: .75; }
@ -452,8 +451,8 @@ function onClearAll() { emit('clear-all') }
/* ★ SOURCE 3 OF THE THREE HEIGHT SOURCES — "the size of the buttons that show up whenever you /* ★ SOURCE 3 OF THE THREE HEIGHT SOURCES — "the size of the buttons that show up whenever you
select something." 22px -> 18px, and the type comes DOWN with it (0.9375rem -> 0.8125rem). select something." 22px -> 18px, and the type comes DOWN with it (0.9375rem -> 0.8125rem).
NOTE THIS REVERSES AN EARLIER, CORRECTLY-FOLLOWED INSTRUCTION. A previous round was told NOTE THIS REVERSES AN EARLIER, CORRECTLY-FOLLOWED INSTRUCTION. A previous round was told
"increase the font slightly of the selected row", and did: 0.85rem -> 0.9375rem. The Driver is "increase the font slightly of the selected row", and did: 0.85rem -> 0.9375rem. The whole row
now asking for the whole row to be "a lot more subtle", and at 18px a 0.9375rem glyph does not is now to be a lot more subtle, and at 18px a 0.9375rem glyph does not
fit its box. The newer instruction wins; this is not a regression of the older one. fit its box. The newer instruction wins; this is not a regression of the older one.
white-space:nowrap keeps a two-word label on one line — a wrapped chip would grow the row and white-space:nowrap keeps a two-word label on one line — a wrapped chip would grow the row and
re-break the fixed-height invariant. re-break the fixed-height invariant.
@ -477,7 +476,7 @@ function onClearAll() { emit('clear-all') }
the user the whole view. the user the whole view.
THE ROW HEIGHT IS NOT NEGOTIABLE and must not be touched here: the three comment blocks above THE ROW HEIGHT IS NOT NEGOTIABLE and must not be touched here: the three comment blocks above
record a measured layout-shift bug (18px vs a 24.25px chipped row walking the map 180 -> 182.25px) record a measured layout-shift bug (18px vs a 24.25px chipped row walking the map 180 -> 182.25px)
and the Driver's "a lot more subtle" instruction that set 16px. Growing the button would reopen and the "a lot more subtle" instruction that set 16px. Growing the button would reopen
both. both.
So the HIT AREA grows and the BOX does not. An absolutely-positioned ::after centred on the So the HIT AREA grows and the BOX does not. An absolutely-positioned ::after centred on the
button claims 24x24 of pointer/touch surface while contributing nothing to the flex row's button claims 24x24 of pointer/touch surface while contributing nothing to the flex row's

View file

@ -49,7 +49,7 @@ function exactIndices(haystack, needle) {
// (threshold 0.4, ignoreLocation) tolerates TYPOS -- an extra trailing letter ("Bhutann"), // (threshold 0.4, ignoreLocation) tolerates TYPOS -- an extra trailing letter ("Bhutann"),
// a substituted one ("Indoa") -- because bitap scores k errors against the pattern length. // a substituted one ("Indoa") -- because bitap scores k errors against the pattern length.
// A subsequence can only ever DROP haystack chars; an EXTRA needle char always fails it. // A subsequence can only ever DROP haystack chars; an EXTRA needle char always fails it.
// Measured on the live dashboard: 'Bhutan' -> 13 rows, 'Bhutann' -> 0. The Driver's test. // Measured on the live dashboard: 'Bhutan' -> 13 rows, 'Bhutann' -> 0, the reported test.
// //
// So this is the standard approximate-substring DP (Sellers/Ukkonen "k-errors" matching): // So this is the standard approximate-substring DP (Sellers/Ukkonen "k-errors" matching):
// best edit distance of the needle against ANY window of the haystack (free start, free end), // best edit distance of the needle against ANY window of the haystack (free start, free end),
@ -63,7 +63,7 @@ function approxIndices(haystack, needle, threshold) {
// ★ ABSOLUTE CAP AT 2 EDITS (the classic did-you-mean bound). threshold*len alone grows with // ★ ABSOLUTE CAP AT 2 EDITS (the classic did-you-mean bound). threshold*len alone grows with
// the term: at 8+ chars it allowed 3 errors, and 3-edit windows are how 'Canadian' -- a country // the term: at 8+ chars it allowed 3 errors, and 3-edit windows are how 'Canadian' -- a country
// with ZERO rows in this dataset -- raked 47 rows of unrelated facets. Every real typo the // with ZERO rows in this dataset -- raked 47 rows of unrelated facets. Every real typo the
// Driver reported (Bhutann, Banladesh, Austrian->Austria, Canadian->Canada) is 1-2 edits. // reported (Bhutann, Banladesh, Austrian->Austria, Canadian->Canada) is 1-2 edits.
const maxErr = Math.min(2, Math.floor(threshold * m)) const maxErr = Math.min(2, Math.floor(threshold * m))
if (maxErr <= 0) return null if (maxErr <= 0) return null
// dp[j] = min errors matching the whole prefix n[0..i] ending at text position j. // dp[j] = min errors matching the whole prefix n[0..i] ending at text position j.
@ -88,9 +88,8 @@ function approxIndices(haystack, needle, threshold) {
/** /**
* Low-level predicates shared with the dashboard's search matcher (useDashboardShell.js). * Low-level predicates shared with the dashboard's search matcher (useDashboardShell.js).
* *
* ★ TIERED, NOT A UNION (Driver 2026-09-24: "fuzzy search is too fuzzy -- Bhutan returns more * ★ TIERED, NOT A UNION: search was too fuzzy (Bhutan returned more than the country) and did not
* results than just the country, and there is no interoperability with the other filters like * agree with the other filters, such as choosing it as a Place. The first fuzzy pass returned exact matches PLUS every <=k-error
* selecting it as a place"). The first fuzzy pass returned exact matches PLUS every <=k-error
* neighbour, so the EXACT term 'Bhutan' counted 132 rows while the Country filter counted 13 -- * neighbour, so the EXACT term 'Bhutan' counted 132 rows while the Country filter counted 13 --
* two controls disagreeing about the same place. The conventional contract is tiered: when a * two controls disagreeing about the same place. The conventional contract is tiered: when a
* term has exact matches, return ONLY those (search now agrees with the facet, 13 = 13); the * term has exact matches, return ONLY those (search now agrees with the facet, 13 = 13); the
@ -106,9 +105,8 @@ export function exactMatches(text, term) {
return raw.toLowerCase().includes(q.toLowerCase()) return raw.toLowerCase().includes(q.toLowerCase())
} }
/** /**
* ★ TIER 2 — FACET-VALUE RESOLUTION (Driver 2026-09-24: "if I search the name of the country * ★ TIER 2 — FACET-VALUE RESOLUTION: a country's name with an n added (Austrian, Canadian) selects
* and add n to it, like Austrian or Canadian, I would like for there to be a selection of that * that country in the search results, so the table under the map shows that country's result.
* country in the search results -- the table under the map shows the result for that country").
* Given the DISTINCT values of a facet (country labels, region names...), return the values the * Given the DISTINCT values of a facet (country labels, region names...), return the values the
* term fuzzy-resolves to. A term like 'Austrian' then selects rows by country === 'Austria' -- * term fuzzy-resolves to. A term like 'Austrian' then selects rows by country === 'Austria' --
* byte-identical to picking the country as a Place filter -- instead of raking every text field * byte-identical to picking the country as a Place filter -- instead of raking every text field

View file

@ -3,14 +3,14 @@
* *
* ★ THE ONE print mechanism, graduated VERBATIM from my-upg-100-list/poster-wall.vue * ★ THE ONE print mechanism, graduated VERBATIM from my-upg-100-list/poster-wall.vue
* openPrintWindow() (commit e957cbe "Print the poster in place, on a portrait sheet") on the * openPrintWindow() (commit e957cbe "Print the poster in place, on a portrait sheet") on the
* Driver's ruling that killed the pop-up path: print must happen IN PLACE — no new browser * ruling that killed the pop-up path: print must happen IN PLACE — no new browser
* window, no pop-up blocker, the dialog rises over the page the user is looking at. * window, no pop-up blocker, the dialog rises over the page the user is looking at.
* *
* WHY IT GRADUATES (library high bar: reusable + parameterized, 2+ consumers): the * WHY IT GRADUATES (library high bar: reusable + parameterized, 2+ consumers): the
* prayer-walk-planner's print still used window.open(), and that is not just the rejected UX -- * prayer-walk-planner's print still used window.open(), and that is not just the rejected UX --
* it is AGENT-HOSTILE. window.open() without a user gesture is pop-up-blocked, so * it is AGENT-HOSTILE. window.open() without a user gesture is pop-up-blocked, so
* PrayerWalkPlanner.print() called from an agent (the Driver: "the agent can also create * PrayerWalkPlanner.print() called from an agent (an agent can also create polygons and
* polygons and trigger the print function") silently did nothing. An iframe cannot be blocked * trigger the print function) silently did nothing. An iframe cannot be blocked
* and needs no gesture, which is what makes the same call work for a human click AND an agent. * and needs no gesture, which is what makes the same call work for a human click AND an agent.
* *
* Mechanics preserved from the poster-wall original, each for a measured reason: * Mechanics preserved from the poster-wall original, each for a measured reason:
@ -49,7 +49,7 @@ export function openPrintFrame(bodyHtml, { autoprint = false } = {}) {
/** /**
* Print a rendered poster IMAGE, at its real paper size, through the same in-place frame. * Print a rendered poster IMAGE, at its real paper size, through the same in-place frame.
* *
* ★ THE DRIVER'S RULE (2026-09-28): an "Export poster" button opens the PRINT DIALOG with a preview. * ★ THE RULE (2026-09-28): an "Export poster" button opens the PRINT DIALOG with a preview.
* It never hands over a file. Simple Research was rendering a PNG and calling a download helper, so * It never hands over a file. Simple Research was rendering a PNG and calling a download helper, so
* the reader got doxa-prayer-poster.png in their downloads folder and no preview, no paper size, and * the reader got doxa-prayer-poster.png in their downloads folder and no preview, no paper size, and
* no way to print it without opening it somewhere else first. The dialog IS the preview: the sheet the * no way to print it without opening it somewhere else first. The dialog IS the preview: the sheet the

View file

@ -3,8 +3,8 @@
* *
* ★ WHY (card BUG-LAZY-PLUGIN-DEATH-ON-A-SINGLE-FAILED-IMPORT). Vue's defineAsyncComponent * ★ WHY (card BUG-LAZY-PLUGIN-DEATH-ON-A-SINGLE-FAILED-IMPORT). Vue's defineAsyncComponent
* caches a REJECTED loader promise: once a dynamic import() fails, that component is dead for * caches a REJECTED loader promise: once a dynamic import() fails, that component is dead for
* the life of the page — every later render replays the same rejection. The Driver hit exactly * the life of the page — every later render replays the same rejection. It happened live, exactly
* this: one net::ERR_NETWORK_CHANGED event (wifi switch / VPN / sleep-wake, a TRANSPORT blip, * like this: one net::ERR_NETWORK_CHANGED event (wifi switch / VPN / sleep-wake, a TRANSPORT blip,
* not a 404) failed five unrelated module fetches at the same moment, and doxa-simple-map + * not a 404) failed five unrelated module fetches at the same moment, and doxa-simple-map +
* doxa-research-map stayed "not loading" until a full reload — a working product reading as * doxa-research-map stayed "not loading" until a full reload — a working product reading as
* two broken plugins. * two broken plugins.

View file

@ -234,8 +234,7 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
const provenance = `# ${exportLabel} export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}` const provenance = `# ${exportLabel} export — scope: ${scopeLabel()}; filters: ${chips}; rows: ${picked.length}`
+ (sources ? `; sources: ${sources}` : '') + (sources ? `; sources: ${sources}` : '')
// ★ THE PROVENANCE LINE GOES LAST, NOT FIRST — that one position was the whole "glitched CSV". // ★ THE PROVENANCE LINE GOES LAST, NOT FIRST — that one position was the whole "glitched CSV".
// Driver-reported 2026-09-22: "this is me exporting the CSV directly without making any edits... // Reported 2026-09-22: a CSV exported directly, with no edits, looked a little glitched out.
// it does look a little bit glitched out."
// //
// MEASURED with a strict RFC-4180 parser against this exact generator. EVERYTHING ELSE IS CORRECT // MEASURED with a strict RFC-4180 parser against this exact generator. EVERYTHING ELSE IS CORRECT
// and must not be "fixed" on suspicion: // and must not be "fixed" on suspicion:
@ -244,7 +243,7 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
// rows ... N columns each, ZERO with a wrong column count // rows ... N columns each, ZERO with a wrong column count
// embedded commas, embedded double quotes AND embedded newlines all survive the round trip, so // embedded commas, embedded double quotes AND embedded newlines all survive the round trip, so
// esc() is genuinely RFC-4180 (quote when , " CR LF appear; double the internal quotes). The // esc() is genuinely RFC-4180 (quote when , " CR LF appear; double the internal quotes). The
// file is comma-separated, not tab-separated — the tabs in the Driver's paste were his terminal // file is comma-separated, not tab-separated — the tabs in the pasted report were a terminal's
// rendering, not the bytes. // rendering, not the bytes.
// //
// The defect is purely POSITIONAL: every spreadsheet importer treats the FIRST line as the header. // The defect is purely POSITIONAL: every spreadsheet importer treats the FIRST line as the header.

View file

@ -39,9 +39,8 @@
* the table's share is derived as shellH - mapH (see the CSS in research-dashboard.vue). Before * the table's share is derived as shellH - mapH (see the CSS in research-dashboard.vue). Before
* this, --rd-map-h alone was BOTH "the map/table split" AND "the element's total height" — one * this, --rd-map-h alone was BOTH "the map/table split" AND "the element's total height" — one
* knob answering two questions, which is why dragging the map/table divider used to resize the * knob answering two questions, which is why dragging the map/table divider used to resize the
* whole element as a side effect. Driver: "you improperly bound that control to the separator * whole element as a side effect: the divider between the map and the table was bound to the
* line between the map and the table... that's not the request I was making to resize the * page's height, which is not what that control is for.
* height of the page."
* *
* COMPOSITION with the collapse-table fix (do NOT regress): * COMPOSITION with the collapse-table fix (do NOT regress):
* The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule * The shell's DEFAULT rows are `52px auto 42px 1fr var(--table-h,34%)` while the collapsed rule
@ -106,34 +105,29 @@ const DEFAULT_STORAGE_KEY = 'rd-dashboard-layout-v6'
// shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table // shellH default (720) is the SUM the old single-knob mapH(360) used to imply — map 360 + table
// budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split; // budget 360 (328 grid + 32 bar) — so first paint is pixel-identical to before this axis split;
// only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above). // only DRAGGING behaves differently now (see the SHELL vs. TABLE axis note above).
// ★ shellH 720 -> 602 so the table OPENS at seven data rows (Driver 2026-09-23: "the table needs to // ★ shellH 720 -> 602 so the table OPENS at seven data rows by default. 602 = map 360 + bar 32 + 7 rows x 26 + foot 28.
// only show SEVEN rows by DEFAULT height"). 602 = map 360 + bar 32 + 7 rows x 26 + foot 28.
// ★ A DEFAULT, NOT A LOCK — and the distinction is the whole correction. My first attempt hard-capped // ★ A DEFAULT, NOT A LOCK — and the distinction is the whole correction. My first attempt hard-capped
// the keyhole at 7 rows in CSS, which read as the same thing and was not: nothing writes // the keyhole at 7 rows in CSS, which read as the same thing and was not: nothing writes
// --rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h, so the cap could not // --rd-table-rows, while the drag handles write --rd-shell-h and --rd-map-h, so the cap could not
// be reached by any gesture. The Driver lost BOTH resizes at once and said so: "I didn't want the // be reached by any gesture: BOTH resizes were lost at once, when only the table was meant to
// table set to 7. Only the table needs to be resizable. We've set it to where I can't even resize." // change (seven rows as a default, still resizable). Setting the STARTING VALUE instead leaves the
// Setting the STARTING VALUE instead leaves the derivation intact: the keyhole stays // derivation intact: the keyhole stays
// shell - map - bar - foot, so dragging either handle still moves the table, and seven rows is // shell - map - bar - foot, so dragging either handle still moves the table, and seven rows is
// merely where it opens. // merely where it opens.
// ★ RECLAIM PASS 2 (Driver 2026-09-23: "it is a priority to reclaim height... so I can see half // ★ RECLAIM PASS 2: reclaim height so half of the People Groups grid's first row shows. mapH 360 -> 300 and shellH follows the same
// of the first row of the people groups grid"). mapH 360 -> 300 and shellH follows the same
// arithmetic as before: 300 map + 32 bar + 7x26 rows + 28 foot = 542. The seven-row table // arithmetic as before: 300 map + 32 bar + 7x26 rows + 28 foot = 542. The seven-row table
// promise is unchanged; only the map's share shrinks -- and the map compensates by DEFAULTING // promise is unchanged; only the map's share shrinks -- and the map compensates by DEFAULTING
// MORE ZOOMED OUT with the world-floor released on the globe (see DashboardMapPanel), so a // MORE ZOOMED OUT with the world-floor released on the globe (see DashboardMapPanel), so a
// shorter map shows MORE world, not less. // shorter map shows MORE world, not less.
// shellH 502 -> 542 (Driver 2026-09-23: "the dashboard should be taller by about 40px"). The // shellH 502 -> 542: the dashboard about 40px taller. The
// extra 40px flows to the TABLE via the standing derivation (table = shell - map - bar - foot); // extra 40px flows to the TABLE via the standing derivation (table = shell - map - bar - foot);
// the map row stays pinned at mapH. One owner: this number, with the CSS var fallbacks in // the map row stays pinned at mapH. One owner: this number, with the CSS var fallbacks in
// research-dashboard.vue kept byte-equal at 542. // research-dashboard.vue kept byte-equal at 542.
// mapH 260 -> 300 (Driver 2026-09-23 follow-up: the +40px was to be "added to the map -- map is // mapH 260 -> 300: the +40px belongs to the map, 40px more display room. shellH stays 542, so the table's derived share returns to its
// 40px taller display room"). shellH stays 542, so the table's derived share returns to its
// pre-+40 height and the whole gain lands in the map row. // pre-+40 height and the whole gain lands in the map row.
// +40px again (Driver 2026-09-23 second raise: "go taller with the dashboard by another 40px, // +40px again, added to the map's height: shellH 542 -> 582 and mapH 300 -> 340 move together, so
// add that to the height of the map"): shellH 542 -> 582 and mapH 300 -> 340 move together, so
// the whole gain lands in the map row and the table's derived share stays put. // the whole gain lands in the map row and the table's derived share stays put.
// Driver fine-tune 2026-09-23: "almost perfect height -- keep the map the same height, but // Fine-tune: the map keeps its height and the dashboard loses about 20px. Map stays 340; the 20px comes out of
// decrease the height of the dashboard by about 20px". Map stays 340; the 20px comes out of
// the table's derived share (shell - map - bar - foot). // the table's derived share (shell - map - bar - foot).
const DEFAULTS = { facetW: 296, railW: 296, tableH: '34%', mapH: 340, shellH: 562 } const DEFAULTS = { facetW: 296, railW: 296, tableH: '34%', mapH: 340, shellH: 562 }
const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] } const CLAMPS = { facetW: [160, 420], railW: [200, 520], tableH: [12, 70], mapH: [220, 1400], shellH: [400, 2600] }
@ -157,16 +151,16 @@ export function useDashboardLayout(options = {}) {
bus = null, bus = null,
isNarrow = false, isNarrow = false,
pageScroll = false, pageScroll = false,
// ★ KEY VERSIONED v2 (2026-09-23). The Driver reported "sidebars are not equally spaced" on // ★ KEY VERSIONED v2. The sidebars still looked unequal AFTER the symmetric-rail change landed
// his machine AFTER the symmetric-rail change landed -- because his browser restores a layout // -- because a browser restores a layout
// SAVED under the old defaults (rail 264), and a restored value outranks any new default by // SAVED under the old defaults (rail 264), and a restored value outranks any new default by
// design. When defaults change materially, the old saved layouts are prescriptions from a // design. When defaults change materially, the old saved layouts are prescriptions from a
// different bottle: bump the key so every reader starts from the new defaults once, and a // different bottle: bump the key so every reader starts from the new defaults once, and a
// fresh drag re-personalises from there. The v1 blob is left in place, inert. // fresh drag re-personalises from there. The v1 blob is left in place, inert.
// v2 -> v3: retire stored layouts so the new 542 default is actually SEEN -- a persisted // v2 -> v3: retire stored layouts so the new 542 default is actually SEEN -- a persisted
// shellH:502 from the previous default would silently mask this change on the Driver's browser. // shellH:502 from the previous default would silently mask this change in any browser that saved one.
// ★ NAMESPACED PER DASHBOARD, AND THAT IS THE ONE BEHAVIOUR CHANGE IN THIS MOVE. // ★ NAMESPACED PER DASHBOARD, AND THAT IS THE ONE BEHAVIOUR CHANGE IN THIS MOVE.
// SPEC-DASHBOARD-KIT section 8 named it as landing 2's risk: this key was a single constant, so // This was the move's named risk: the key was a single constant, so
// the moment a page carries TWO dashboards they read and write one another's layout. Dragging a // the moment a page carries TWO dashboards they read and write one another's layout. Dragging a
// divider in one would move the other's on next load, and neither would be wrong on its own. // divider in one would move the other's on next load, and neither would be wrong on its own.
// While there was exactly one dashboard that was invisible; sharing the engine is precisely // While there was exactly one dashboard that was invisible; sharing the engine is precisely

View file

@ -45,8 +45,8 @@ export function buildSearchAccessors(config) {
* array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value. * array from buildSearchAccessors(config). Case-insensitive substring over each accessor's value.
*/ */
export function makeSearchMatcher(bus, accessors) { export function makeSearchMatcher(bus, accessors) {
// ★ FUZZY, NOT SUBSTRING (Driver 2026-09-23: "add an n to the end of a country name" must // ★ FUZZY, NOT SUBSTRING: a country name with an n added must still find it (matching what
// still find it -- check what DX-life's search bar bakes in). DX-life runs real Fuse.js with // DX-life's search bar bakes in). DX-life runs real Fuse.js with
// { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its // { threshold: 0.4, ignoreLocation: true, minMatchCharLength: 3 } (UupgsList.vue:288), so its
// search tolerates typos. This matcher was a plain .includes() -- the one search surface that // search tolerates typos. This matcher was a plain .includes() -- the one search surface that
// silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0). // silently lost that behaviour in the migration (measured: 'Bhutan' 13 rows, 'Bhutann' 0).
@ -75,8 +75,8 @@ export function makeSearchMatcher(bus, accessors) {
/** /**
* Apply the TIERED search to a row set: * Apply the TIERED search to a row set:
* 1. exact substring -- an exact term returns exactly the rows the equivalent facet selects. * 1. exact substring -- an exact term returns exactly the rows the equivalent facet selects.
* 2. facet-VALUE resolution (Driver: "Austrian or Canadian... a selection of that country -- * 2. facet-VALUE resolution (Austrian or Canadian selects that country, and the table under the
* the table under the map shows the result for that country"): the term is resolved by * map shows its result): the term is resolved by
* whole-value edit distance against each accessor's DISTINCT values, and the rows returned * whole-value edit distance against each accessor's DISTINCT values, and the rows returned
* are those carrying a resolved value -- byte-identical to picking it as a filter. * are those carrying a resolved value -- byte-identical to picking it as a filter.
* 3. the generic windowed fuzzy rescue, only when nothing resolves. * 3. the generic windowed fuzzy rescue, only when nothing resolves.

View file

@ -82,7 +82,7 @@ export const BEBAS_NEUE_FACES = [
const POPPINS_LATIN = 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD' const POPPINS_LATIN = 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD'
const POPPINS_LATIN_EXT = 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF' const POPPINS_LATIN_EXT = 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF'
// FIGTREE — the free stand-in for Brandon Grotesque, which is COMMERCIAL and must not ship in a // FIGTREE — the free stand-in for Brandon Grotesque, which is COMMERCIAL and must not ship in a
// public giveaway (Driver decision 2026-09-24). Chosen by measurement: at 500/19.5312px it renders // public giveaway. Chosen by measurement: at 500/19.5312px it renders
// their button text at 126px against Brandon's 128px on live doxa.life, the closest of 19 libre // their button text at 126px against Brandon's 128px on live doxa.life, the closest of 19 libre
// grotesques; Inter was +6 and Archivo +7. Weight 500 only — that is the single weight the two // grotesques; Inter was +6 and Archivo +7. Weight 500 only — that is the single weight the two
// rules that named Brandon actually use. // rules that named Brandon actually use.

View file

@ -306,8 +306,8 @@ export function useMapFly(options = {}) {
// FLY_CONFIG carries `speed` and `curve`, which flyTo understands and fitBounds IGNORES — // FLY_CONFIG carries `speed` and `curve`, which flyTo understands and fitBounds IGNORES —
// so this call looked animated and was a hard cut. MEASURED in the cap-4: selecting a row // so this call looked animated and was a hard cut. MEASURED in the cap-4: selecting a row
// moved the camera 1.45 -> 6 with only TWO distinct zoom values across 14 samples over // moved the camera 1.45 -> 6 with only TWO distinct zoom values across 14 samples over
// ~1.7s, where an eased move shows a curve of intermediates. That is the Driver's // ~1.7s, where an eased move shows a curve of intermediates. That is why transitions
// "transitions too snappy, not smooth" report, and it is specific to fitBounds: the flyTo // read as snappy rather than smooth, and it is specific to fitBounds: the flyTo
// paths in this same file were always eased. // paths in this same file were always eased.
// //
// 900ms matches the feel of the flyTo paths at speed 0.5 without making a selection feel // 900ms matches the feel of the flyTo paths at speed 0.5 without making a selection feel

View file

@ -131,8 +131,8 @@ export function engineGlobal(provider) {
* position:STATIC where Mapbox declares it absolute, .mapboxgl-canary at display:block (that * position:STATIC where Mapbox declares it absolute, .mapboxgl-canary at display:block (that
* canary is Mapbox's OWN unstyled-detector), Mapbox's "missing CSS declarations" warning firing, * canary is Mapbox's OWN unstyled-detector), Mapbox's "missing CSS declarations" warning firing,
* and 0 markers — because a canvas container with no positioning context gives every * and 0 markers — because a canvas container with no positioning context gives every
* absolutely-positioned child, .mapboxgl-marker included, nothing to anchor to. The Driver saw it * absolutely-positioned child, .mapboxgl-marker included, nothing to anchor to. It showed as the
* as "the polygons and map colors and pins are not showing up". * polygons, the map colours and the pins all missing.
* *
* Returns null for an engine with no stylesheet, so callers can skip injection rather than * Returns null for an engine with no stylesheet, so callers can skip injection rather than
* inject undefined. * inject undefined.
@ -245,7 +245,7 @@ export async function ensureEngineCSS(element, cssUrl) {
* back the raw `mapbox://styles/mapbox/standard` URL and the DARKNESS lives in the config * back the raw `mapbox://styles/mapbox/standard` URL and the DARKNESS lives in the config
* ({ basemap: { lightPreset: 'night' } }), which is a Mapbox-only mechanism. This function used to * ({ basemap: { lightPreset: 'night' } }), which is a Mapbox-only mechanism. This function used to
* collapse every mapbox:// style to ONE light constant, so flipping provider turned the research * collapse every mapbox:// style to ONE light constant, so flipping provider turned the research
* map from the dark map the Driver knows into a LIGHT one — MEASURED by screenshotting it, not by * map from its familiar dark look into a LIGHT one — MEASURED by screenshotting it, not by
* any error: both maps mount, both paint, both report success. Interoperability had "worked" while * any error: both maps mount, both paint, both report success. Interoperability had "worked" while
* the product looked like a different product. * the product looked like a different product.
* Reading the preset the Mapbox side would have applied keeps the two engines telling the same * Reading the preset the Mapbox side would have applied keeps the two engines telling the same
@ -646,8 +646,8 @@ export function useMapInstance({
// ── CONSOLE ONLY — NO ON-SCREEN BANNER ────────────────────────────────────────── // ── CONSOLE ONLY — NO ON-SCREEN BANNER ──────────────────────────────────────────
// An earlier version of this handler also appended a `.dx-tile-auth-note` corner // An earlier version of this handler also appended a `.dx-tile-auth-note` corner
// banner to the map container. REMOVED on Driver instruction 2026-09-22: "the map // banner to the map container. REMOVED: the map must not show a popup saying the
// should not have a popup that says basemap tiles blocked". // basemap tiles are blocked.
// //
// WHY THE INSTRUCTION IS RIGHT, so nobody re-adds it. The banner reported an // WHY THE INSTRUCTION IS RIGHT, so nobody re-adds it. The banner reported an
// OPERATOR problem (one account setting, on one deployment origin) to the END USER, // OPERATOR problem (one account setting, on one deployment origin) to the END USER,

View file

@ -61,8 +61,8 @@ export function hasMapToken(cfg) {
*/ */
export function resolveMapProvider(profileConfig, { token } = {}) { export function resolveMapProvider(profileConfig, { token } = {}) {
const cfg = unwrapConfig(profileConfig) const cfg = unwrapConfig(profileConfig)
// ★ AUTO IS MAPLIBRE. A TOKEN NO LONGER CHOOSES AN ENGINE (Driver, 2026-09-28: MapLibre is the // ★ AUTO IS MAPLIBRE. A TOKEN NO LONGER CHOOSES AN ENGINE (2026-09-28: MapLibre is the default
// default everywhere, on cost and on how it looks; Mapbox stays supported as an explicit choice). // everywhere, on cost and on how it looks; Mapbox stays supported as an explicit choice).
// //
// It used to read: explicit || (token present ? 'mapbox' : 'maplibre') — so merely HOLDING a // It used to read: explicit || (token present ? 'mapbox' : 'maplibre') — so merely HOLDING a
// Mapbox token selected Mapbox. That conflated two different things: "I have a token" is a // Mapbox token selected Mapbox. That conflated two different things: "I have a token" is a

View file

@ -21,8 +21,8 @@
// //
// ⚠ PRINTS IN PLACE, never window.open(). A popup is blockable and drops the reader // ⚠ PRINTS IN PLACE, never window.open(). A popup is blockable and drops the reader
// out of the map; the shared openPrintFrame mounts a hidden same-page iframe. This // out of the map; the shared openPrintFrame mounts a hidden same-page iframe. This
// is the mechanism the Driver asked for by name: "not supposed to open up a new // is the mechanism asked for by name: no new browser window, the print dialog
// browser, but should open up the print dialog directly in the browser". // opening directly in the page.
import { ref } from 'vue'; import { ref } from 'vue';
import { openPrintFrame } from './printInPlace.js'; import { openPrintFrame } from './printInPlace.js';
@ -98,8 +98,8 @@ export function usePosterPrints(opts = {}) {
// PosterWall.vue, which meant the MAP LEGEND's own "print this list's poster" had no way to // PosterWall.vue, which meant the MAP LEGEND's own "print this list's poster" had no way to
// reach it — and the legend had grown a fourth implementation instead: a hand-rolled // reach it — and the legend had grown a fourth implementation instead: a hand-rolled
// `<a download>` of the .webp. That was wrong twice over. It silently downloads a file, // `<a download>` of the .webp. That was wrong twice over. It silently downloads a file,
// which the Driver ruled out explicitly (2026-09-23: "no silent file download as the default // which is ruled out explicitly (no silent file download as the default action), and it has
// action"), and it has NO fallback, so on the 27 lists whose .webp does not ship it simply // NO fallback, so on the 27 lists whose .webp does not ship it simply
// saved a 404. // saved a 404.
// //
// Two guards below were each learned the hard way and MUST survive any future move: // Two guards below were each learned the hard way and MUST survive any future move:
@ -163,8 +163,8 @@ export function usePosterPrints(opts = {}) {
} }
// ── 2. THE LIST ─────────────────────────────────────────────────────────── // ── 2. THE LIST ───────────────────────────────────────────────────────────
// The Driver's FIRST option — "that's what the whole app was about": the list // The FIRST option, and what the whole app is about: the list itself as a
// itself as a print-paginated table with a prayer checkbox per row. // print-paginated table with a prayer checkbox per row.
async function printList(item, { columns, title } = {}) { async function printList(item, { columns, title } = {}) {
if (busy()) return; if (busy()) return;
const kind = 'List'; const kind = 'List';

View file

@ -43,9 +43,8 @@ export function createPplrInstance(instanceId) {
* that shares an instance store the `defaultTab` prop was DEAD — declared, passed, * that shares an instance store the `defaultTab` prop was DEAD — declared, passed,
* seeding a local ref, and named in a watch fallback, yet never able to win. * seeding a local ref, and named in a watch fallback, yet never able to win.
* MEASURED 2026-09-24: my-upg-100-list passes :default-tab="1" (the UPG-100 Lists tab) * MEASURED 2026-09-24: my-upg-100-list passes :default-tab="1" (the UPG-100 Lists tab)
* and opened on Region at BOTH widths, which is the Driver's first complaint about this * and opened on Region at BOTH widths, which was the first complaint about this legend:
* legend verbatim — "it shouldn't have me sitting there on Regions, I don't know what to * opening on Regions left the reader not knowing what to do.
* do, it's confusing."
* A sentinel rather than a different initial value: changing activeTab's default would * A sentinel rather than a different initial value: changing activeTab's default would
* move every other profile's landing tab as a side effect. * move every other profile's landing tab as a side effect.
*/ */

View file

@ -118,7 +118,7 @@ export function createSelectionBus() {
// ★ HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. It does not enter selectionSet, does not // ★ HOVER IS NOT SELECTION, AND MUST NEVER BECOME IT. It does not enter selectionSet, does not
// bump selectionVersion, and therefore does not move the camera: useSelectionCamera watches the // bump selectionVersion, and therefore does not move the camera: useSelectionCamera watches the
// version, and a camera that chased the pointer across a table would be the resize-strobe bug // version, and a camera that chased the pointer across a table would be the resize-strobe bug
// wearing a new hat, on a Driver who is photosensitive. A panel may fade or outline on hover; it // wearing a new hat, on a reader who may be photosensitive. A panel may fade or outline on hover; it
// may not fly, select or filter. // may not fly, select or filter.
const hoverKey = ref(null) const hoverKey = ref(null)
const subs = new Map() // event -> Set<cb> const subs = new Map() // event -> Set<cb>

View file

@ -7,7 +7,7 @@
* decoupling the bus exists for — the table panel owns no camera code, and this file owns * decoupling the bus exists for — the table panel owns no camera code, and this file owns
* no table code. * no table code.
* *
* ── MOTION SAFETY (BUG-RESIZE-STROBE class; the Driver is photosensitive) ────────────── * ── MOTION SAFETY (BUG-RESIZE-STROBE class; a reader may be photosensitive) ────────────
* The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler * The strobe bug fixed in @map/composables/useMapInstance.js was caused by a resize handler
* re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here: * re-snapping the camera on EVERY frame at 60fps. The rule learned there is applied here:
* *

View file

@ -1,10 +1,9 @@
/** /**
* useSharedMapToken.js — ONE page, ONE map token, shared by every element on it. * useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
* *
* THE BUG THIS ENDS (Driver, 2026-09-23; the repo already names it * THE BUG THIS ENDS (the repo names it BUG-COMPOSITE-LEAVES-ARE-TOKEN-FREE-BUT-NEST-REAL-MAPS): the
* BUG-COMPOSITE-LEAVES-ARE-TOKEN-FREE-BUT-NEST-REAL-MAPS): * element receives the tk, and it must not gatekeep it to one component — every component in a
* "the element gets the tk to the element itself and does not need to only gatekeep that to one * plugin can share it.
* component only — all components in a plugin can share that tk"
* *
* Inside ONE element that was already true: ProfileLoader provides 'mapboxToken' and eleven * Inside ONE element that was already true: ProfileLoader provides 'mapboxToken' and eleven
* components inject it. The gate is the ELEMENT BOUNDARY. Every custom element mounts its own Vue * components inject it. The gate is the ELEMENT BOUNDARY. Every custom element mounts its own Vue

View file

@ -53,7 +53,7 @@ import { provideOwn, withoutInheritedProvides } from './provide-own.js'
* runtime-core's app.provide() then warns on every key that is already reachable — * runtime-core's app.provide() then warns on every key that is already reachable —
* 'App already provides property with key "profileModules" inherited from its parent element. * 'App already provides property with key "profileModules" inherited from its parent element.
* It will be overwritten with the new value.' * It will be overwritten with the new value.'
* — 8 of those per key, 24 in the Driver's console (2026-09-28), for three keys where overwriting is * — 8 of those per key, 24 in a live console (2026-09-28), for three keys where overwriting is
* the POINT. `profileModules` is the plugin's own .vue glob and a child must not render its parent's * the POINT. `profileModules` is the plugin's own .vue glob and a child must not render its parent's
* components; pinia is per-instance so two embeds never bleed state; and the i18n instance is what * components; pinia is per-instance so two embeds never bleed state; and the i18n instance is what
* ProfileLoader drives from THIS element's profile-config, which is the whole of the permanently- * ProfileLoader drives from THIS element's profile-config, which is the whole of the permanently-

View file

@ -6,8 +6,8 @@
* Vue. Same split as library/composables/host-token-url.js and library/packets/describeGroup.js. * Vue. Same split as library/composables/host-token-url.js and library/packets/describeGroup.js.
* *
* ★ WHY SHADOWING IS CORRECT HERE. Vue's CE layer lets a nested custom element inherit its parent * ★ WHY SHADOWING IS CORRECT HERE. Vue's CE layer lets a nested custom element inherit its parent
* element's provides, and app.provide() warns on any key already reachable — 24 of those in the * element's provides, and app.provide() warns on any key already reachable — 24 of those in a live
* Driver's console (2026-09-28), 8 each for profileModules, appI18n and pinia's symbol. All three must * console (2026-09-28), 8 each for profileModules, appI18n and pinia's symbol. All three must
* be the child's OWN: profileModules is the plugin's own .vue glob, pinia is per-instance so two embeds * be the child's OWN: profileModules is the plugin's own .vue glob, pinia is per-instance so two embeds
* never bleed state, and the i18n instance is what ProfileLoader drives from THIS element's * never bleed state, and the i18n instance is what ProfileLoader drives from THIS element's
* profile-config. Inheriting any of them would be the bug. The warning is Vue reporting an intentional * profile-config. Inheriting any of them would be the bug. The warning is Vue reporting an intentional

View file

@ -159,7 +159,7 @@ export function createAppI18n() {
if (RTL_LOCALES.has(locale)) document.documentElement.dir = 'rtl' if (RTL_LOCALES.has(locale)) document.documentElement.dir = 'rtl'
} }
} catch { /* no document (SSR/tests) — nothing to reflect */ } } catch { /* no document (SSR/tests) — nothing to reflect */ }
// ★ A DETACHED EFFECT SCOPE (P0, the Driver's console, 2026-09-27). vue-i18n's createGlobal() runs // ★ A DETACHED EFFECT SCOPE (P0, seen in a live console, 2026-09-27). vue-i18n's createGlobal() runs
// inside scope.run() and throws UNEXPECTED_ERROR ("SyntaxError: Unexpected error") when that returns // inside scope.run() and throws UNEXPECTED_ERROR ("SyntaxError: Unexpected error") when that returns
// undefined; Vue 3.5 creates a new EffectScope INACTIVE when the currently active scope is STOPPED. // undefined; Vue 3.5 creates a new EffectScope INACTIVE when the currently active scope is STOPPED.
// A custom element's connectedCallback -> configureApp -> here can run while a released tab's stopped // A custom element's connectedCallback -> configureApp -> here can run while a released tab's stopped

View file

@ -20,7 +20,7 @@
everything, every section's element mounts on FIRST RENDER (not on activation). If you only want the everything, every section's element mounts on FIRST RENDER (not on activation). If you only want the
visible ones to load, that's the tabs layout's job; a scroll-stack shows all. visible ones to load, that's the tabs layout's job; a scroll-stack shows all.
★ THE LAYOUT OWNS THE SCROLL — the elements do NOT. This is the Driver's scroll-page contract: a ★ THE LAYOUT OWNS THE SCROLL — the elements do NOT. This is the scroll-page contract: a
plugin dropped in a section only needs ROOM to display at full height; it never needs its own plugin dropped in a section only needs ROOM to display at full height; it never needs its own
scrollbar. The root is `display:flow-root` with NO fixed height and NO `position:absolute; inset:0` scrollbar. The root is `display:flow-root` with NO fixed height and NO `position:absolute; inset:0`
— so it takes its content's natural height and the PARENT PAGE scrolls through everything. Each — so it takes its content's natural height and the PARENT PAGE scrolls through everything. Each
@ -83,7 +83,7 @@ import { bundleTreeRoot } from '@map/utils/embedBase.js'
import { loadScriptOnce, claimTag } from '@map/utils/embedScript.js' import { loadScriptOnce, claimTag } from '@map/utils/embedScript.js'
// ★ ANCHOR A SIBLING-BUNDLE src TO THE TREE ROOT, NOT TO A GUESSED DEPTH. // ★ ANCHOR A SIBLING-BUNDLE src TO THE TREE ROOT, NOT TO A GUESSED DEPTH.
// THE BUG (Driver-reported 2026-09-23, verbatim console text): // THE BUG (reported, the console text verbatim):
// Could not load <doxa-research-map> from ../../doxa-research-map/doxa-research-map.js // Could not load <doxa-research-map> from ../../doxa-research-map/doxa-research-map.js
// A composite declares its children as `src: '../../<bundle>/<bundle>.js'`. That literal is correct // A composite declares its children as `src: '../../<bundle>/<bundle>.js'`. That literal is correct
// from a ONE-deep leaf and wrong from a TWO-deep one, and the generator emits both: // from a ONE-deep leaf and wrong from a TWO-deep one, and the generator emits both:
@ -110,7 +110,7 @@ function resolveBundleSrc(url) {
const m = new RegExp('^(.*/' + TREE_SEGMENT + '/)').exec(window.location.pathname) const m = new RegExp('^(.*/' + TREE_SEGMENT + '/)').exec(window.location.pathname)
if (m) return m[1] + bare if (m) return m[1] + bare
// (2) THE DOCUMENT IS NOT INSIDE THE TREE AT ALL, so there is no segment to anchor on. // (2) THE DOCUMENT IS NOT INSIDE THE TREE AT ALL, so there is no segment to anchor on.
// Driver-reported 2026-09-23: <plugin-studio> mounts at the SERVER ROOT (/index.html) in both // Reported: <plugin-studio> mounts at the SERVER ROOT (/index.html) in both
// trees, and from there this function's only anchor was missing, so it returned the literal // trees, and from there this function's only anchor was missing, so it returned the literal
// '../../doxa-research-map/doxa-research-map.js' unchanged. The browser resolved that against // '../../doxa-research-map/doxa-research-map.js' unchanged. The browser resolved that against
// '/' — where '../' cannot climb — and requested /doxa-research-map/doxa-research-map.js: // '/' — where '../' cannot climb — and requested /doxa-research-map/doxa-research-map.js:
@ -258,7 +258,7 @@ export default defineComponent({
host.innerHTML = '' host.innerHTML = ''
host.appendChild(el) host.appendChild(el)
}) })
// ★ A FAILED SECTION MUST SAY SO (BUG-SIMPLE-RESEARCH-GREY-PANE, Driver reported 4x). // ★ A FAILED SECTION MUST SAY SO (BUG-SIMPLE-RESEARCH-GREY-PANE, reported four times).
// The rest of the stack still renders — that part was always right, and is why this catch // The rest of the stack still renders — that part was always right, and is why this catch
// is per-section rather than around the whole loop. What was wrong is that it left the host // is per-section rather than around the whole loop. What was wrong is that it left the host
// EMPTY and silent, so a composite whose children all failed rendered as a blank rectangle // EMPTY and silent, so a composite whose children all failed rendered as a blank rectangle

View file

@ -115,7 +115,7 @@
<!-- ★ HEADERLESS MOBILE ESCAPE HATCH — the ONLY way to reach the rail when there is no header. <!-- ★ HEADERLESS MOBILE ESCAPE HATCH — the ONLY way to reach the rail when there is no header.
FOUND 2026-09-22 by resizing the cap-5 SPA to a phone width. That SPA is header-OFF by FOUND 2026-09-22 by resizing the cap-5 SPA to a phone width. That SPA is header-OFF by
default (the Driver's no-stacked-headers rule), so the toggle above — which lives inside default (the no-stacked-headers rule), so the toggle above — which lives inside
`v-if="header"` — does not exist. On mobile the rail is an off-canvas drawer at `v-if="header"` — does not exist. On mobile the rail is an off-canvas drawer at
translateX(-100%) with `navOpen` starting false, and the only other toggle (.vtl-collapse) translateX(-100%) with `navOpen` starting false, and the only other toggle (.vtl-collapse)
sits INSIDE the drawer, measured off-screen at x:-244. Net effect: every tab except the one sits INSIDE the drawer, measured off-screen at x:-244. Net effect: every tab except the one
@ -168,7 +168,7 @@
v-if="grp.folder" type="button" class="vtl-tier vtl-tier-btn" v-if="grp.folder" type="button" class="vtl-tier vtl-tier-btn"
:aria-expanded="String(!isFolderClosed(grp))" :aria-expanded="String(!isFolderClosed(grp))"
@click="toggleFolder(grp.key)"> @click="toggleFolder(grp.key)">
<!-- NO section-header logo (Driver rule: logos only on the vertical TAB rows, never on the <!-- NO section-header logo (the rule: logos only on the vertical TAB rows, never on the
section dividers). The folder chevron below is an affordance, not a logo, so it stays. --> section dividers). The folder chevron below is an affordance, not a logo, so it stays. -->
<span class="vtl-cap" v-if="grp.badge">{{ grp.badge }}</span> <span class="vtl-cap" v-if="grp.badge">{{ grp.badge }}</span>
<span class="vtl-tl">{{ grp.label }}</span> <span class="vtl-tl">{{ grp.label }}</span>
@ -176,7 +176,7 @@
<span class="vtl-chev" :class="{ open: !isFolderClosed(grp) }" aria-hidden="true" v-html="iconSvg('chevron')"></span> <span class="vtl-chev" :class="{ open: !isFolderClosed(grp) }" aria-hidden="true" v-html="iconSvg('chevron')"></span>
</button> </button>
<!-- SECTION heading = a static always-visible label (with optional badge/count). No logo here <!-- SECTION heading = a static always-visible label (with optional badge/count). No logo here
either — section dividers are text-only (Driver rule); row icons live on the tabs below. --> either — section dividers are text-only; row icons live on the tabs below. -->
<div v-else class="vtl-tier"> <div v-else class="vtl-tier">
<span class="vtl-cap" v-if="grp.badge">{{ grp.badge }}</span> <span class="vtl-cap" v-if="grp.badge">{{ grp.badge }}</span>
<span class="vtl-tl">{{ grp.label }}</span> <span class="vtl-tl">{{ grp.label }}</span>
@ -228,7 +228,7 @@
<main class="vtl-main"> <main class="vtl-main">
<!-- The #toolbar strip (locale switcher etc.) is part of the header chrome, so it renders ONLY <!-- The #toolbar strip (locale switcher etc.) is part of the header chrome, so it renders ONLY
when header is on. Driver rule: a header-off, sidebar-only element (doxa-maps-spa) inherits when header is on. The rule: a header-off, sidebar-only element (doxa-maps-spa) inherits
its language from the PARENT via the `language` prop and shows NO standalone locale dropdown; its language from the PARENT via the `language` prop and shows NO standalone locale dropdown;
a header-on host (plugin-studio) DOES get the toolbar/locale dropdown. --> a header-on host (plugin-studio) DOES get the toolbar/locale dropdown. -->
<slot name="toolbar" v-if="header" /> <slot name="toolbar" v-if="header" />
@ -621,7 +621,7 @@ export default defineComponent({
* ★ ONE SOURCE OF TRUTH FOR THE FLOATING CIRCLE, because two copies of this condition is the * ★ ONE SOURCE OF TRUTH FOR THE FLOATING CIRCLE, because two copies of this condition is the
* bug waiting to happen. The button's v-if and the --map-fab-clearance that reserves room for * bug waiting to happen. The button's v-if and the --map-fab-clearance that reserves room for
* it MUST agree: if they drift, you get either a legend whose last row sits under the circle * it MUST agree: if they drift, you get either a legend whose last row sits under the circle
* (the Driver's report) or a strip of dead whitespace with no circle to justify it. Neither * (the reported case) or a strip of dead whitespace with no circle to justify it. Neither
* has a symptom a gate can see, so the fix is to make disagreement impossible rather than to * has a symptom a gate can see, so the fix is to make disagreement impossible rather than to
* test for it. * test for it.
*/ */

View file

@ -72,7 +72,7 @@ export function localizeBasemapLabels(map, lang) {
// MEASURED against the live tiles (six z3/z4 tiles across Europe, Asia and Africa, identical key // MEASURED against the live tiles (six z3/z4 tiles across Europe, Asia and Africa, identical key
// sets): name_ar, name_de, name_el, name_en, name_es, name_fr, name_it, name_nl, name_pl, // sets): name_ar, name_de, name_el, name_en, name_es, name_fr, name_it, name_nl, name_pl,
// name_pt, name_uk. Of the eleven locales we ship that covers SEVEN — ar, de, en, es, fr, it, pt // name_pt, name_uk. Of the eleven locales we ship that covers SEVEN — ar, de, en, es, fr, it, pt
// — in their own language, with no change to the basemap the Driver chose. // — in their own language, with no change to the chosen basemap.
// //
// hi, ro, ru and zh are genuinely absent from those tiles, so they take name_en: Latin script, // hi, ro, ru and zh are genuinely absent from those tiles, so they take name_en: Latin script,
// one script, readable rather than mixed CJK and Devanagari endonyms. That is the same role // one script, readable rather than mixed CJK and Devanagari endonyms. That is the same role

View file

@ -1,8 +1,8 @@
/** /**
* mercator-slice.js — cut a map's bounds into EQUAL-HEIGHT rows, which is not the same as equal degrees. * mercator-slice.js — cut a map's bounds into EQUAL-HEIGHT rows, which is not the same as equal degrees.
* *
* ★ THE DEFECT THIS EXISTS FOR (Driver, 2026-09-28, Simple Research's poster export): the poster "shows * ★ THE DEFECT THIS EXISTS FOR (Simple Research's poster export): the poster showed too much of
* too much of Antarctica" and comes out at the wrong, vertical aspect. * Antarctica and came out at the wrong, vertical aspect.
* *
* The big-poster renderer stitches a grid of tiles, each rendered into an EQUAL-HEIGHT slot in the * The big-poster renderer stitches a grid of tiles, each rendered into an EQUAL-HEIGHT slot in the
* output image. It chose each tile's latitude band by dividing the degree span evenly: * output image. It chose each tile's latitude band by dividing the degree span evenly:
@ -13,7 +13,7 @@
* DEGREE bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is 37.0% * DEGREE bands are not equal SCREEN bands. MEASURED on a -60..75 view in 4 rows: the top band is 37.0%
* of the true screen height and is drawn into 25% of the poster; the next is 19.7% drawn into 25%. Every * of the true screen height and is drawn into 25% of the poster; the next is 19.7% drawn into 25%. Every
* row is squeezed or stretched, which is the wrong aspect, and the high-latitude bands — where a degree * row is squeezed or stretched, which is the wrong aspect, and the high-latitude bands — where a degree
* is worth the most pixels — are the most over-represented, which is the Antarctica the Driver saw. * is worth the most pixels — are the most over-represented, which is where the excess Antarctica came from.
* *
* Slicing in MERCATOR Y instead gives 25.0% per row exactly, because that IS the screen coordinate. * Slicing in MERCATOR Y instead gives 25.0% per row exactly, because that IS the screen coordinate.
* *

View file

@ -3,9 +3,9 @@
// ★ THE POINT OF THIS FILE: A DECK PRINTS AS CARDS, NOT AS PAPER. // ★ THE POINT OF THIS FILE: A DECK PRINTS AS CARDS, NOT AS PAPER.
// The previous deck set `@page{size:letter portrait}` and gave each card // The previous deck set `@page{size:letter portrait}` and gave each card
// `height:100%`, so one card was stretched across a whole 8.5x11 sheet — 95 // `height:100%`, so one card was stretched across a whole 8.5x11 sheet — 95
// mostly-empty letter pages, with no photo. The Driver: "the print dialog that // mostly-empty letter pages, with no photo. The print dialog has to show every
// opens up needs to show all the cards not in a paper format but a CARD format, // card in a CARD format, not a paper format: the aspect ratio the card generator
// the aspect ratio that is used in the card generator." // uses.
// //
// So the page IS the card: @page takes the card's own dimensions, and every card // So the page IS the card: @page takes the card's own dimensions, and every card
// fills exactly one page. Print two-sided on cardstock and cut, or print to PDF // fills exactly one page. Print two-sided on cardstock and cut, or print to PDF

View file

@ -92,7 +92,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
const PAGE_W_IN = 8.5; const PAGE_W_IN = 8.5;
const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page const SCALE = (PAGE_W_IN * 96 / W).toFixed(6); // print scale: one sheet = one page
const M = 60, HEAD = 90; // margins + running header const M = 60, HEAD = 90; // margins + running header
// ★ THE PROFILE TABLE (Driver brief A1 item 1): ~20 per page, picture | generated localized // ★ THE PROFILE TABLE (brief A1, item 1): ~20 per page, picture | generated localized
// description | three prayer checkboxes. This replaces the 2-column card grid with 20 FULL-WIDTH // description | three prayer checkboxes. This replaces the 2-column card grid with 20 FULL-WIDTH
// ROWS. PER_PAGE stays 20, so this is a change of LAYOUT SHAPE, NOT OF PAGINATION — which is what // ROWS. PER_PAGE stays 20, so this is a change of LAYOUT SHAPE, NOT OF PAGINATION — which is what
// lets check-packet-render's dropped-groups assertion (sheets === 1 + … + ceil(cards/PER_PAGE) + 2) // lets check-packet-render's dropped-groups assertion (sheets === 1 + … + ceil(cards/PER_PAGE) + 2)
@ -100,7 +100,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// comment refuses a ±1 tolerance, so a rewrite that needed it loosened would be the wrong rewrite. // comment refuses a ±1 tolerance, so a rewrite that needed it loosened would be the wrong rewrite.
const ROWS = 20, PER_PAGE = ROWS; const ROWS = 20, PER_PAGE = ROWS;
const COLS = 1; const COLS = 1;
// ★ THE THREE PRAYER COLUMNS, the Driver's own three questions (2026-09-27), in his order: // ★ THE THREE PRAYER COLUMNS, three questions in this order:
// 1 have I blessed them // 1 have I blessed them
// 2 God, what do you want me to KNOW about them // 2 God, what do you want me to KNOW about them
// 3 God, what do you want me to DO to help them // 3 God, what do you want me to DO to help them
@ -484,8 +484,8 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
} }
// ── PAGE 1: the map one-pager ─────────────────────────────────────────────── // ── PAGE 1: the map one-pager ───────────────────────────────────────────────
// Lightweight by design — the Driver asked for "a lightweight one pager that shows // Lightweight by design — a one-page map showing the geography with all the pins,
// the geography with all the pins", explicitly NOT the poster. The poster remains // explicitly NOT the poster. The poster remains
// its own artefact (generate.mjs); this is the packet's cover. // its own artefact (generate.mjs); this is the packet's cover.
// ★ IT SAYS WHICH SET IT BELONGS TO. A partner holding one packet must be able to tell it is one of // ★ IT SAYS WHICH SET IT BELONGS TO. A partner holding one packet must be able to tell it is one of
// the UPG 100 lists, not a stand-alone leaflet — hence the series line. The count is READ from // the UPG 100 lists, not a stand-alone leaflet — hence the series line. The count is READ from
@ -571,7 +571,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// ── PAGE 2: the funnel — how this list was narrowed out of every people group ─ // ── PAGE 2: the funnel — how this list was narrowed out of every people group ─
// //
// WHY THIS PAGE EXISTS. Driver 2026-09-26, via the shepherd: the packets go to ALL partners for // WHY THIS PAGE EXISTS. The packets go to ALL partners for
// the largest prayer campaign, and a partner holding one list needs to see where their list sits // the largest prayer campaign, and a partner holding one list needs to see where their list sits
// in the whole. The narrowing IS the argument: 12,379 people groups on earth, 7,341 of them // in the whole. The narrowing IS the argument: 12,379 people groups on earth, 7,341 of them
// unreached, 4,754 frontier, 2,107 on this network's list, and N of those in the reader's hand. // unreached, 4,754 frontier, 2,107 on this network's list, and N of those in the reader's hand.
@ -593,8 +593,8 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// ⚠ K AND N ARE READ AT RENDER TIME, NEVER TYPED. Measured 2026-09-26: the brief said "~30 lists // ⚠ K AND N ARE READ AT RENDER TIME, NEVER TYPED. Measured 2026-09-26: the brief said "~30 lists
// of 50-100" and the catalogue says K=27 with counts from 8 to 118 — west-africa 118, // of 50-100" and the catalogue says K=27 with counts from 8 to 118 — west-africa 118,
// asia-india-rajasthan 116, uncovered-scatter 8. Only about half sit inside 50-100, so this page // asia-india-rajasthan 116, uncovered-scatter 8. Only about half sit inside 50-100, so this page
// never claims that band; it states the share the data can defend. Shepherd approved exactly this // never claims that band; it states the share the data can defend. K=27 includes the two residual
// (ENDTOKEN-DXS-UPG-KDECIDE), K=27 including the two residual buckets until the Driver rules. // buckets: provisional, pending a product decision.
// //
// ⚠ "NUMERALS UNTOUCHED" IS ABOUT DIGITS, NOT GROUPING, and those are two properties. I verified the // ⚠ "NUMERALS UNTOUCHED" IS ABOUT DIGITS, NOT GROUPING, and those are two properties. I verified the
// digits and assumed the rest. rega measured the rendered hi sheet and found Indian grouping — // digits and assumed the rest. rega measured the rendered hi sheet and found Indian grouping —
@ -847,14 +847,14 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// ── LIFE CALENDAR: 100 years x 52 weeks = 5200 boxes ──────────────────────── // ── LIFE CALENDAR: 100 years x 52 weeks = 5200 boxes ────────────────────────
// //
// Driver, 2026-09-27: page 2 is "a life calendar of the years of a human life in weeks", and page 3 // Page 2 is a life calendar: the years of a human life, in weeks. Page 3 is the same calendar used
// is "the same life cal used for percentage of Christian on average for dx people groups". ONE grid, // for the average percentage of Christians in the DOXA people groups. ONE grid,
// TWO readings — which is the whole point. A percentage drawn as a bar is an abstraction a reader // TWO readings — which is the whole point. A percentage drawn as a bar is an abstraction a reader
// has to translate; drawn on the grid they have just understood as their OWN LIFE, it is the same // has to translate; drawn on the grid they have just understood as their OWN LIFE, it is the same
// 5200 boxes they were looking at a page earlier. // 5200 boxes they were looking at a page earlier.
// //
// ★ 5,200 IS NOT ROUNDED OR CHOSEN FOR LAYOUT: 100 years x 52 weeks, the Driver's own numbers // ★ 5,200 IS NOT ROUNDED OR CHOSEN FOR LAYOUT: 100 years x 52 weeks, a 100-year life in weeks.
// ("a 100 year life in weeks"). Everything else here is derived from it, so the two pages cannot // Everything else here is derived from it, so the two pages cannot
// drift apart into two grids that merely look alike. // drift apart into two grids that merely look alike.
// ★ LAID OUT LIKE THE PRINTED LIFE CALENDAR (the Module 1 "Life Calendar in Weeks" sheet, which the // ★ LAID OUT LIKE THE PRINTED LIFE CALENDAR (the Module 1 "Life Calendar in Weeks" sheet, which the
// Driver asked this page to imitate): square boxes, week numbers across the top, the year down the // Driver asked this page to imitate): square boxes, week numbers across the top, the year down the
@ -911,8 +911,8 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// Page 3 — the SAME grid, shaded by the share of the DX population that is evangelical. // Page 3 — the SAME grid, shaded by the share of the DX population that is evangelical.
// //
// ⚠ THE NUMBER IS POPULATION-WEIGHTED, NOT A GROUP COUNT, because the Driver asked for // ⚠ THE NUMBER IS POPULATION-WEIGHTED, NOT A GROUP COUNT, because the page shows the Christian
// "% of Christian population vs lost". Those differ sharply: 69.1% of the 2,091 matched DX GROUPS // share of the POPULATION against the lost. Those differ sharply: 69.1% of the 2,091 matched DX GROUPS
// have no known evangelicals, but weighted by POPULATION the evangelical share is 0.61% of // have no known evangelicals, but weighted by POPULATION the evangelical share is 0.61% of
// 209,079,159 people — 32 boxes of 5,200. A group-count version would have shown a different, // 209,079,159 people — 32 boxes of 5,200. A group-count version would have shown a different,
// milder picture of the same data. // milder picture of the same data.
@ -1110,8 +1110,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
.join(' · '); .join(' · ');
const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : ''; const sub = p.sub ? ` <span style="font:italic 17px Georgia,serif;color:#8c8170">— ${esc(p.sub)}</span>` : '';
const passage = p.print_passage const passage = p.print_passage
// Point 4 prints Mark 5:1-20 IN FULL. Driver: "it actually lists out the full // Point 4 prints Mark 5:1-20 IN FULL, because prayer point four IS reading the word.
// scripture, because prayer point number four is actually read the word."
// class="p4-passage" is a TEST HOOK, not styling — the block is styled inline. // class="p4-passage" is a TEST HOOK, not styling — the block is styled inline.
// Without it the only way to assert the passage rendered was to search for the // Without it the only way to assert the passage rendered was to search for the
// English word "Gadarenes", which would go red the moment this key is translated. // English word "Gadarenes", which would go red the moment this key is translated.
@ -1371,7 +1370,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// working unchanged and a host with no funnel data still gets a complete, correct packet. A page // working unchanged and a host with no funnel data still gets a complete, correct packet. A page
// that cannot state true numbers must not appear at all — better a 4-section packet than one // that cannot state true numbers must not appear at all — better a 4-section packet than one
// asserting figures it could not read. // asserting figures it could not read.
// ★ TWO PACKETS, ONE RENDERER — the Driver's §A, 2026-09-28: the Prayer List print gains a caret // ★ TWO PACKETS, ONE RENDERER: the Prayer List print gains a caret
// with "Full detail" and "Lightweight". They are NOT two code paths. Every page below already // with "Full detail" and "Lightweight". They are NOT two code paths. Every page below already
// exists and is already exported; a variant is a FILTER over the page array, so the two packets // exists and is already exported; a variant is a FILTER over the page array, so the two packets
// cannot drift apart in layout, i18n, RTL or print scale — there is only ever one implementation // cannot drift apart in layout, i18n, RTL or print scale — there is only ever one implementation
@ -1410,7 +1409,7 @@ export function createPacketRenderer({ strings, lang = 'en', fontData = null, de
// sees the same 5,200 boxes answer "how many of these people know Christ". // sees the same 5,200 boxes answer "how many of these people know Christ".
// ⚠ KEYED, NOT INDEXED. The omit-set names pages; it never carries positions. An index-based // ⚠ KEYED, NOT INDEXED. The omit-set names pages; it never carries positions. An index-based
// filter reads identically today and silently drops the WRONG page the first time this order // filter reads identically today and silently drops the WRONG page the first time this order
// changes — and the order above is the Driver's, so it is expected to change again. // changes — and the order above is a product decision, so it is expected to change again.
// An unknown variant falls back to the full packet: a typo must not silently print a shorter // An unknown variant falls back to the full packet: a typo must not silently print a shorter
// packet, because a packet missing two pages still looks like a packet. // packet, because a packet missing two pages still looks like a packet.
const omit = VARIANTS[variant] || VARIANTS.full; const omit = VARIANTS[variant] || VARIANTS.full;

View file

@ -2,9 +2,9 @@
// browser, on click. // browser, on click.
// //
// ★ WHY: 378 posters were committed as .webp — 41 MB in every clone, shipped to // ★ WHY: 378 posters were committed as .webp — 41 MB in every clone, shipped to
// every reader whether or not they open one. Driver: "it would be a big win to not // every reader whether or not they open one. Not shipping ~80 MB of images in a
// have to have 80mb of images in a public folder and let the posters get generated // public folder, and letting the website generate the posters, is a big win.
// by the website." A publish was ALREADY rejected once at a 74 MB asset tree // A publish was ALREADY rejected once at a 74 MB asset tree
// (git-packed to 57 MiB), which is what not doing this costs. // (git-packed to 57 MiB), which is what not doing this costs.
// //
// THE MOVE IS NOT A REWRITE. generate.mjs is 2,816 lines with 26 Node touchpoints; // THE MOVE IS NOT A REWRITE. generate.mjs is 2,816 lines with 26 Node touchpoints;

View file

@ -1,9 +1,9 @@
// qr.js — a minimal, self-contained QR encoder. No dependency, no network. // qr.js — a minimal, self-contained QR encoder. No dependency, no network.
// //
// WHY HAND-ROLLED. The prayer cards need a QR per group linking back to that // WHY HAND-ROLLED. The prayer cards need a QR per group linking back to that
// group's live page, and the Driver's standing constraint is "as much data as you // group's live page, and the standing constraint is as much data as possible
// can without compromising speed and without adding a lot of dependencies on the // without compromising speed and without adding a lot of dependencies on the
// internet". A QR symbol is pure computation — Reed-Solomon over GF(256) and a bit // internet. A QR symbol is pure computation — Reed-Solomon over GF(256) and a bit
// layout — so it costs one small file instead of a package, and it runs identically // layout — so it costs one small file instead of a package, and it runs identically
// in Node (build-time decks) and the browser (print-on-click). // in Node (build-time decks) and the browser (print-on-click).
// //

View file

@ -1,9 +1,9 @@
/** /**
* describeGroup.js — one localized sentence pair describing a people group, built ONLY from API fields. * describeGroup.js — one localized sentence pair describing a people group, built ONLY from API fields.
* *
* DRIVER BRIEF 2026-09-28 §A1.5: the packet's profile rows carry "a short GENERATED description built * THE BRIEF: the packet's profile rows carry a short GENERATED description built from the API
* from the API variables, localized: how lost/unreached they are, where (country), what language they * variables, localized: how lost/unreached they are, where (country), what language they speak.
* speak". ~20 rows per page, so it has to be short, and it has to be right in eleven languages. * ~20 rows per page, so it has to be short, and it has to be right in eleven languages.
* *
* PURE. (group, locale) -> string. No refs, no map, no fetch. The strings object is the third argument * PURE. (group, locale) -> string. No refs, no map, no fetch. The strings object is the third argument
* so a test can inject one; it defaults to the packet's own catalogue, which is what the renderer * so a test can inject one; it defaults to the packet's own catalogue, which is what the renderer

View file

@ -6,7 +6,7 @@
* LAYOUT IS COMPUTED HERE, ONCE, AT BUILD TIME, so a phone does none of it, and the output is projected * LAYOUT IS COMPUTED HERE, ONCE, AT BUILD TIME, so a phone does none of it, and the output is projected
* to lng/lat so a map engine can draw it undistorted on the GPU. * to lng/lat so a map engine can draw it undistorted on the GPU.
* *
* ★ WHY THIS IS IN library/ AND WHAT DELIBERATELY IS NOT (Driver brief §B, "make it reusable"). This was * ★ WHY THIS IS IN library/ AND WHAT DELIBERATELY IS NOT (the brief: make it reusable). This was
* extracted from gospel-internet's scripts/build-language-tree.mjs, which does TWO jobs in one file: this * extracted from gospel-internet's scripts/build-language-tree.mjs, which does TWO jobs in one file: this
* layout, and a classification of each language from eBible, FCBH verse scopes and a screened link table. * layout, and a classification of each language from eBible, FCBH verse scopes and a screened link table.
* Moving the file whole would have put those sources inside library/ and a fork building the tree from * Moving the file whole would have put those sources inside library/ and a fork building the tree from
@ -159,8 +159,8 @@ export function buildLanguageTree({
// ★ HOW MUCH OF THE SQUARE THE TAPESTRY SPANS DECIDES HOW FAR A READER CAN ZOOM OUT, and that is the // ★ HOW MUCH OF THE SQUARE THE TAPESTRY SPANS DECIDES HOW FAR A READER CAN ZOOM OUT, and that is the
// whole reason this number is a parameter with a measured default. The engines stop at zoom 0 (the // whole reason this number is a parameter with a measured default. The engines stop at zoom 0 (the
// whole Mercator square), so the SMALLER the drawing, the more room there is below its opening view. // whole Mercator square), so the SMALLER the drawing, the more room there is below its opening view.
// At 68% there was about one level of room (Driver, 2026-09-27: "I need to be able to zoom out a bit // At 68% there was about one level of room, and zooming out a bit further was needed; at 0.17
// more"); at 0.17 there are two. // there are two.
// The conversion moved to library/maps/whiteboard-projection.js so a second whiteboard chart cannot // The conversion moved to library/maps/whiteboard-projection.js so a second whiteboard chart cannot
// re-derive it — gospel's set charts were about to scale linearly into degrees, which Mercator then // re-derive it — gospel's set charts were about to scale linearly into degrees, which Mercator then
// renders as ovals. Behaviour here is unchanged and check:whiteboard-projection asserts that over a // renders as ovals. Behaviour here is unchanged and check:whiteboard-projection asserts that over a

View file

@ -8,7 +8,7 @@
// //
// ★ THIS IS NOT THE RESEARCH MAP. library/scripts/research/out/iso3-to-iso2.json is a different file // ★ THIS IS NOT THE RESEARCH MAP. library/scripts/research/out/iso3-to-iso2.json is a different file
// with a different job: the 176-country spine that drives the data FETCHES. Expanding that one changes // with a different job: the 176-country spine that drives the data FETCHES. Expanding that one changes
// what gets fetched, which is a data-scope decision for the Driver (dx-shepherd, REG242B) and must not // what gets fetched, which is a product decision about data scope and must not
// happen as a side effect of adding country names. This map drives no fetch. The two are deliberately // happen as a side effect of adding country names. This map drives no fetch. The two are deliberately
// not interchangeable, which is why they have different names, different shapes and this paragraph. // not interchangeable, which is why they have different names, different shapes and this paragraph.
// //

View file

@ -8,7 +8,7 @@
* *
* WHY. The map downloaded and parsed the whole slim file — every column, as one object per row — to * WHY. The map downloaded and parsed the whole slim file — every column, as one object per row — to
* draw ten fields. That, plus waiting on the live prayer API before drawing anything, is most of why * draw ten fields. That, plus waiting on the live prayer API before drawing anything, is most of why
* the Driver found it "not even loading, takes a very long time". This file keeps the same rows and * the map seemed not to load at all, or took a very long time. This file keeps the same rows and
* values in column order, with the repetitive strings (country, religion, evangelical level, * values in column order, with the repetitive strings (country, religion, evangelical level,
* language) stored once in dictionaries and referenced by index, and coordinates at 3 decimals * language) stored once in dictionaries and referenced by index, and coordinates at 3 decimals
* (~100 m, far below a pin's size at any zoom this map shows). * (~100 m, far below a pin's size at any zoom this map shows).

View file

@ -39,7 +39,7 @@ const POSTERS = join(OUT, 'posters');
mkdirSync(POSTERS, { recursive: true }); mkdirSync(POSTERS, { recursive: true });
// ── THE SERIES ────────────────────────────────────────────────────────────── // ── THE SERIES ──────────────────────────────────────────────────────────────
// ★ THIS FILE IS THE HALF NOBODY WAS LOOKING AT. The Driver reported the country posters carrying // ★ THIS FILE IS THE HALF NOBODY WAS LOOKING AT. The country posters were reported carrying
// UPG-100 framing, and the search went to packet-strings.json — but the block telling the reader to // UPG-100 framing, and the search went to packet-strings.json — but the block telling the reader to
// divide the set among THIRTY disciples is not a packet string at all. It is poster furniture, a // divide the set among THIRTY disciples is not a packet string at all. It is poster furniture, a
// hard-coded array in THIS file (see NETWORKS below), so a series parameter applied to the packet // hard-coded array in THIS file (see NETWORKS below), so a series parameter applied to the packet
@ -83,9 +83,8 @@ const adm0 = readJSON(join(DATA, 'adm0', 'world-adm0.geojson'));
const adm1Ind = readJSON(join(DATA, 'gb-IND-ADM1.geojson')); const adm1Ind = readJSON(join(DATA, 'gb-IND-ADM1.geojson'));
const adm1Chn = readJSON(join(DATA, 'gb-CHN-ADM1.geojson')); const adm1Chn = readJSON(join(DATA, 'gb-CHN-ADM1.geojson'));
// CAPITALS + MAJOR CITIES. Driver: "a typical map poster needs details on the map // CAPITALS + MAJOR CITIES. A map poster needs enough detail to read easily, but not
// that is not hard to see, but not so much that it becomes too complicated" — // so much that it becomes complicated: cities and capitals.
// "cities and capitals makes sense".
// //
// Natural Earth 1:10m, PUBLIC DOMAIN: no attribution required, no licence notice to // Natural Earth 1:10m, PUBLIC DOMAIN: no attribution required, no licence notice to
// carry onto a printed packet, and vendored so a build is reproducible offline and // carry onto a printed packet, and vendored so a build is reproducible offline and
@ -93,7 +92,7 @@ const adm1Chn = readJSON(join(DATA, 'gb-CHN-ADM1.geojson'));
// 170 KB / 2,573 (national + admin-1 capitals, plus cities >= 250k) because a poster // 170 KB / 2,573 (national + admin-1 capitals, plus cities >= 250k) because a poster
// cannot legibly carry 7,342 dots and the packet is downloaded. // cannot legibly carry 7,342 dots and the packet is downloaded.
// //
// ⚠ ROADS WERE DELIBERATELY NOT ADDED, though the Driver asked for "main roads". // ⚠ ROADS WERE DELIBERATELY NOT ADDED, though main roads were asked for.
// Natural Earth's 10m roads layer has ZERO features for LAO, MMR, KHM, VNM, THA, // Natural Earth's 10m roads layer has ZERO features for LAO, MMR, KHM, VNM, THA,
// NPL, BTN, TCD, NER, MLI, SOM, AFG, YEM, IDN, IND and CHN — its coverage is // NPL, BTN, TCD, NER, MLI, SOM, AFG, YEM, IDN, IND and CHN — its coverage is
// essentially USA/CAN/MEX. Shipping it would draw a rich road network on a US map // essentially USA/CAN/MEX. Shipping it would draw a rich road network on a US map
@ -2242,7 +2241,7 @@ async function renderPoster(zone, pins, zoneFC) {
// to run the SAME number of lines in each column — that is what makes three // to run the SAME number of lines in each column — that is what makes three
// columns read as one band instead of three unequal blocks, and it costs a line // columns read as one band instead of three unequal blocks, and it costs a line
// of height, which goes back to the map. // of height, which goes back to the map.
// ★ FROM series.json, NOT FROM HERE. These three blocks were the Driver's second complaint: they // ★ FROM series.json, NOT FROM HERE. These three blocks were the second reported problem: they
// tell the reader to divide the set among THIRTY disciples, which is coherent for ~30 UPG-100 // tell the reader to divide the set among THIRTY disciples, which is coherent for ~30 UPG-100
// zones and incoherent for ~170 country posters. The country wording is not a find-and-replace of // zones and incoherent for ~170 country posters. The country wording is not a find-and-replace of
// "thirty" with "a hundred and seventy" — he changed the SHAPE, from "split the set" to "ask // "thirty" with "a hundred and seventy" — he changed the SHAPE, from "split the set" to "ask

View file

@ -16,8 +16,8 @@
// never disagree with its poster about which groups belong to a zone. // never disagree with its poster about which groups belong to a zone.
// //
// ── EVERY STRING IS IN packet-strings.json ────────────────────────────────── // ── EVERY STRING IS IN packet-strings.json ──────────────────────────────────
// Driver: "it all needs to be generative so that I can generate it in multiple // It all has to be generated, so the packet can be generated in every language.
// languages." So no human-readable text is written in this file. LANG=xx picks a // So no human-readable text is written in this file. LANG=xx picks a
// block; missing keys fall back to English individually, so a partial translation // block; missing keys fall back to English individually, so a partial translation
// renders what it has rather than blanking the page. // renders what it has rather than blanking the page.
// //
@ -46,7 +46,7 @@ const REPO = join(__dirname, '../../..');
// ★ WHY THIS EXISTS. The country posters shipped rendering the UPG-100 packet: its kicker, its // ★ WHY THIS EXISTS. The country posters shipped rendering the UPG-100 packet: its kicker, its
// "1 of {n} UPG-100 lists" line, its narrowing funnel. Not a mislabel — there was NO series // "1 of {n} UPG-100 lists" line, its narrowing funnel. Not a mislabel — there was NO series
// parameter anywhere, so "the packet" and "the UPG-100 packet" were the same thing and nothing // parameter anywhere, so "the packet" and "the UPG-100 packet" were the same thing and nothing
// could disagree. Reported by the Driver, 2026-09-28. // could disagree.
// UNKNOWN OR ABSENT RESOLVES TO upg100, deliberately: every existing run must render byte-identical // UNKNOWN OR ABSENT RESOLVES TO upg100, deliberately: every existing run must render byte-identical
// output with no env var set, or this refactor is a rewrite wearing a parameter's clothes. // output with no env var set, or this refactor is a rewrite wearing a parameter's clothes.
const SERIES_ALL = JSON.parse(readFileSync(join(__dirname, 'series.json'), 'utf8')); const SERIES_ALL = JSON.parse(readFileSync(join(__dirname, 'series.json'), 'utf8'));
@ -120,8 +120,8 @@ const BASE_STRINGS = JSON.parse(readFileSync(join(__dirname, 'packet-strings.jso
// The renderer gets `strings` as a whole and does its own per-key locale fallback, so an override // The renderer gets `strings` as a whole and does its own per-key locale fallback, so an override
// applied only to this file's t() would render in the pages THIS file writes and not in the pages // applied only to this file's t() would render in the pages THIS file writes and not in the pages
// the RENDERER writes. One series, two different kickers, and nothing would fail. // the RENDERER writes. One series, two different kickers, and nothing would fail.
// Overrides are ENGLISH-ONLY until the Driver signs the wording off (series.json // Overrides are ENGLISH-ONLY until the wording is approved (series.json
// _draft_awaiting_driver), so they are applied to every locale ON PURPOSE: an English country // _draft_pending_decision), so they are applied to every locale ON PURPOSE: an English country
// kicker is visibly awaiting translation, whereas inheriting the translated UPG-100 kicker would // kicker is visibly awaiting translation, whereas inheriting the translated UPG-100 kicker would
// silently print the WRONG SERIES in fluent Russian. Loud beats wrong. // silently print the WRONG SERIES in fluent Russian. Loud beats wrong.
const SERIES_STRINGS = SERIES.strings || {}; const SERIES_STRINGS = SERIES.strings || {};

View file

@ -5,7 +5,7 @@
"The country posters shipped rendering the UPG-100 packet's framing: the 'UNENGAGED PEOPLE", "The country posters shipped rendering the UPG-100 packet's framing: the 'UNENGAGED PEOPLE",
"GROUPS' kicker, 'This packet is 1 of {n} UPG-100 lists', the UPG-100 narrowing funnel, and a", "GROUPS' kicker, 'This packet is 1 of {n} UPG-100 lists', the UPG-100 narrowing funnel, and a",
"below-map block telling the reader to divide the set among thirty disciples. Coherent for ~30", "below-map block telling the reader to divide the set among thirty disciples. Coherent for ~30",
"UPG-100 zones; incoherent for ~170 countries. Reported by the Driver, 2026-09-28.", "UPG-100 zones; incoherent for ~170 countries.",
"There was no series parameter anywhere: packet.mjs and packet-render.js knew only one series,", "There was no series parameter anywhere: packet.mjs and packet-render.js knew only one series,",
"so 'the packet' and 'the UPG-100 packet' were the same thing and nothing could disagree.", "so 'the packet' and 'the UPG-100 packet' were the same thing and nothing could disagree.",
"", "",
@ -47,9 +47,9 @@
"end; `strings` below is the half that carries 11 locales." "end; `strings` below is the half that carries 11 locales."
], ],
"translation_status": [ "translation_status": [
"The `country` English below is a DRAFT taken from the Driver's dictated shape and is NOT signed", "The `country` English below is a DRAFT, provisional, pending a product decision -- see",
"off -- see _draft_awaiting_driver on that entry. NO translations are to be made from it: a", "_draft_pending_decision on that entry. NO translations are to be made from it: a",
"paraphrase multiplied by 11 locales is 11 wrong strings. When he signs off the English, the", "paraphrase multiplied by 11 locales is 11 wrong strings. Once the English is approved, the",
"strings go to a HUMAN translator via dx-shepherd, which is this file's established practice --", "strings go to a HUMAN translator via dx-shepherd, which is this file's established practice --",
"all 11 locales of packet-strings.json carry real translations, never English echoes." "all 11 locales of packet-strings.json carry real translations, never English echoes."
] ]
@ -96,11 +96,10 @@
"'Series: List of 100 UPG's · map 11 of 28' and a side box calling it 'one of a 30-map set", "'Series: List of 100 UPG's · map 11 of 28' and a side box calling it 'one of a 30-map set",
"called the List of 100 UPG's'. My check:series-isolation passed because its vocabulary was a", "called the List of 100 UPG's'. My check:series-isolation passed because its vocabulary was a",
"HAND LIST of the phrases I had already found -- a PASS from it was only ever a lower bound.", "HAND LIST of the phrases I had already found -- a PASS from it was only ever a lower bound.",
"seriesName 'Country Maps' is the Driver's own wording, recorded in a generate.mjs design note", "seriesName 'Country Maps' is the series' agreed name, recorded in a generate.mjs design note",
"dated 2026-08-01: 'we'll call this series the Country Map series... inside of the quotes say", "dated 2026-08-01, which also made the series name a parameterized field. The kicker says UNREACHED rather",
"Country Maps. So that series should be a parameterized field.' The kicker says UNREACHED rather",
"than UNENGAGED because a country sheet lists the unreached groups living there, which is a", "than UNENGAGED because a country sheet lists the unreached groups living there, which is a",
"wider set than the unengaged -- see _draft_awaiting_driver; this wording is part of that draft." "wider set than the unengaged -- see _draft_pending_decision; this wording is part of that draft."
], ],
"_funnel_why": [ "_funnel_why": [
"The funnel narrows every people group on earth down to THIS UPG-100 list (Project 2000/7000/", "The funnel narrows every people group on earth down to THIS UPG-100 list (Project 2000/7000/",
@ -109,10 +108,9 @@
"Dropping it also removes 12 of the 17 series-specific keys from the translation bill: a page", "Dropping it also removes 12 of the 17 series-specific keys from the translation bill: a page",
"that does not render needs no locales." "that does not render needs no locales."
], ],
"_draft_awaiting_driver": [ "_draft_pending_decision": [
"THE ENGLISH BELOW IS A DRAFT, NOT APPROVED. It follows the shape the Driver dictated on", "THE ENGLISH BELOW IS A DRAFT, NOT APPROVED: provisional, pending a product decision on how",
"2026-09-28 but he has not signed off the wording. regions-plugin has asked him to dictate the", "the three blocks should print. Do not translate, print at scale, or quote as final",
"three blocks as he wants them printed. Do not translate, print at scale, or quote as final",
"until that lands; replace this key with the approved text when it does." "until that lands; replace this key with the approved text when it does."
], ],
"strings": { "strings": {
@ -142,7 +140,7 @@
"_networks_why": [ "_networks_why": [
"NOT a find-and-replace of thirty with a hundred and seventy. 'Divide the set among thirty", "NOT a find-and-replace of thirty with a hundred and seventy. 'Divide the set among thirty",
"disciples' is coherent at 30 and incoherent at ~170 (172 countries + China + India, per", "disciples' is coherent at 30 and incoherent at ~170 (172 countries + China + India, per",
"emit-countries.mjs), so the Driver replaced the SHAPE: not 'split the set' but 'ask which one,", "emit-countries.mjs), so the SHAPE was replaced: not 'split the set' but 'ask which one,",
"then give them that poster'. The numbers are gone from the headings for the same reason." "then give them that poster'. The numbers are gone from the headings for the same reason."
] ]
} }

View file

@ -1340,7 +1340,7 @@ const ROUTE = {
for (const b of built) { for (const b of built) {
const app = ROUTE[b.name]; const app = ROUTE[b.name];
if (!app) continue; if (!app) continue;
// ★ THE LEAK THIS LOOP CAUSED (found live 2026-09-24, Driver-critical): emit() already // ★ THE LEAK THIS LOOP CAUSED (found live 2026-09-24, critical): emit() already
// respects PRIVATE_TABLES and keeps those out of the repo -- but THIS loop copied from // respects PRIVATE_TABLES and keeps those out of the repo -- but THIS loop copied from
// out/tables into the BUNDLE's data dir without the same check, so field-access-visas.json // out/tables into the BUNDLE's data dir without the same check, so field-access-visas.json
// landed in plugin-designs/key-cities/data/tables/, flowed into the public snapshot AND the // landed in plugin-designs/key-cities/data/tables/, flowed into the public snapshot AND the

View file

@ -100,8 +100,8 @@ export const STYLE_REGISTRY = [
bestFor: ['research-dashboard'] }, bestFor: ['research-dashboard'] },
// ── GALAXY — black ground, BLUE water ──────────────────────────────────────────────────── // ── GALAXY — black ground, BLUE water ────────────────────────────────────────────────────
// DRIVER 2026-09-23: "the map style needs to have the BLUE WATER with like the BLACK GALAXY // The ask (2026-09-23): BLUE WATER against a BLACK GALAXY background. The continents go to deep
// background". The continents go to deep space; the oceans stay unmistakably water. // space; the oceans stay unmistakably water.
// //
// ★ WHY THIS IS A BASE STYLE + A PAINT PATCH, NOT A HAND-AUTHORED style.json. Writing a whole // ★ WHY THIS IS A BASE STYLE + A PAINT PATCH, NOT A HAND-AUTHORED style.json. Writing a whole
// style document would mean owning ~47 layers, a glyph endpoint and a sprite URL forever, and // style document would mean owning ~47 layers, a glyph endpoint and a sprite URL forever, and
@ -121,10 +121,9 @@ export const STYLE_REGISTRY = [
// non-text threshold, so the coastline is a real edge rather than a tonal hint. Darker, // non-text threshold, so the coastline is a real edge rather than a tonal hint. Darker,
// moodier blues were tried and measured first: #1b3a6b 1.74:1 and #1e4d8f 2.34:1 both fall // moodier blues were tried and measured first: #1b3a6b 1.74:1 and #1e4d8f 2.34:1 both fall
// UNDER 3:1, which is how an ocean ends up merely looking like slightly-less-black land. // UNDER 3:1, which is how an ocean ends up merely looking like slightly-less-black land.
/* ★ CORRECTED 2026-09-23 — THE FIRST GALAXY WAS A MISREADING OF THE ASK, and the Driver said so: /* ★ CORRECTED 2026-09-23 — THE FIRST GALAXY WAS A MISREADING OF THE ASK. Not a dark basemap: a
"I don't want a dark basemap, I didn't ask for one. I asked for a spherical GLOBE with a black spherical GLOBE on a black background (the galaxy, as the Mapbox map has it), with BLUE OCEANS
background — the galaxy, like the Mapbox map — with BLUE OCEANS like the Mapbox map, and the like the Mapbox map, and land that is NOT black.
land was NOT to be black."
The first attempt tinted a dark basemap (black land, recoloured water) — the night side of The first attempt tinted a dark basemap (black land, recoloured water) — the night side of
Earth. What he described is the DAY side seen from space: NORMAL land colours, the default Earth. What he described is the DAY side seen from space: NORMAL land colours, the default
blue oceans, and the blackness belongs to the SPACE BEHIND THE SPHERE, not to the ground. blue oceans, and the blackness belongs to the SPACE BEHIND THE SPHERE, not to the ground.
@ -133,7 +132,7 @@ export const STYLE_REGISTRY = [
background showing through outside the sphere (see DashboardMapPanel's .rd-map rule). */ background showing through outside the sphere (see DashboardMapPanel's .rd-map rule). */
{ id: 'galaxy', label: 'Galaxy', mood: 'the day side of Earth floating in space — normal land, blue oceans, black void behind the sphere', { id: 'galaxy', label: 'Galaxy', mood: 'the day side of Earth floating in space — normal land, blue oceans, black void behind the sphere',
mapbox: 'mapbox://styles/mapbox/standard', mapbox: 'mapbox://styles/mapbox/standard',
// ★ FOUND, NOT AUTHORED (Driver: "I want it replaced with something you can find online"). // ★ FOUND, NOT AUTHORED: replaced with a style that already exists online.
// VersaTiles Colorful — a maintained, production-free, keyless style (280 layers) whose ocean // VersaTiles Colorful — a maintained, production-free, keyless style (280 layers) whose ocean
// is a real blue (#bfd9f2), the closest of the surveyed free styles to the Mapbox look. The // is a real blue (#bfd9f2), the closest of the surveyed free styles to the Mapbox look. The
// alternatives measured and rejected: CARTO Voyager's water is pale teal #b0d0d6; OSM // alternatives measured and rejected: CARTO Voyager's water is pale teal #b0d0d6; OSM

View file

@ -1,8 +1,8 @@
/** /**
* formatCount — the ONE place the fleet decides how a COUNT is punctuated. * formatCount — the ONE place the fleet decides how a COUNT is punctuated.
* *
* ★ IT HOLDS en-US ON PURPOSE. Whether numerals follow the reader's locale is a DRIVER decision and * ★ IT HOLDS en-US ON PURPOSE. Whether numerals follow the reader's locale is provisional, pending a
* it is currently held, so this file changes nothing about what anyone sees: every caller renders * product decision, so this file changes nothing about what anyone sees: every caller renders
* exactly the digits and separators it rendered before. What it changes is the number of places that * exactly the digits and separators it rendered before. What it changes is the number of places that
* decision is written down — from thirteen call sites across four bundles to one. * decision is written down — from thirteen call sites across four bundles to one.
* *

View file

@ -31,7 +31,7 @@ fails, so a bad refresh cannot land quietly.
`library/scripts/research/out/iso3-to-iso2.json` is a DIFFERENT map with a different job: it is the `library/scripts/research/out/iso3-to-iso2.json` is a DIFFERENT map with a different job: it is the
176-country research spine that drives the data fetches, and expanding it changes what gets fetched — 176-country research spine that drives the data fetches, and expanding it changes what gets fetched —
a data-scope decision that belongs to the Driver, not a side effect of adding country names. The map a product decision about data scope, not a side effect of adding country names. The map
derived here is for DISPLAY NAMES ONLY and drives no fetch. CLDR agrees with all 176 values in the derived here is for DISPLAY NAMES ONLY and drives no fetch. CLDR agrees with all 176 values in the
research map, which is a useful independent check on it, but the two files are not interchangeable. research map, which is a useful independent check on it, but the two files are not interchangeable.