Card BUILD-SHAREABLE-FILTERED-TABLES, for researchers and media-to-movements teams: narrow the table to
the peoples you are working on, then give that exact view to a teammate or reopen it on a phone.
TWO GAPS, both measured on main before building.
1. IT WAS NOT REUSABLE. The share hash was a PRIVATE function inside
plugin-designs/research-dashboard/src/composables/useDashboardShell.js, so exactly one dashboard in
the fleet could produce a link. go-int's board had none — grepped, zero matches — and every future
kit dashboard would have had none either. Nothing in it was research-specific except its address.
It is now library/composables/useShareableView.js, parameterized: the bus and labelFor are passed
in, the filter and search param names are options, and no dashboard is named inside it. The
research dashboard supplies the one genuinely research thing — labelFor, for the display-only slug.
2. THE LINK WAS INCOMPLETE, AND SILENTLY SO. The hash serialized filterSet ONLY. Typing in the search
box is the commonest way this persona narrows a table, so a shared link reopened with MORE ROWS than
the sender saw, with nothing to tell the recipient their set was wider. For population data passed
between teams that is worse than a link that plainly fails. `q` is appended only when a term is
present, so a dashboard with an empty search box writes byte-identically the hash it wrote before.
`&` joins params now, so it is reserved in values alongside `;` `:` `*` `,` `%` — otherwise a search for
"rice & beans" would truncate the link at the ampersand.
check:share-policy grows a DRIVEN arm: the two pure functions are lifted from the kit source and RUN,
so nine guarantees are measured rather than asserted — the old format byte-for-byte on an empty search,
slug still display-only, multi-value still dropping the slug, the typed search reaching the link and
coming back, a literal comma in a people-group name surviving, an ampersand in the search surviving, and
both legacy shapes (JSON hashes, and old #f= links with no q) still restoring. No browser needed to
prove a string round-trips.
Source only. Rebuild: research-dashboard, dashboard-plus-grid.
check:filter-multivalue arm C follows the file: it lifted escapeValue/unescapeValue from the shell to
drive the hash round-trip, and said so plainly when they moved ("the hash format moved and this gate is
blind to it") rather than passing vacuously. Repointed to the kit module.
rega found ?card= throwing "Cannot read properties of null (reading 'country')" at the back
button. Card mode had no `selected` — the country — and the sheet reads selected.* in
FOURTEEN places, so guarding the one line rega pointed at would have left thirteen. The card
already knows its country, so `selected` is populated from it and every existing read is
satisfied; the curated-analysis block stays hidden on its own hasCurated test because a card
carries none of those fields. The back button is also hidden in card mode: it would have
opened a country sheet with no pins behind it.
The lesson is mine and worth the line. I shipped this having measured the shards, the byte
saving, the addressing and the card contents — 400 slugs resolving, a tier-0 and a tier-2
card correct — and reported it as done. Every one of those checks passed on a feature that
rendered nothing, because I cannot launch a browser and did not say that the render was
therefore unverified. Data being right is not the feature working.
So the gate grows the arm that would have caught it: a headless mount of ?card=<slug> from
the roster, asserting the page renders more than 400 characters and names the group, plus an
unknown slug that must still render. rega runs it (browser gates are rega's), which is
exactly why it belongs in the gate rather than in my own ad-hoc runs.
BUILD-FIELD-RESOURCE-CARD. For the catalyst standing with the group on a phone. Seeing ONE
group used to cost the engine, the world boundaries, the basemap tiles and the 2.3 MB pin
index before a single link appeared.
?card=<slug> renders the group's sheet alone — no engine, no boundaries, no tiles, no pin
index. Measured from the served files: the map transfers 7,942 KB plus a ~700 KB engine and
unbounded tiles; the card transfers 678 KB worst case, of which 661 KB is the bundle itself
and 17 KB the largest shard. 92% fewer bytes before tiles are counted at all. The bundle is
now the floor, and splitting it is a separate job I have not done.
SHARDED BY A STABLE HASH, after two measured failures: first two letters of the slug gave a
120 KB worst shard because every "Deaf <people>" lands in "de"; three letters left 110 KB
because those slugs all begin "dea". djb2 low byte gives 255 even shards, mean 9 KB, max
17 KB, and the leaf recomputes the key so there is no index and one request. The identical
function lives in both files: if either drifts the card 404s rather than serving somebody
else's group.
Flat filenames, card-<key>.json, because internal/generate-tree.mjs stages tables/*.json as
files and throws EISDIR on a subdirectory — shared build machinery, not mine to change for
one plugin.
Share uses navigator.share where it exists (it offers whatever the catalyst already has) and
falls back to the clipboard, with a cancelled share treated as the non-event it is. The link
carries doxa.life's own slug, so what arrives reopens this card rather than a search — the
slug now reaches the sheet from a tapped pin too, so sharing works from the map as well.
Honesty unchanged: 1,738 cards carry something in the group's own language, 369 carry
nothing and say so with the way in. Checked 400 roster rows resolve to a card, 0 missing;
a tier-0 and a tier-2 card both carry what they should.
The Plugin Studio's corner button read "rudder-voice" (rega, repo: it showed in the README retake). A widget
button now reads studio catalogue key widget:<row key>, falling back to the row label; widget:rudder-voice/
rudder-voice is "Rudder — voice to text" in all 11 locales (Rudder kept as the product name).
rega measured it at ?lang=ar: ".of 5,067 languages have…" and ".both key-gated" — English
runs inside an rtl block, so the browser moves their leading and trailing punctuation to
the wrong end. Thirty of them across four leaves, found by walking the template and
flagging every static English run with no dir anywhere in its ancestry.
Each run is wrapped in <bdi dir="ltr"> rather than giving the block a direction: these
paragraphs mix English prose with translated labels and numbers, and a dir on the
container would left-align the Arabic beside it. bdi isolates exactly the run and nothing
else.
The first pass broke the build and the second caught it: my tag regex ended a tag at the
first ">", so v-if="matches.length > 8" looked like text and the wrapper landed INSIDE the
attribute — v-if="matches.length > <bdi dir="ltr">8". check:sfc-parse failed, I patched
four sites by hand, and that left orphaned </bdi> closes. Reverting all four files and
redoing it with a quote-aware tokenizer was the cheaper fix than repairing my own repair:
30 runs, every open matched, 0 wrappers inside an attribute, SFC-PARSE and gates:pre 11/11
green.
Needs eyes: this is a bidi rendering fix and only a screenshot can confirm the punctuation
sits right. rega has the case at l17-7a7ca67b.
A neighbor dot showed a pointer and did nothing on click. Clicking a dot now picks
and scrolls to its card; clicking a card (or Enter on it) flies to the dot and rings
it, so the map and the brief agree on which neighbor you are looking at.
BUILD-CORNER-WIDGET-CLASS-RUDDER-IN-PLUGIN-STUDIO (design approved by dx-shepherd). A profile's .meta.json
declares "widget": { "corner": "bottom-right" | ... }; generate-tree carries it into manifest.json, so the
Studio keeps no list. The Studio mounts each widget once in a fixed corner host (z-index above everything),
outside the rail and the pane, collapsed to a button, with its open state remembered. Picking it in the rail
opens the corner instead of mounting a second copy in a pane. The host and the mounted element live in a
page-level singleton on document.body, so a Studio hot update or remount re-attaches the same element and
Rudder keeps its live stream. rudder-voice declares bottom-right. Needs eyes: open, HMR, collapse.
Its nowrap placeholder overflowed the bounded selection side and ran under
'Try a combination' at 1280 and 1440 (rega r82). The side now clips and the
placeholder ellipsizes, with its full text as the tooltip.
rudder-voice's connect() called new window.EventSource unconditionally from its mounted hook, so a headless
mount (and any host without EventSource) threw 'window.EventSource is not a constructor' and failed
check:profiles-mount in en and ar for every lane (go-int). It now shows the engine-not-running state.
profiles-mount: 140 mounted clean.
Rudder is the first plugin that isn't a map: it shows that one plugin-designs/ folder, developed live
in the Plugin Studio, can ship as a desktop app with its own local backend.
- plugin-designs/rudder-voice: <rudder-voice> element. Words stream into the end of a text area while
you type/paste anywhere (typing holds the stream; nothing duplicated or lost). Mic picker with
hot-plug, 3-second mic test, plain-language errors, info panel, privacy-safe debug report.
meta.json declares "targets": ["desktop"].
- backend/rudder-engine (Go): supervises NVIDIA NeMo-Speech.cpp (ggml, CPU) with the Nemotron 3.5 ASR
Streaming GGUF over a loopback WebSocket; records with parec/pw-record only while recording; recovers
dead Bluetooth mic links (reopen -> CVSD voice profile -> optional RUDDER_BT_RESET) and starts
headsets on CVSD. No telemetry: traced with strace, zero sockets/DNS while transcribing.
- Privacy: log lines redact MAC addresses and the home folder, transcripts are never logged, Host check
against DNS rebinding, CORS only for the desktop webview, random API key for the speech server.
- internal/backend-dev-plugin.mjs + vite.config.js: `bun run dev` starts built plugin backends and
proxies /api/<name> same-origin, so a plugin calls the same API in the studio and on desktop.
- backend/rudder-engine/setup-runtime.sh: fetches the runtime and model pinned by SHA-256.
Not yet: the Tauri desktop build (sidecar) and the PocketBase word-correction table.
The family names were typed in English. They now come from the shared religion
catalogue (religionFamilyLabel), in the legend, its list and the ready line; the keys
and the map filter stay English.
The seven map views read the shared messages.mapUnavailable through usePplrT, which
prefixes every key with pplr., so they rendered the raw key. They now read it with
tStatic, which takes a qualified key and follows the page locale.
Out: the grid's 'Open in Gospel Resources' opens the selected peoples in
resources-map by its ?groups= contract (DOXA slugs: all 2,107 of the
board's slugs are Gospel Resources' pin slugs; capped at 300). And every
committed selection is dispatched as a bubbling, composed
gospel-internet:selection event with codes (slugs, ISO 639-3, ISO 3166),
the host captured in setup. In: ?groups= arrives selected, ?codes= and
?code= narrow by language, exactly as Gospel Resources reads them.
The composite now marks its inactive pane inert, the contract upg-100-mfe follows, so
PosterWall forgets its shown card when the wall is out of view and a second press
selects the country instead of toggling it off to the world view.
Each chart's title is its question, its subtitle says what a bar is and
counts, and a computed takeaway answers it in words. 1: what they have in
their own language. 2: the largest people groups with nothing in their own
language, by name (7 of the 8 largest on the roster are Deaf communities).
3: of those with nothing, how many live where the country is online.
4: which languages most need the New Testament, by people who speak them
(736 languages, 153 million people). The stacked chart, the social
platform chart and the app chart are gone: no readable answer, or not a
gap.
The rail was sized to the map row alone (~300px), and its cards, children
of a flex column, shrank instead of scrolling: the measure toggle
collapsed to a 2px line and a caveat card was cut in half (rega, the
Driver). The rail now spans the map and the table rows, its children
never shrink, it opens at 360px, and chart labels get 130px so no band
name truncates.
Both halves of studio's L13U refusal were correct and both are mine.
1. THE LEGACY upg100WallIntro FALLBACK IS GONE. It existed for exactly one window — the three
plain-text keys were landing in studio's catalogue while this consumer was already written, so the
branch rendered the old markup-bearing key until they arrived. They are on main in all eleven
locales, so the condition can no longer be true: it is DEAD CODE that keeps a key alive, because
studio cannot delete upg100WallIntro while a line still reads it. A fallback whose condition cannot
fire is not safety, it is a reference.
The three remaining mentions in this file are COMMENTS, not code — verified that no live call site
anywhere in plugin-designs/ or library/ reads it, so it is now safe to delete.
2. THE GATE DEFAULTED TO A GITIGNORED FILE. check:zoning-declared read
library/scripts/posters/out/_records.json, which .gitignore:94 excludes, so on a clean checkout it
exited 75 — NOT MEASURED forever, indistinguishable from an unwired gate. studio measured exactly
that and correctly refused to wire it into gates:post. This tree has been here before:
check:packets-all SKIPped on every build for weeks for the same reason.
★ THE FIX IS A COMMITTED FIXTURE, NOT A LOOSER GATE. data/people-groups/zoning-records.json carries
slug plus every field a selector actually reads — 2,107 records, ~300 KB. country_code alone would
not do: the deaf zone reads rop1 and five African/European zones read wagf_block/wagf_region, which
is the same omission that made six zones silently resolve to nothing in the packet generator.
The live cache is still PREFERRED when present, so a local run measures the freshest data and CI
measures the committed one.
VERIFIED on a genuinely clean tree (git archive of the staged tree, no cache, no network):
PASS — 28 zones, 2,107 records, 2,096 covered, all five assertions green, exit 0.
check:i18n-keys-exist PASS, check:sfc-parse PASS.
At 390px the legend card covered most of the map on open, and its close
button was a 16px glyph (rega, row 75). Both legends (map and language
tree) now start folded to their Legend button below 520px, and the close
button is a 28px box.
The P1 that emptied Big Picture's Religion and Region charts was a free identifier:
useDashboardAggregations.js read NEUTRAL five times and never imported it. This gate exists for exactly
that class and did not fire, because it walked only .vue files. A bundle compiles .js and .vue alike,
so the gate was seeing half of what ships.
Two things were needed, and the first attempt got only one of them. Adding .js to the walker is not
enough: a .js module was then handed to Vue's SFC parser, which found no <script>, and acorn reported
"Unexpected token (2:1)" — the second line of the file's own doc comment. A .js file is its own script
block, so it now parses directly, with offset 0 so reported line numbers are the file's real ones.
VERIFIED BOTH DIRECTIONS, which is the only way to know a gate works:
- clean main: rc=0, no false positives
- NEUTRAL import removed again: rc=1, naming useDashboardAggregations.js:173 and :216
So it would have caught the P1 before it reached the Driver's console.
Blast radius measured on main first: exactly 2 findings, both false — DOMException and
createImageBitmap, standard browser globals missing from the known-globals list. Added. No other
owner's file is implicated, and nothing was changed outside this gate.
Two certain defects from source. What I could NOT settle without a browser is stated at the end
rather than guessed at.
1. THE PROBE READ THE WRONG NODE. The sign has to match the element the GRID lays out with. Both
probes read the SHELL; the handle is a grid child, so whatever direction the grid resolves the
handle inherits — and the shell is one node further out. A host that sets dir between the two, or
a shellRef that is not the grid itself, yields a sign that disagrees with the mirroring the reader
can see. Both the pointer and the keyboard path now try the handle first and fall back to the
shell, then to ltr, so a detached handle cannot throw mid-gesture.
2. research-dashboard's two handles used PHYSICAL margins (margin-right / margin-left) while the grid
they sit in mirrors — grid-template-columns and justify-self both follow the inline direction — so
in Arabic each handle's 4px overhang sat on the wrong side of its own column. go-int's board
already used the logical pair; this makes them agree.
WHAT WAS ALREADY RIGHT, so nobody re-fixes it: the xSign inversion itself is correct for a mirroring
grid and is DEPLOYED — 2 of 2 rtl checks present in the built gospel-internet, dashboard-plus-grid and
research-dashboard bundles, so this was never a stale-rebuild problem.
MEASUREMENT I NEED (rega, one line): during a drag in ar on the Gospel x Internet board, log
getComputedStyle on BOTH the handle and the shell and report each .direction. If the handle says rtl
and the shell says ltr, item 1 above is the whole bug. If both say ltr while the grid is visibly
mirrored, the mirroring comes from somewhere my probe cannot see and I will need that.
Source only. Rebuild: research-dashboard, dashboard-plus-grid, gospel-internet (library change).
On a phone the brief covers the bottom half, and the selected group and its neighbors
were framed behind it; both views now fit them into the visible strip. The prayer
map's "Tap a coloured dot" coach-mark, which covered the brief, is turned off here.
country-posters-mfe reads profile-config `view` ("map" or "wall") to choose its
opening pane, so an embed can start on the map.
Five lenses answered one question five ways on a 40-country snapshot, and the default
one silently dropped 27% of its senders. They are replaced by two views over one
dataset built from peoplegroups.org (7,307 unreached groups):
Mobilize pick an unreached group; see its reached neighbors at E-1 (same language),
E-2 (near culture) and E-3 (nearby, other culture), its P-level, what
resources exist in its language, and export them as audience segments.
Campaigns bridge languages spoken by believers and by unreached groups, ranked by
the unreached population one campaign in that language reaches: where to
run it, whom it reaches, exportable.
The dataset is fetched (bundle 1.27 MB -> 0.55 MB); the old view names open the new
views so existing embeds keep working. 65 new strings in all eleven languages.
The tapestry is laid out at half its former size in the map's world square,
so from the opening view the reader can zoom out two levels instead of
one. Every zoom-dependent stop (dot sizes, label entry zooms, framing
limits) moves up one level with it, so the opening view looks the same.
profile-config tk, then the token ProfileLoader provides (a tokenUrl
token-service answer or the page's shared token), then window.MAP_TOKEN.
It read only the first and last, so a host using tokenUrl got no Mapbox
token on either of the board's maps.
stage:experimental puts it on the Plugin Studio's Experimental shelf until it
works as a production map; heavy:true makes the Studio unmount it when
left, since it holds up to two WebGL contexts (map + language tree).
The Driver reported the list page being slow and guessed the posters. The
posters are behind a v-if and never load until a region is opened. The cost
was the mount fetch: it requested the full PEOPLE_GROUP_LIST_FIELDS for every
people group, and this page renders none of it -- a region's label, group
count, country count and country names all come from country_code and
wagf_region alone. Verified by running summarizeRegions on two-field rows:
the four values this page binds all come out.
SIZE OF THE SAVING: NOT MEASURED, and I will not put a number on it. Six of
eight fields are dropped per row across the whole dataset, including two
coordinates, a population integer and three status/count fields, so the
reduction is real -- but sizing it needs the live endpoint, which I cannot
call from here. My first attempt quoted a proxy off
public/assets/people-groups/people-groups-slim.json; that file is the IMB
dataset in PascalCase and carries NONE of the eight field names, so both
halves of that comparison were measuring empty objects. The figure was
meaningless and is removed rather than rounded.
PEOPLE_GROUP_LIST_FIELDS is untouched: the dashboard tab needs all eight for
its map pins and stat cards.
It owns up to two WebGL contexts (its map, plus the language-tree
whiteboard once opened). Kept mounted by v-show, it spent the context
budget that silently drops the oldest map. go-int's edit here was
dropped at landing.
82 lines, one importer (useDashboardShell), and every import already @map/ — so it moves as-is with
no adapter and no shim. Its inject('profileConfig') stays inside its try/catch: it can be called from
outside setup, and that is the property the move had to preserve.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
Landing 4a extracted buildSearchAccessors / makeSearchMatcher / filterRowsBySearch into
library/composables/useDashboardSearch.js. It left them DEFINED in useDashboardShell.js as well —
4.3KB of code-identical copies, including the Fuse.js matcher. I compared all three function bodies
with comments and whitespace stripped: identical, byte for byte.
Nothing had diverged, so nothing failed, and that is exactly why it survived: the facet rail ran the
library copy while the shell's KPIs and three panels ran the shell's. The comment that used to sit
above those definitions said the point of ONE declaration is that "the searchable text can never
drift from what the panels show" — and there were two. This tree has already paid for that shape once,
at Jaccard 0.82, with useNeighborT and usePplrT.
The shell now imports them like every other consumer, and DashboardChartsRail, DashboardDataTable and
DashboardMapPanel read '@map/composables/useDashboardSearch.js' instead of reaching through the shell.
No re-export shim: all four consumers are repointed in this commit, so there is nothing for a shim to
serve, and an unretired shim is how three older ones sat in the tree unnoticed until this afternoon.
check:filter-multivalue grows arm E, deliberately beside arm B. Arm B exists because a copied FILTER
predicate lets one panel disagree with the others about what is on screen; a copied SEARCH is the same
hazard with a different name. Arm E asserts the library module declares all three, that the shell
declares none of them, and that no file anywhere imports them from the shell. Its importer sweep walks
plugin-designs and library rather than a hardcoded list, because a hardcoded list of "files that might
import this" is the assumption that let 4a's copies survive.
Groundwork for 4d-4: this removes 4.3KB from the file that has to be split into a generic kernel and a
research composition root, and it removes three exports from the surface that split has to preserve.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
The Driver: a public plugin that is not yet a production map shows under Experimental until it is. A
profile's .meta.json declares "stage": "production" | "experimental" (default experimental; a bundle is
production if any profile says so) and "heavy": true for a live WebGL owner. generate-tree carries both into
manifest.json, so the Studio and the gallery read the same field and neither keeps a list.
The seven production bundles are marked: dashboard-plus-grid, research-map-plus-grid, regions-dashboard,
my-upg-100-list, doxa-simple-map, cities-internet, gospel-resources. The Studio rail keeps its capability
sections for them and puts every experimental bundle (gospel-internet among them) in one final Experimental
section. The gallery's auto-discovered Experimental folder skips a production bundle. gospel-internet-board
joins HEAVY_ELEMENTS (it can own two WebGL contexts). check:studio-rail asserts the split.
The numerals policy is HELD — whether counts follow the reader's locale is the Driver's decision — so
this changes nothing anyone can see. Every caller renders exactly the digits and separators it
rendered before. What changes is how many places that decision is written down: thirteen call sites
across four bundles becomes one, library/utils/formatCount.js, and eight of the thirteen (mine) now
read it.
That is the whole value while the policy is held. If the answer becomes "follow the locale" it is a
one-line edit in formatCount.js instead of thirteen edits by four owners, each free to pick a
different locale source — and the first divergence in that world would be invisible: two panels in
one dashboard, same number, different separators.
★ IT FORMATS. IT DOES NOT DECIDE WHAT EMPTY MEANS, and that is the bug this fold would otherwise have
shipped. I told dx-shepherd these three helpers were "byte-identical". They are not:
DashboardDataTable null/undefined -> '' and '' falls through to Number('') === 0, so "0"
DashboardDetailDrawer null/undefined/'' -> '—'
useDashboardShell null/undefined -> '—' and '' likewise becomes "0"
Three different answers to "what does a missing count look like", inside one dashboard. Folding them
onto one empty policy would have silently changed two of the three — a table cell going from blank to
an em dash, or an em dash where a zero used to be. So each caller KEEPS its own guard and hands
formatCount a value it has already established is real. The locale decision folds; the empty policy
does not, and the difference is written into the helper's header so the next person does not re-merge
them.
Folded: DashboardDataTable, DashboardDetailDrawer, useDashboardShell (its exported fmt keeps working
for the KPI strip), DashboardFacetRail's three inline template calls, people-groups-grid's two.
NOT folded, because they are other owners': regions-dashboard (3) and pplr-data-maps (2). The helper
is theirs to import when they want it; nothing forces them, and no gate demands it — a gate requiring
the helper everywhere would fail on their files today, which is not a thing to land on someone else.
Source only. research-dashboard, dashboard-plus-grid and people-groups-grid need a rebuild.
THE GROUP WAS SCHEDULED AS ONE LANDING AND DELIVERED AS TWO. Section 8 of the spec puts the facet
rail, the chip bar and the BREADCRUMB in a single landing. The rail moved in 4c-3 and the chips in
4c-2; the breadcrumb was left behind, and nothing failed, because it is mounted by nobody. A leftover
like that is invisible unless someone re-reads the spec against the tree, which is what this landing
came out of.
The breadcrumb gets exactly the inversion the chip bar got: no useTreeLegendLabels import, the
resolver read from dashboardConfig.legend, and the SAME unconfigured default — identity, joining an
array. Identical on purpose rather than by copy-paste: the breadcrumb and the chips render the same
filterSet in the same band, so if their defaults differed a half-configured dashboard would show one
value resolved and the other raw, side by side. Its dead `dataStore` / `searchSourceId` injects go
too; they existed only to build the composable that is no longer imported.
★ AND THREE ONE-CYCLE SHIMS FROM LANDINGS 2 AND 3 WERE NEVER RETIRED. plugin-designs/research-
dashboard/src/composables/ still held useDashboardLayout.js (11 lines), useSelectionCamera.js (11) and
useCsvExport.js (14), each a pure re-export of the library module. MEASURED before deleting: ZERO
importers. Every consumer — including DashboardMapPanel, DashboardDataTable, DashboardDetailDrawer,
useDashboardShell, gospel-internet's board and research-dashboard.vue — already reads
'@map/composables/…' directly, and a tree-wide grep for a relative import of any of the three returns
nothing. So they were dead code that made the plugin look like it still owned three library modules.
I wrote "a one-cycle shim that nobody retires is permanent" into the previous landing's gate comment
while three older ones were sitting in the tree unnoticed. They were not found by a gate; they were
found by listing what is still in plugin-designs and comparing it against the spec.
check:legend-mirrors arm 5 now guards three moved names instead of two, so a re-export of the
breadcrumb creeping back into the plugin barrel is caught the same way.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
The chip bar (4c-2) and the facet rail (4c-3) each moved into library/components/dashboards/ and were
re-exported from the plugin barrel for ONE cycle, so that moving a file and changing its importers
were separate, reviewable landings. That cycle is over.
BLAST RADIUS, measured on main rather than assumed: exactly ONE real importer. research-dashboard.vue
imported DashboardFacetRail from './src/components/dashboards'; it now reads
'@map/components/dashboards/DashboardFacetRail.vue'. DashboardFilterChips had ZERO importers, so its
shim served nobody and is simply gone. Everything else that named either component was a gate already
pointing at the library path, or prose.
check:legend-mirrors arm 5 INVERTS, and the inversion is the point rather than an edit. While the
shim existed, the risk was an importer failing to resolve, so the arm REQUIRED the re-export. With the
shim retired the risk is the opposite and larger: a re-export creeping back makes the plugin barrel
look like the owner of a library component, which is exactly how check:filter-multivalue once ended up
reading a shim instead of the panel it was written to guard, and passing vacuously. A one-cycle shim
that nobody retires is permanent, so the gate now holds the window closed — it fails on a returning
re-export AND on any file still importing either name through the barrel or its old path.
★ THE THREE FILES MUST LAND TOGETHER. Measured: the new gate produces 3 arm-5 failures against
unmodified main, because main still has both shims and the barrel import. check:legend-mirrors is in
gates:post, so landing the gate without the source changes turns every build red. All three failures
are in the two files this commit fixes, so nothing outside it is affected and no other owner is
blocked.
Two false positives of my own, both caught by running the gate rather than reading it:
- The importer test used `import … <Name> … [\s\S]{0,200}? from '…'`, and that span CROSSED INTO THE
NEXT STATEMENT: after the fix, research-dashboard.vue has the new single-name import immediately
followed by a braced import ending `from './src/components/dashboards'`, so the regex started on the
corrected line and found the barrel path two statements later. It reported the file it had just been
fixed in. Now bounded to one statement with a negative lookahead for the next `import`.
- That is the third unbounded-span false positive I have written today — the others walked a brace
match past a callback-less watch() and read a chip's internal name as prose. The shape is the
lesson, so it is written down beside the fix: never let a span cross a statement boundary.
Verified on main + these changes: check:legend-mirrors, check:sfc-parse, check:no-free-identifiers,
check:filter-multivalue, check:no-cross-plugin-imports, check:composites-mount, check:library-index and
check:setup-order all pass. Arm 5 negative-tested both ways: a returning barrel re-export and a
reverted importer are each caught, and the clean state passes.
Source only. research-dashboard and dashboard-plus-grid need a rebuild.
Six public/ conflicts, zero in source. They take main's side here and are regenerated from the merged
source in the next commit — a bundle is an output, and a hand-reconciled middle version is wrong by
construction.
A vector SVG of the current selection, one sheet of the chosen size,
printed in place (library printInPlace) or downloaded for a print shop.
Every label is placed so that none overlaps another or covers a circle:
families on top of their dish, leaf names running outward like spokes,
the rest beside their dot, and a name that cannot fit is left off and
counted on the sheet. The poster has its own layout (build step), with
slots for languages only, because resources are catalogued per language:
671 of 858 languages named at 3 ft, 779 at 4 ft (all families), 0 overlaps.
check:language-tree measures both sizes.
A strip under the title shows what is selected, as the kit's removable
filter chips (the board is DashboardFilterChips' second consumer: it
provides selectionBus and dashboardConfig), with a language-tree node
named as the node, not its language codes. Beside it, seven combinations
a mobiliser would run, each with its live count for the current list:
nothing in their language in a country 60%+ online (220), hear but cannot
read in an online country, nothing in their language offline, in a
WhatsApp or a Telegram country, where a Deaf bridge exists, and India and
China. A combination is a filter set on the one bus, so the map, the
language tree, the charts and both tables all follow it.
P1, rega on main a71007f1: "NEUTRAL is not defined", both engines, both languages, and the two charts
render nothing.
Kit landing 4c-1 moved NEUTRAL into library/composables/useFacetAggregation.js and updated the
RE-EXPORT line in useDashboardAggregations.js, but not the IMPORT line beside it — so the module
re-exported a name it could not itself read. colorFor() and buildCountryRegionColors() read NEUTRAL
five times as the "this facet has no palette for that key" colour, and every read threw.
One line: NEUTRAL joins the import. Nothing else changes.
The wall opened at the whole-wall view, where a card is 11-15 CSS px, under wallLod's 48 px photo
threshold, so every card showed its empty image slot: the README picture read as a blank grid (the
Driver: "zoomed too far out... hard to see the people groups"). It now opens at the top-left corner,
where the store puts the biggest clusters, with cards ~96 px wide. Checked headless with lodPlan on
the component's own geometry (2,107 groups, 53x40): desktop 1440x820 k=7.5, 77 photos; 1280x720
k=8.6, 60; phone 390x700 k=20 (max), 15 photos, all 'hd' and far under the 300 cap, so the memory
win holds. Reset Zoom still shows the whole wall. Rows were 3 units apart against 8 between columns
and left a 200-unit empty band at the bottom of the 4ft x 3ft canvas; 8 fills it exactly.
Built with BUNDLE=wall-of-people-groups only, then the tree; check:sri PASS.
The tapestry is laid out at half its former size in the map's world square,
so from the opening view the reader can zoom out two levels instead of
one. Every zoom-dependent stop (dot sizes, label entry zooms, framing
limits) moves up one level with it, so the opening view looks the same.