From 4904d63e27f741a51d793e41a19f99059952c6fd Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 19:42:30 +0000 Subject: [PATCH] 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. --- internal/check-legend-data-contract.mjs | 127 ++++++++++++++++++ package.json | 2 + .../doxa-research-map/research-map.vue | 42 ++++-- 3 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 internal/check-legend-data-contract.mjs diff --git a/internal/check-legend-data-contract.mjs b/internal/check-legend-data-contract.mjs new file mode 100644 index 000000000..8bbe5328f --- /dev/null +++ b/internal/check-legend-data-contract.mjs @@ -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(', ')}).`) diff --git a/package.json b/package.json index e43ed673c..b8599fc61 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,7 @@ "check:poster-layout": "node internal/check-poster-layout.mjs", "check:map-control-overlap": "node internal/check-map-control-overlap.mjs", "check:poster-attribution": "node internal/check-poster-attribution.mjs", + "check:legend-data-contract": "node internal/check-legend-data-contract.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", @@ -374,6 +375,7 @@ "check:poster-layout": "the EXPORTED poster is a layout, not a map with things painted on it (Driver 2026-09-28: the legend beside the map, never over it, with a proper header and footer). usePosterLayout always computed the right geometry; the renderer never used it, spawning the hidden map at FULL PAGE size and compositing the title, legend and footer on top. One arm checks the geometry on four real sheets, the other checks the renderer CONSUMES it — the link that was missing, and the reason a computeLayout-only test would have passed throughout the bug's life. Headless; in gates:pre.", "check:map-control-overlap": "two controls pinned to the same corner of the research-dashboard map must not land on each other (Driver 2026-09-28: \"some map controls overlap each other\"). The zoom toolbar was pinned at a literal top:34px, tuned when the projection toggle measured 23px tall; a later WCAG 2.5.8 fix gave the toggle min-height:24px, which in a shadow root with no global box-sizing rendered 34px and ran 8px into a toolbar drawn over it at z-index 10. The check COMPUTES the toggle's rendered height from its own declarations, honouring box-sizing, and requires the toolbar to clear it and to DERIVE its offset rather than restate a number. Headless; in gates:pre.", "check:poster-attribution": "the poster footer is a LICENCE statement, so it must credit whoever actually served the tiles. posterDefaults ships '\u00a9 Mapbox \u00a9 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 \u2014 the credit is in the TileJSON, merged into the SOURCE object after load \u2014 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: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:describe-group": "the prayer packet's generated profile sentence (library/packets/describeGroup.js): each GSEC level states its own fact, and an index-ordered language name (\"Arabic, Moroccan\") is un-inverted by library/utils/languageLabel.js without shredding a comma inside parentheses, the 13 names the old split printed reversed. A catalogue arm renders every GSEC level from the REAL packet-strings.json in en and ar and fails on a blank sentence. Headless; in gates:pre.", "check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)", "check:done-evidence": "a card may not reach status:done with NO bluf AND NO fields — the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.", diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 0429ed9f6..e7ba08ba1 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -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()