The poster asked for legend rows from a key the composable never had

useLegendData returns { items, columns, title, activeFilter, setFilter,
totalCount, totalPopulation, progress, fmtPop, fmtCount }. There is no `rows`.
research-map read legend.rows in three places, and because JavaScript answers
undefined rather than complaining, each one failed in a different costume:

  - provide('legendRows', legend.rows) provided undefined, and nothing in the repo
    injects that key -- the comment above it described a contract no component
    implements
  - openPoster's fallback, legend?.rows?.value || [], could only ever be [], so on
    any tab where the flat legend is empty the poster had no legend rows
  - the packet's page-1 slot was rows: legend?.rows?.value || [] with NO second
    source, so that poster's legend was empty unconditionally

and useMapPoster skips the legend slot entirely when the row list is empty. So the
missing legend on the exported poster had two independent causes: the renderer
painting it over the map, fixed separately, and this -- an empty array arriving
through a fallback that could never be anything else.

Both poster paths now share one builder, so they cannot drift apart again, and it
flattens children because a prayer legend is a parent plus two child tiers and
printing only the parents describes bands the map does not draw.

Two further calls went the same way: legend.refresh?.() and
legend.build?.(normalizedPeopleGroups), against methods the composable has never
defined. The optional call swallowed both, so two lines that read like steps did
nothing. `items` is a computed and re-derives on its own, so they are gone rather
than repointed.

exp-shepherd found the same misspelling in countries-map, where it threw instead
(NE-17). The check therefore guards the class rather than these lines: it reads
the returned key set from the composable and reports any consumer reading outside
it. It is NOT in gates:pre yet, because it still reports countries-map, which
belongs to another owner.

Writing it turned up two traps worth keeping. Stripping block comments with one
global regex DELETED REAL CODE -- a slash-star inside an ordinary string opens a
comment nobody wrote and the replace runs to the next real terminator -- and the
check confidently reported "no consumers at all" while both sat in the files. And
matching property reads by name flagged every t('legend.colLabel.religion'): an
i18n key spelled like a property access is not a read.
This commit is contained in:
Doxa Maps 2026-09-28 19:42:30 +00:00
parent 0147b6c2c6
commit fa14fedfdd
3 changed files with 165 additions and 8 deletions

View file

@ -0,0 +1,127 @@
#!/usr/bin/env node
/**
* check-legend-data-contract — nobody may read a key off useLegendData that it does not return.
*
* WHY A WHOLE-CLASS CHECK AND NOT A ONE-LINE FIX. useLegendData returns
* { items, columns, title, activeFilter, setFilter, totalCount, totalPopulation, progress, fmtPop,
* fmtCount }. There is no `rows`. Two plugins independently read `legend.rows` anyway, and because
* JavaScript answers `undefined` rather than complaining, each failure wore a different costume:
*
* - countries-map: Export poster THREW (exp-shepherd's NE-17, fixed in d6916e05e)
* - research-map: provide('legendRows', legend.rows) provided undefined; the poster fallback
* `legend?.rows?.value || []` could only ever be []; and the packet's legend slot was
* `rows: legend?.rows?.value || []` with no second source, so that poster printed NO legend at
* all — useMapPoster skips the slot when the row list is empty.
*
* The empty legend was the expensive one, because it looks like a layout or data problem and is
* really a misspelt key resolving quietly to undefined. One line per plugin would have fixed the two
* known cases and left the next one to be found by a Driver looking at a blank poster.
*
* The returned key set is READ FROM THE COMPOSABLE, not restated here, so adding a key to it does
* not require editing this file — and removing one immediately names every consumer that breaks.
*
* ★ IT DOES NOT STRIP BLOCK COMMENTS, AND THAT IS DELIBERATE. The obvious way to ignore comments is
* one global regex replacing every block comment with nothing. On these files that DELETES REAL
* CODE: a slash-star sequence inside an ordinary string — a glob path, a URL — opens a comment the
* author never wrote, and the replace then runs from there to the next genuine block-comment
* terminator, taking every line between with it. Written that way, this check reported "no consumers
* at all" while both consumers sat plainly in the files, because the very lines it was looking for
* had been erased before it looked. Comments are skipped per LINE instead, which cannot swallow code
* it does not understand.
*
* (Writing that regex out in this comment closed this block early and broke the file's parse, which
* is the same trap one layer up: a comment describing a terminator must not contain one.)
*
* Headless, no browser, no dependencies. Exit 0 pass · 1 fail.
*/
import { resolve, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync, readdirSync, statSync } from 'node:fs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const COMPOSABLE = 'library/composables/legends/useLegendData.js'
const fails = []
// ── what it actually returns ──────────────────────────────────────────────────
const isCommentLine = (line) => /^\s*(\/\/|\*|\/\*)/.test(line)
/** Is column `col` of `line` inside a quoted span? Quotes toggle; a backslash escapes the next char. */
function inString(line, col) {
let quote = null
for (let i = 0; i < col && i < line.length; i++) {
const c = line[i]
if (c === '\\') { i++; continue }
if (quote) { if (c === quote) quote = null }
else if (c === '"' || c === "'" || c === '`') quote = c
}
return quote !== null
}
const csrc = readFileSync(resolve(ROOT, COMPOSABLE), 'utf8')
// the composable's own top-level return: the LAST `return {` in the file, which is the one that
// closes useLegendData itself rather than a computed's early exit.
const lastReturn = csrc.lastIndexOf('return {')
if (lastReturn < 0) { console.error('LEGEND-DATA-CONTRACT: FAIL\n ✗ could not find useLegendData\'s return object'); process.exit(1) }
const block = csrc.slice(lastReturn + 'return {'.length, csrc.indexOf('}', lastReturn))
const RETURNED = new Set(
block.split(',').map((t) => t.split(':')[0].trim()).filter((t) => /^[A-Za-z_$][\w$]*$/.test(t))
)
if (RETURNED.size < 5) { console.error(`LEGEND-DATA-CONTRACT: FAIL\n ✗ parsed only ${RETURNED.size} returned keys; the parse is wrong, not the code`); process.exit(1) }
if (RETURNED.has('rows')) fails.push('useLegendData now returns `rows` — this check\'s premise changed; re-read it before trusting the result')
// ── every consumer ────────────────────────────────────────────────────────────
const walk = (dir, out = []) => {
for (const e of readdirSync(dir)) {
if (e === 'node_modules' || e === '.git' || e === 'public') continue
const p = resolve(dir, e)
if (statSync(p).isDirectory()) walk(p, out)
else if (/\.(vue|js|mjs)$/.test(e)) out.push(p)
}
return out
}
const files = [...walk(resolve(ROOT, 'library')), ...walk(resolve(ROOT, 'plugin-designs'))]
let consumers = 0
for (const file of files) {
const raw = readFileSync(file, 'utf8')
if (!/useLegendData\s*\(/.test(raw)) continue
if (file.endsWith('useLegendData.js')) continue
const src = raw
const bind = src.match(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*useLegendData\s*\(/)
if (!bind) continue
consumers++
const name = bind[1]
const rel = relative(ROOT, file)
const useRe = new RegExp(String.raw`\b${name}\s*(?:\?\.|\.)\s*([A-Za-z_$][\w$]*)`, 'g')
const bad = new Map()
let m
while ((m = useRe.exec(src))) {
const key = m[1]
if (RETURNED.has(key)) continue
// Comments legitimately discuss the old spelling (this fix is documented in both plugins), so a
// match on a comment line is ignored — per line, never by deleting spans of the file.
const before = src.slice(0, m.index)
const line = before.split('\n').length
const lineText = src.split('\n')[line - 1] || ''
if (isCommentLine(lineText)) continue
// ★ AND NOT INSIDE A STRING. t('legend.colLabel.religion') is an i18n KEY that happens to be
// spelled like a property access on this very binding, and every such key was reported as a
// violation on the first run. A translation key is not a read.
if (inString(lineText, m.index - (before.lastIndexOf('\n') + 1))) continue
if (!bad.has(key)) bad.set(key, [])
bad.get(key).push(line)
}
for (const [key, lines] of bad) {
fails.push(`${rel}: reads \`${name}.${key}\`, which useLegendData does not return ` +
`(line${lines.length > 1 ? 's' : ''} ${lines.join(', ')})\n` +
` it returns: ${[...RETURNED].join(', ')}\n` +
` JavaScript answers undefined here, so this fails as a blank legend or an empty array, not as an error`)
}
}
if (!consumers) fails.push('found no useLegendData consumers at all — the scan is broken, not the code')
if (fails.length) {
console.error('LEGEND-DATA-CONTRACT: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log(`LEGEND-DATA-CONTRACT: PASS — ${consumers} consumer(s) read only keys useLegendData returns (${[...RETURNED].join(', ')}).`)

View file

@ -145,6 +145,7 @@
"check:leaf-english-leaks:self-test": "node internal/check-leaf-english-leaks.mjs --self-test",
"check:leaves-mount": "node internal/check-gospel-leaves-mount.mjs",
"check:leaves-mount:poison": "node internal/check-gospel-leaves-mount.mjs --poison",
"check:legend-data-contract": "node internal/check-legend-data-contract.mjs",
"check:legend-export-reachable": "node internal/check-legend-export-reachable.mjs",
"check:legend-logical-insets": "node internal/check-legend-logical-insets.mjs",
"check:legend-logical-insets:self-test": "node internal/check-legend-logical-insets.mjs --self-test",
@ -211,6 +212,7 @@
"check:one-scroll-container": "node internal/check-one-scroll-container.mjs",
"check:pin-selection": "node internal/check-pin-selection.mjs",
"check:poster-action-print": "node internal/check-poster-action-print.mjs",
"check:poster-attribution": "node internal/check-poster-attribution.mjs",
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
@ -420,6 +422,7 @@
"check:layout-key-namespaced": "two dashboards on one page must not share a saved layout. useDashboardLayout persists the four divider positions to localStorage, and until kit landing 2 the key was a single constant — invisible while there was exactly one dashboard, and SHARING THE ENGINE IS WHAT MAKES A SECOND ONE POSSIBLE, so SPEC-DASHBOARD-KIT section 8 named it as that landing's risk. The failure is quiet rather than loud: drag a divider in one dashboard, reload, and the other has moved. Asserts the shell passes no bare literal, EVALUATES the real expression with two different dashboard ids to prove the key varies (never a retyped copy of the rule), and that the pre-namespacing key is READ and never written — namespacing must not silently discard a reader's layout, and a migration that also deletes cannot be rolled back. Static; no DOM, no browser.",
"check:leaf-engine-per-provider": "every leaf in the manifest at ?provider=maplibre makes ZERO requests to any *.mapbox.com host and a map leaf still draws; at its default every map leaf draws with the engine it drew with before (read from the canvas that drew). --baseline <tree> compares against another tree (e.g. main). Browser gate, @browser-slot-max-hold 2700.",
"check:leaves-mount": "DID THE ELEMENT ACTUALLY MOUNT, on all five gospel leaves. Exists because a ReferenceError thrown during setup dies INSIDE the custom-element boundary: the page loads, every asset returns 200, pageerror fires zero times, and the element sits there with an empty shadow root. A healthy-looking dead page. This asks the cheap unambiguous question the rich gates are too slow to ask on every leaf — is anything there — in five steps that each name their own failure: tag defined, host present, shadowRoot attached, shadow root non-empty, visible text rendered. It does NOT trust the absence of console errors; zero errors is not the same as working. --poison for the negative test.",
"check:legend-data-contract": "nobody may read a key off useLegendData that it does not return. It returns items, never rows; two plugins read legend.rows anyway and JavaScript answered undefined, so the same mistake surfaced as a THROWN Export poster in countries-map (exp NE-17) and as a silently BLANK poster legend in research-map. The returned key set is read from the composable rather than restated. NOT in gates:pre yet: it still reports countries-map, which is another owner's file (exp's d6916e05e covers .rows but not the dead .build/.refresh calls). Run it directly.",
"check:legend-export-reachable": "the Export affordance must be reachable at BOTH widths. Selects a leaf row first (the control is gated on selectedNode) and opens the mobile sheet first (it starts closed). Asserts a REAL BOX, not CSS visibility — on mobile the elements report visibility:visible at 0x0. PASSES at both widths since the mobile sheet was given :export-enabled and :tabs. SKIPs when nothing is served.",
"check:legend-logical-insets": "the legend components position themselves with LOGICAL insets (inset-inline-start/end, padding-/margin-inline-*). A physical left/right does not mirror under dir=\"rtl\" while the map controls (inset-inline-end) do, so in Arabic the controls overlapped a legend that stayed left, on Simple Research, Prayer Progress and UPG-100 at once. Symmetric rules (left:50%, left:0 with right:0), auto resets and a short ALLOWED list with reasons pass.",
"check:legend-logical-insets:self-test": "poisons .stl-panel back to left:8px and asserts check:legend-logical-insets notices.",
@ -467,6 +470,7 @@
"check:popup-escaping": "the library's pin and region popups render hostile data (an <img onerror>, a <script>, a javascript: URL) as text, never markup: every field goes through escapeHtml/safeUrl (library/utils/html.js). Headless.",
"check:pin-selection": "the shared source reader understands BOTH engines: mapbox-gl stores a GeoJSON source _data as the FeatureCollection, maplibre-gl wraps it as { geojson } (or { url } when remote). Reading only the Mapbox shape returned an EMPTY list on MapLibre — the keyless engine public builds use — which silently killed zoomToLegendRow camera fits on three maps and the research packet row selection, with no error because every caller no-ops on no features. Asserts both shapes, that a remote source returns [] AND warns, the serialize fallback, and that selectedPinIds agrees with the reader. Headless, no map. Exit 0 pass, 1 fail.",
"check:poster-action-print": "a poster button opens the print dialog (Driver rule 2026-09-28: never a direct download). PosterWall's posterAction defaults to 'print', and any consumer that opts into 'download' carries a posterAction-download: comment saying why. Source-only, headless.",
"check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '© Mapbox © OpenStreetMap', which under the fleet's MapLibre default credits a company that served nothing. Checked live 2026-09-28: the OpenFreeMap STYLE declares no attribution on either source — the credit is in the TileJSON, merged into the SOURCE object after load — so it is read from map.getSource(id), flattened from HTML, deduped, and an unreadable map falls back rather than printing a blank line. Headless; in gates:pre.",
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",
"check:poster-print-fallback": "clicks the REAL poster print control with every posters/*.webp aborted, and asserts the preview RASTERISES from posters/<id>.html instead of opening on \"Poster not available yet\". Needs a served tree built with posterSources:true; SKIPs otherwise.",

View file

@ -901,7 +901,13 @@ const legend = useLegendData({
dataStore,
legendType: activeLegendType
})
provide('legendRows', legend.rows)
// ★ REMOVED: provide('legendRows', legend.rows). useLegendData returns `items`, never `rows`, so
// this provided undefined from the day it was written, and nothing in the repo injects 'legendRows'
// — LegendDesktop and LegendMobile read the `legend` key instead, which is what the comment above
// used to claim was happening. exp-shepherd found the same line in countries-map, where it was not
// merely dead: it made Export poster throw (NE-17, their d6916e05e). A provide that is undefined and
// a comment that describes a contract nobody implements are worse together than either alone, so
// both go. If a consumer ever needs these rows, it should provide legend.items and say so then.
// SemanticTreeLegend (and LegendFamilyTree.legacy) need raw normalized
// people-groups, not pre-aggregated legendRows. mapData owns the normalized
// array; expose to descendants.
@ -1410,6 +1416,25 @@ watch(() => mapStore.selectedRegion, (key) => {
// Now the button does the export itself, through the SHARED poster path (useMapPoster → render →
// print), with a sensible print default: the current view on US Letter portrait at 200 DPI, the
// legend title and rows, and a source/date footer. One click, one dialog, the poster in the preview.
// The legend rows a poster prints, from the ONE composable that actually has them.
//
// ★ useLegendData RETURNS `items`, NOT `rows`. Both poster paths below read `legend.rows.value`,
// which is undefined, so `|| []` silently handed the renderer an empty list — and useMapPoster skips
// the legend slot entirely when there are no rows. That is why the exported poster had no legend:
// not a layout problem but an empty array, arriving through a fallback that could never be anything
// else. The booklet path had no second source at all, so ITS legend was empty unconditionally.
//
// Children are flattened in because a prayer legend is a parent plus two child tiers, and a printed
// legend that shows only the parents describes bands the map does not draw.
function posterLegendRows(preferFlat) {
const flat = preferFlat ? (flatLegend.items.value || []) : []
const src = flat.length ? flat : (legend?.items?.value || [])
return src
.flatMap((it) => [it, ...(it.children || [])])
.map((r) => ({ label: r.label, color: r.color }))
.filter((r) => r.label)
}
const posterBusy = ref(false)
async function openPoster() {
if (posterBusy.value) return
@ -1442,8 +1467,7 @@ async function openPoster() {
spec.dir = ['ar', 'fa', 'he', 'ur'].includes(String(uiLocale.value || '').split('-')[0]) ? 'rtl' : 'ltr'
spec.slots.title = { ...spec.slots.title, text: title }
// The legend the reader sees: the flat tab's rows (and their sub-rows), else the aggregated ones.
const flatRows = (flatLegend.items.value || []).flatMap(it => [it, ...(it.children || [])])
const rows = (flatRows.length ? flatRows : (legend?.rows?.value || [])).map(r => ({ label: r.label, color: r.color }))
const rows = posterLegendRows(true)
spec.slots.legend = { ...spec.slots.legend, rows, title: legendTitle.value }
// No scale bar or north arrow: the headless renderer's are placeholders (a fixed bar reading
// "scale"), which on a print would be a false statement — and a globe has no single scale.
@ -1584,7 +1608,7 @@ async function openPacket() {
// separate document and an object URL created here would be revoked out from under it.
const spec = defaultPosterSpec({ widthIn: 8.27, heightIn: 11.69, dpi: 150, orientation: 'portrait' })
spec.slots.title = { ...spec.slots.title, text: legendTitle.value || 'Doxa Map' }
spec.slots.legend = { ...spec.slots.legend, rows: legend?.rows?.value || [] }
spec.slots.legend = { ...spec.slots.legend, rows: posterLegendRows(true), title: legendTitle.value }
let mapSvg = ''
try {
const mapBlob = await useMapPoster(() => map.value).render(spec, 'png')
@ -1791,8 +1815,9 @@ function switchTab(tabId) {
map.value.moveLayer('language-family-pins')
}
}
// Re-aggregate legend rows for the new lens
legend.refresh?.()
// Nothing to re-aggregate by hand: useLegendData exposes `items` as a COMPUTED, so the legend
// re-derives from the new lens on its own. This used to call legend.refresh?.(), which the
// composable has never defined — the optional call made a no-op look like a step.
// Re-color clusters (cluster paint depends on active strategy)
clustering.refreshColors?.()
}
@ -2874,8 +2899,9 @@ async function onMapReady(normalizedPeopleGroups) {
if (activeTab.value?.id === 'prayer') mapLayers.startPrayerGlow()
// Hand the same dataset to clustering so MST/network can rebuild on toggle
clustering.setData?.(normalizedPeopleGroups)
// Build the initial legend
legend.build?.(normalizedPeopleGroups)
// No build step: `items` is a computed over the same data, so the legend appears when the data
// does. This used to call legend.build?.(normalizedPeopleGroups) against a method that does not
// exist; ?.() swallowed it, so a line that read like initialisation did nothing at all.
// Selected-pin highlight + GO marker — must come after the language-families
// source exists (added by addLanguageFamilyLayer above).
selectedPin.initialize()