file-map.md documented `SOW_EXPORT_BUTTON=false` as a hidden global switch that "hides the
fully-wired Export menu across ALL bundles". SOW_ appears nowhere in this repo, so anyone setting
it would get silence and conclude the Export menu was broken. Docs describe the code as it is, so
the claim is gone.
The per-component prop it mentioned is real and untouched: LegendMobile.vue passes
:export-enabled. Only the global env override was fictional. MAX_LIVE went with the staging.js row
it lived in, and neither name appears in README.md or documentation/ now.
If a global export switch is wanted, that is a card, not a doc edit.
Audited README.md and all 35 documentation/*.md against main. Script names and markdown links
were already clean — 59 documented scripts all exist in package.json, 0 broken links. What was
wrong was file-map.md describing files that no longer exist, and three paths that had moved.
REMOVED, all archived in afb998f17 or earlier and still documented as current:
internal/staging.js — the repo-root staging page; the plugin studio is the only entry now
index.html — the same page's HTML
library/composables/useDoxaSearch.js — deleted in ea0c47b27, and dropped from a prose list too
REPOINTED: the three colour strategies documented under plugin-designs/doxa-research-map/src/colors/
live in library/colors/. THIRD-PARTY.md's two vendored files live under data/vendor/.
embeddability.md's reference to the staging script now says it was archived and where the entry is.
WHAT I DID NOT "FIX", because it was not broken: ~20 relative shorthand paths in the architecture
docs (`composables/useMapData.js` inside a document about library/) read correctly in context, and
three paths name things that are not repo files at all — a runtime endpoint (public/api/maps/token.json),
a generated artifact (out/_records.json) and a placeholder (./bundle/bundle.js). Reporting those as
defects would have buried the five real ones in 23 false positives.
TWO MEASUREMENT TRAPS I fell into on the way, both corrected before reporting: my first env-var scan
grepped only internal/, plugin-designs/ and library/ and declared 13 documented env vars unread —
11 of them are read in internal/build-lock.sh, .env.example, deployment/cloud/Dockerfile and the Go
backend. And an earlier sweep of my own data files reported six "rega" hits that were all the word
"aggregates".
main is the shared sandbox the dev server follows; /cdn is built only from PRODUCTION_BRANCH (default
production), which moves when someone promotes: git push origin main:production. The container watches
that branch and rebuilds /cdn once it has held a new commit for CDN_AUTOBUILD_MINUTES (default 2; 0 = the
admin button only); a failed commit is not retried automatically. The build fetches the branch into its
own ref (never touching the dev server's FETCH_HEAD) and uses that commit's own exclusion list, so
loosening it takes a promotion too. DEPLOY.md documents the promote step.
A preset page baked only the instance's props, so every default in the profile's
.meta.json config that the instance did not repeat was lost (go-int: upg-neighbor's
five presets dropped pinHint:false). instanceHtml now takes the meta config first
and the props over it. check:preset-config-merge (gates:post) reads every built
preset page and fails on a lost default; the rule is written into
PRESET-PLUGIN-STANDARD.md. Affected before the fix: upg-neighbor only; no
production preset has profile defaults to lose.
Laptop (bun run dev, zero config); a shared cloud dev server (Docker or Coolify, with a persistent /data);
public embeds from /cdn or any static host; the built folder inside a site's public/, as DX-life does.
Each with the command, what you get, and the detailed doc.
upg100-plugin caught it: my THIRD-PARTY.md row 17 stated their licences table
was EMPTY on the very commit where it held all 162. Our landings crossed and
neither of us could see the other's tree. Worse than they framed it -- my own
two files disagreed, since data/boundaries/ATTRIBUTION already carried the 162
and the three duty classes.
Row 17 now records what is true: 162 licences established, 32 duty-free, 65
attribution-only, 65 share-alike class (58 ODbL + 7 explicit ShareAlike), with
the ODbL "Derivative Database" question marked OPEN and legal rather than
measurable. The "unverified, someone should look" paragraph drops from four
rows to three, because world-adm0's provenance is answered.
★ AND THE STRUCTURAL FIX, WHICH MATTERS MORE THAN THE ROW. The counts are
asserted in two files, so that is the one place these records can silently
disagree. The register now names their PROVENANCE.json as the AUTHORITY and
says plainly that if the two disagree, it wins -- drift becomes resolvable
instead of invisible. Their phrase for the failure is the right one: a register
that still reads "unestablished" after the work is done is settled-by-omission
in reverse, the absence of an update reading as the absence of the work.
The India ADM1 boundary is Creative Commons Attribution (CC BY 2.5 IN,
sourced from DataMeet and the Election Commission of India), so the credit
has to reach whoever receives the data. It did not: 6.1 MB of boundary
GeoJSON shipped with no human-readable attribution, and a sidecar for one
file of eight.
data/boundaries/ATTRIBUTION now ships beside the data. Every licence value in
it is verbatim from the geoBoundaries API, queried per boundary -- CC BY 2.5
IN for India, Public Domain for China. No licence wording is ours.
★ IT TOOK TWO ALLOWLIST EDITS, and the first one silently did nothing. The
boundary staging is TWO-STAGE: a pair list gathers into the build pool, and a
separate path list decides what reaches the tree. I added the file to the pair
list, rebuilt, and it still did not ship. An entry in one stage of a two-stage
pipeline is not an entry.
WORLD-ADM0 IS CORRECTED, not closed. This register first guessed Natural
Earth, public domain. It is geoBoundaries gbOpen assembled per country: its
features carry geoBoundaries' own iso3/shapeGroup/shapeName, and the plugin's
data/adm0/PROVENANCE.json names the source and endpoint. That file also states
the real problem -- gbOpen passes each country's UPSTREAM licence through, so
one file carries up to 162 licences and up to 162 attribution duties -- and
its licences table is EMPTY. Which of the 162 are CC BY is unestablished.
Stated in ATTRIBUTION as an undischarged duty rather than papered over.
pplr's row is exp-shepherd's evidence: the PPLR ArcGIS org aggregating IMB,
Joshua Project, ROP and WCD identifiers, publishing no licence at all, and
WCD is a proprietary Brill database.
repo's zero-config .env.example is on main (8c529c031), verified before writing about it: every DX_* and
VITE_* line is present and empty, empty meaning the local default. That is the follow-up patch I held back
rather than documenting a file whose contents had not landed.
"Every line in it is optional" is a better opening than the list I had, because it IS the zero-config
promise in five words — a reader who copies the file and fills in nothing has a working setup, which is
exactly what the Driver asked the default to be.
Also states what happens if the Mapbox token is left empty: the Mapbox-backed maps do not draw, and the
keyless MapLibre provider is the alternative. My previous wording implied the token was required to get
anywhere, which is not true and is discouraging at exactly the wrong moment.
Both are MIT, which asks for the permission notice to be included with the
software. Tabulator's minified .js carried only "(c) Oliver Folkerd 2025" and
its .css carried nothing; Vue's banner names @license MIT but omits the
permission text. Each now has its upstream LICENSE vendored verbatim beside
it -- fetched, not authored, with the URL and sha256 recorded.
★ AND THE ALLOWLIST IS WHY IT WOULD NOT HAVE WORKED. generate-tree stages
vendor/ by an explicit PATH LIST, so the licences sat in data/vendor and never
reached the tree: staged count 0. Vendoring a notice the staging step drops is
not compliance, it is paperwork. Both LICENSE paths are now listed.
documentation/THIRD-PARTY.md is the register the sweep produced: every
non-original thing dx-maps ships, its source, its licence, whether the licence
or attribution actually travels, and the gap. Compiled by inspecting the
SHIPPED tree rather than the source lists, because the question is what a
recipient receives.
Four rows are left OPEN and marked as such. Two are unverified and need a look
(world-adm0's provenance, the pplr upstream). Two need a decision, not a fetch:
people-groups and the 304 derived tables carry data whose redistribution terms
are not established, 36 MB of it. Attaching a licence I have not confirmed
would be worse than the gap, so those go to the Driver. geoBoundaries is
closable by work rather than judgment -- CC BY needs attribution, and the
.meta.json sidecar pattern already exists here for one file of eight.
Two of the five shipped without one. bebas-neue/LICENSE was 24 lines and
poppins/LICENSE 29 -- our own notes files named LICENSE, pointing at
openfontlicense.org instead of carrying the text. The OFL requires the licence
to travel WITH the font, and these fonts go out in the repo we intend to give
away.
Both now carry their FAMILY'S OWN upstream OFL, fetched verbatim from
google/fonts, which is what brings the real copyright line with it -- Dharma
Type for Bebas Neue, the Poppins Project Authors for Poppins. No licence text
was authored here. Source URL and sha256 are recorded. figtree keeps its OFL
and loses only the six lines of ours that sat above it.
Our prose moved to documentation/vendored-fonts.md, OUTSIDE the served tree,
and that placement is the whole lesson. My first attempt put a NOTES.md beside
each LICENSE, which check:public-hygiene rejected as "documentation in a served
folder" -- three new hard findings, plus a fourth for an internal folder name I
had relocated with the text. The old bebas-neue LICENSE had said exactly this
in its own words, "prose does not belong in a public served folder"; I quoted
that line and then did it anyway. The licence travels with the font; the
explanation travels with the repo.
Whole shipped tree, measured before and after rather than sampled:
role nouns 6 -> 0, hygiene warnings 18 -> 14, findings 1 -> 1 (the pre-existing
assets/ contract one, not mine).
Driver via dx-shepherd (ENDTOKEN-UPG-DRV1 item 2). Written entirely from repo's exact text
(END-REPO-CLOUDNAMES) rather than from the architecture doc, because the last page needed two rounds of
corrections and every one was something I would have guessed wrong.
It opens by sending most readers away: "Most people do not need this page." The default is a fresh clone
and `bun run dev`, and a doc that implies otherwise costs a newcomer their first afternoon.
TWO OPTIONS, with option 1 recommended as the Driver asked:
1. Keep working locally and follow the remote — one line, DX_SYNC_REMOTE. You keep dictation, your own
database, and a broken push only breaks your own screen.
2. Run the shared container — everyone opens one URL, and the trade-offs are listed honestly rather than
buried: no dictation (the engine falls ~4 s behind speech on 2 CPUs, so it ships off), nobody can edit
on it, one broken push breaks it for everyone, it needs ~2.5 GB and 2 vCPUs, and its database is the
shared team one.
★ THE CONDITION I GAVE ITS OWN CALLOUT is the one that bites silently: the sync only fast-forwards when you
are on the branch with no unsaved edits AND no local commits. Otherwise it stops pulling and says "main is
N commits ahead". That is protective, and the consequence is not obvious — leave local commits sitting
while an agent lands twenty changes and the eventual pull carries a day of divergence. So the page says
"push your own work, or you will drift" in as many words.
EVERY CLAIM VERIFIED IN THE SOURCE, not taken from the message: deployment/cloud/ exists with its
Dockerfile and README, that README really does carry the OIDC_* settings (12 mentions), entry.sh defaults
PULL_INTERVAL to 15 and RUDDER_ENGINE to 0 — which is exactly the "it falls behind so we switched it off"
repo described.
No internal names: the only hit was `dx-maps-dev`, which is the Docker IMAGE TAG in repo's own command, not
a hostname. Same substring trap as 'aggregates' matching 'rega'.
Cross-linked both ways with HOWTO-join-the-table.md.
Driver 01:10Z via dx-shepherd: the DEFAULT is everything LOCAL, zero config. A fresh clone plus
`bun run dev` gives the Studio, dictation, hot reload and a local database, and never reaches a remote
database or Docker. The team database is OPT-IN.
My 674d5655 said the opposite — that most teams point every laptop at the shared one and the local one is
"the fallback". That wording came from repo (END-REPO-PBDOC) and was correct when they gave it; the
decision moved the same day. Inverted here: local is the default and the opt-in line is what needs
explaining, not the other way round.
⚠ AND THE TABLE ABOVE IT WOULD HAVE KEPT SAYING THE OLD THING. Its middle row read "the team — this is
usually the default", six lines from a section now saying the opposite. Fixed in the same pass: the laptop
row is marked as the default, the shared row as opt-in. Third time in this file that a line has survived an
edit around it and quietly stopped agreeing with the prose — the only way to catch it is to re-read what
you did NOT change.
Also states the zero-config claim explicitly where a newcomer meets it first ("That is the whole setup.
Nothing to configure, no server to reach, no Docker"), because "everything is local" and "you have nothing
to set up" are different promises and the Driver is making the second.
Cross-links CLOUD-DEVELOPMENT-ENVIRONMENT.md for people whose agent runs on a server, with "most people do
not need it" — that doc is next and its exact env names are with repo.
repo END-REPO-PBDOC: the Driver's decision went live and REVERSED their earlier instruction to me. When I
first wrote this section repo said to leave DX_PB_URL out entirely, because it sat behind the team sign-in
and failed from a laptop. It is now the TEAM DEFAULT, and the local database is the fallback.
So the section is rewritten rather than appended to: a joiner is now told to set
DX_PB_URL=https://<your team's dev server>/pb, that they sign in with the same team account and there is no
separate password, and that leaving it unset still gives them a private local one — which is a fine way to
start. Placeholder host in the public text, not ours.
⚠ THE OPENING LINE WAS QUIETLY WRONG AFTER THE CHANGE and I nearly left it. "There are three databases and
you only ever touch one" was true when the local one was the only option a joiner had; now they touch the
shared one OR a local one depending on one env line. The claim that actually matters — and is still exactly
true — is that they never touch the third. Now reads "You work against one of the first two — never the
third." A sentence can survive an edit around it and stop being true without looking any different.
Added the detail repo gave that a reader needs and would not guess: new collections start LOCKED until a
migration opens them, deliberately, so nothing is public by accident. That reinforces the schema-as-commits
point the section already makes rather than sitting beside it.
Names unchanged and re-verified: DX_PB_URL points at the PocketBase root (.../pb), DX_PB_PORT for the
port-in-use case. No internal hostnames anywhere in the file.
repo af37aa52 changed two things I had written emphatically, and studio confirmed both independently
(END-REPO-NAMES2 / END-STU-DOCFIX).
- DX_SYNC_REMOTE now takes a remote NAME **or** a full repo URL. My callout said "the remote's NAME, not a
URL" — a sentence whose whole point is now backwards, so it is rewritten rather than softened. A URL saves
a `git remote add`, and a private repo uses the laptop's existing git credentials.
- DX_SYNC_INTERVAL default is 5 seconds, minimum 2. It was 15/5.
- Added the reassurance studio named: a token in an `https://user:token@…` URL is never written to the log
or shown at /api/dev-sync. That is exactly the question someone pasting a private URL will have, and the
answer is good — worth stating rather than leaving them to wonder.
⚠ AND I DID **NOT** TOUCH LINES 38 AND 167, which also say "about every 15 seconds". studio flagged that
those describe the SHARED SERVER's pull loop — a different thing from DX_SYNC_INTERVAL that happens to have
carried the same number. Changing them because the number matched would have introduced a fresh error while
fixing a real one; checked both after the edit and they are intact.
Every other name still matches repo's branch. repo has also warned that a shared team database may later
become the default, which would make the PocketBase section go stale; they will tell me before it does.
repo answered with the names as they will ship (END-REPO-ENVNAMES) and dx-shepherd caught a path I had
wrongly removed. Both corrected; the doc now states nothing I have not verified.
⚠ MY "THE PATH DOES NOT EXIST" CHECK WAS WRONG, AND WRONG IN AN INSTRUCTIVE WAY. I searched the LAST
COMMIT's file list for pb_migrations and found nothing, then reported the path as unbuilt. `git ls-tree -r
origin/main` shows backend/pocketbase/pb_migrations/.gitkeep — it has been there all along. A file list from
one commit answers "was this touched recently", never "does this exist"; I asked the cheap question and read
it as the expensive one. The real path is restored.
THREE CORRECTIONS FROM repo, each of which would have cost a reader time:
- DX_SYNC_REMOTE is a remote NAME (`origin`), not a URL. My draft said "<the repo URL>", which would have
failed silently for every joiner who copied it.
- DX_PB_URL IS OUT ENTIRELY. repo was explicit: it points at the shared database, which sits behind the team
sign-in and does not work from a laptop. Telling a newcomer to set it would have sent them to debug an
auth wall on their first afternoon. What replaced it is the truth — `bun run dev` downloads and starts a
local database itself, and there is nothing to configure.
- VITE_MAPBOX_TOKEN is the one credential they must ask for, and my draft never mentioned it. A joiner
following the old page would have got a working repo and a blank map.
ALSO ADDED, because it is the behaviour that makes the feature safe to recommend: the auto-pull only runs
when you are on that branch, with no unsaved edits and no local commits. Otherwise it does not touch your
files, it says "main is N commits ahead … pull when ready" in the log. That is the difference between a
feature a newcomer trusts and one they turn off after it eats an afternoon's work.
Required set is now stated plainly: DX_SYNC_REMOTE=origin if you want teammates' work to arrive, plus the
Mapbox token. Everything else optional, with its default in a table.
No internal names, re-checked after the rewrite: the only product name left is PocketBase, which a joiner
sees in its own admin UI.
dx-shepherd ENDTOKEN-UPG-DOC, from the Driver's laptop-first decision. Written for someone staring at a
repo, a dev server and a database, unsure which one they are allowed to touch — so it answers that first
and explains second.
THE SHAPE, in the Driver's own three places: laptop = your workshop · main = the truth · the shared server
= the table, look but do not edit. Plus the rule the whole thing rests on, one plugin folder per person.
★ THE 95% NUMBER IS THE POINT OF THE PUSH SECTION, not decoration. A newcomer whose first push is rejected
assumes they broke something. The measured result — 95% of pushes rejected, 0 conflicts, 1.1 retries on
average — tells them the rejection IS the normal case and explains why it is cheap: you collide on who goes
first, never on the same lines, because you are never in the same file. That reframes a scary error into one
extra command, and it is the strongest argument for the one-folder rule.
⚠ TWO THINGS I CORRECTED RATHER THAN SHIPPED:
- I first cited `backend/pocketbase/pb_migrations` as the migrations path. It does NOT exist in this repo —
it is from the architecture doc's PLAN. A path a reader cannot find is worse than no path, because they
assume they cloned wrong. Now says "committed alongside the code" until it is real.
- "reloads every few seconds" is now "about every 15 seconds", which is what the source actually states.
NO INTERNAL NAMES, checked rather than intended: zero hits for the server hostnames, the source-control
product, the agent tabs, the dictation tool, the auth stack, "tenant" or the project's own code names.
Cross-linked to develop-a-plugin-and-see-it-live.md, which answers the different question of how to BUILD a
plugin — this page is about working alongside other people once you have one.
⚠ THE ENV SECTION IS PROVISIONAL. DX_SYNC_REMOTE / DX_SYNC_BRANCH / DX_PB_URL are taken from the
architecture doc; repo is building them now and I have asked for the exact names, which are required vs
optional, and what each points at. A doc with a wrong env name is worse than no doc — the reader blames
themselves — so this gets corrected the moment repo answers, before anyone links it.
generate-tree derives each profile's tier from its composes edges (composing
anything is capability 4; composing a capability-4 plugin is capability 5). An
explicit type is checked against the derivation and a contradiction fails the
build naming both. check:capability-derived asserts every profile is 1-5 and
agrees. documentation/PRESET-PLUGIN-STANDARD.md describes the one
*.instances.json file a preset plugin declares.
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.
cap5-plugin-studio (Rudder's button now reads its label), prod-gospel-resources and its phone
shot. Derived from check:shots on this tree, tokenless; check:shots passes.
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.
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.
generate-library-index took the first line of every file as its description, which for a .json file is data:
the Data section read '"_meta": {', '"value": "AFG",' and a run of ISO codes (flagged by simpleresearch).
A .json file now uses _description, then _meta.description, then _meta.purpose, and otherwise says
'(no description)'. iso3-to-alpha2 now reads as what it is.
The three goal cards, the stat row and their bars were the regions
dashboard's own markup. They are library components now, and the plugin
renders them instead of holding a copy:
GoalCard title + figure + a goal line pinned to the card's bottom,
with a slot for whatever sits between
StatCard one figure and its caption
ProgressBar a single-value track, or a segmented one with a legend
useCardSurface the card surface both cards share, injected once per
root under a stable label instead of copied into each
The bar and its legend now read from ONE list of segments, so a width and
the number printed beside it come from the same figure and cannot drift
apart; before, each was computed separately. A segment's colour is a prop
carrying the same token its .rd-seg--full/--partial/--none class used to
set, so a shared bar needs no vocabulary about prayer and those three
rules are deleted rather than left unreachable.
ProgressBar carries its own reduced-motion guard. The plugin's reset is
scoped to .rd-root and would not have followed the component anywhere
else, which would have reintroduced the one-second sliding bar their own
site suppresses.
The rendered DOM is unchanged for all four instances, measured by
server-rendering the real components and comparing against the markup
that was deleted, including that the bar widths equal the percentOfSubject()
they replace.
check:regions-uses-library grows to cover all four, asserting both halves
again: the plugin imports and renders them, and no longer ships their
markup or CSS. The .rd-root strong rule STAYS in the plugin -- UupgsList
renders a <strong> in the same shadow root and depends on it, which a
file-scoped read would have called dead.
Our data speaks ISO alpha-3. Intl.DisplayNames('region') takes alpha-2 or
UN M49 and NOT alpha-3, so every country name was stuck in English for want
of the join.
Vendored CLDR cldr-core 48.2.2 (Unicode-3.0, 36KB, sha256 and source URL
recorded in library/vendor/cldr/README.md). No npm dependency: a package
would pull the whole supplemental tree and make a shipped data file depend
on an install step, where a vendored copy lets the generator reproduce the
map offline -- which is the point of vendoring rather than merely having a
copy.
generate-iso3-to-alpha2.mjs joins it against the 242 codes in
countries.json and GRADES ITS OWN OUTPUT, writing nothing on failure: 242
coverage, injectivity, two-letter well-formedness, and that each documented
exception is still needed. KOS -> XK is the only exception -- Kosovo has no
ISO 3166-1 assignment, so no refresh will ever supply it, and XK is the
user-assigned code CLDR itself uses in locale ids. It is recorded with its
reason instead of patched into the vendored bytes.
countryName(iso3, locale) falls back localized name -> canonical English
name -> the code. Intl.DisplayNames THROWS on a missing or malformed code
rather than returning null, so the guard is checked before asking; the
probe demonstrates that throw rather than asserting it in a comment. An
unassigned-but-well-formed region returns the input unchanged, so that case
falls back instead of rendering "DE" as a name.
THE RESEARCH SPINE MAP IS NOT TOUCHED. iso3-to-iso2.json is a different
file with a different job -- the 176-country spine that drives the data
fetches -- and expanding it is a Driver decision, not a side effect of
adding country names. The generator cross-checks it and REPORTS
disagreements without rewriting. There are none: CLDR independently agrees
with all 176 values, including the two corrected this session.
Also fixes a duplicate "check:religion-label-not-value" key in
package.json, left by a merge. JSON keeps the last, so one description was
silently dead and check:doc-scripts could not see it; bun's parser warned.
The surviving entry absorbs the only fact the dead one carried, the two
library sites by name.
The rail's rendering is unchanged. What changed is where its last two plugin imports point. 4b had
already removed the research COLOUR and LABEL tables -- those stay in plugin-designs and arrive on
dashboardConfig.legend -- which left exactly two, and both now read the module that OWNS the
function rather than a re-export:
useDashboardAggregations -> @map/composables/useFacetAggregation.js (extracted by 4c-1)
useDashboardShell -> @map/composables/useDashboardSearch.js (extracted by 4a)
Pointing at the shim instead would have been invisible and wrong in a way this tree has already
measured: check:filter-multivalue carries the note from landing 3 that a gate left at the plugin path
read the shim, found no predicate to object to, and PASSED VACUOUSLY.
The dead injects go too. `dataStore` and `searchSourceId` were here only to construct
useTreeLegendColors and useTreeLegendLabels, and 4b removed both constructions. An unused inject is a
claim about the host: a kit component that appears to need a research dataStore is one a new
dashboard will dutifully provide before finding out it was never read. The chip bar shed the same
pair in 4c-2.
check:filter-multivalue and check:legend-mirrors follow the file rather than being left pointing at a
path that no longer exists -- the multivalue gate fails loudly on a missing panel, which is what it
is for, and it is how I would have caught forgetting.
UNLIKE THE CHIP BAR, THIS ONE IS ON SCREEN. 4c-2's component is mounted by nobody, so its gate was
the whole story. The rail is mounted by research-dashboard.vue and is 770 lines of tree, search and
drag behaviour: "the imports resolve" says nothing about whether it still opens four trees by
default or whether its search still narrows. It needs eyes.
The barrel re-exports from the library for one cycle, so no importer's line changes in the same
landing that moves the file. documentation/reference-library-index.md regenerated.
DashboardFilterChips.vue moves to library/components/dashboards/. Nothing about the rendering
changed. What made the move possible was 4b, which inverted the one dependency that blocked it: this
file imported useTreeLegendLabels — the People CODE -> NAME tables, research-specific and staying in
plugin-designs — and a library component importing a plugin composable is what
check:no-cross-plugin-imports refuses. The resolver now arrives on dashboardConfig.legend.
THREE THINGS BEYOND THE MOVE, all found by assessing the file rather than by moving it:
1. THE UNCONFIGURED DEFAULT NOW JOINS A LIST. 4b's fallback returned the value untouched, so a
multi-value filter (spec D1) printed String(['A012','A013']) = "A012,A013", comma-jammed, while a
dashboard that DID supply a resolver got "South Asian Peoples, Bengali Peoples". Configured and
unconfigured should differ in whether CODES RESOLVE, never in punctuation — the second difference
reads as a rendering bug rather than as missing configuration, and go-int is the consumer who will
see the unconfigured state first.
2. THE DEAD INJECTS ARE GONE. `dataStore` and `searchSourceId` existed only to construct
useTreeLegendLabels and have been unused since 4b. Removed rather than left in place: an unused
inject is a claim about the host, and a kit component that appears to need a research dataStore is
one a new dashboard will dutifully provide before discovering it is never read.
3. AN EXPLICIT EXCEPTION TO library/components/WARNING.md, recorded in the file's own header. That
file says a component graduates into the library only once MORE THAN ONE map uses it; this one is
used by ZERO, because the topbar dedup pass unmounted it when SearchFilterHeader became the single
source of truth for showing and clearing filters. It is here because the kit's promise runs the
other way round — a NEW dashboard mounts kit components, so waiting for a second consumer before
providing the first one is a deadlock. The honest consequence is written down too: a passing gate
means "it mounts and degrades", not "it looks right", and nobody has SEEN this component since the
dedup. Whoever wires it in is its first real reader, and must not mount it beside a
SearchFilterHeader that is already showing chips.
check:legend-mirrors grows arm 5, and it asserts the two things a "move" gets wrong that reading the
new file cannot show: that the OLD copy is actually gone (a move that leaves the original behind is a
copy, and two chip bars over one filterSet drift the moment one is edited — this tree has already
paid that with useNeighborT/usePplrT at Jaccard 0.82), and that the UNCONFIGURED behaviour is right,
which no dashboard here would reveal because they all supply a resolver. The default is LIFTED out of
the source and RUN, the way arm 3 drives the rail's fallbacks — not mounted, because a no-op renderer
cannot show a chip and the rendered result is rega's. Five mutants, all caught: old copy left behind,
the join reverted to 4b's bare value, a dead inject re-added, the supplied resolver ignored, and a
relative import restored.
The barrel re-exports from the library for one cycle, so no consumer's import line changes in the
same landing that moves the file. documentation/reference-library-index.md regenerated, per landing.
PARENTED ON a285e06c (4b), not on main, because 4b edits this same file — landing them the other way
round would revert 4b's inversion. Source only: research-dashboard and dashboard-plus-grid need a
rebuild.
useDashboardAggregations has a split personality, and only half of it could ever
move. `lbl` and `aggregate` are pure counting — eleven lines, a Map, a sort, a
topN rollup, no facet name and no colour. Everything else in that file is
research: `colorFor` with its hardcoded religion / wagf_region / affinity_bloc
branches, the religion palettes, the donut and bar builders. Those stay, and
DashboardChartsRail keeps importing them.
The generic pair was what blocked kit landing 4c: the facet rail needs only that
half, and while it lived beside the palettes the rail could not follow it into the
library. Same split, same one-cycle re-export as 4a. Bodies verbatim.
★ AND THE ROLLUP BUCKET SAID "Other" IN EVERY LOCALE. aggregate() pushes
`{ key: 'Other', other: true }` for the tail, and that English word was rendered
directly by the facet rail and the charts rail. check:i18n-keys-exist could not
see it, because there was no T() call to inspect — the string was never a lookup,
it was a KEY being used as a LABEL. I found it while ASSESSING this landing rather
than while doing it, and it is fixed here rather than later for one reason: the
moment this module becomes kit code, an unfixed bug in it is exported to every
dashboard built from the kit.
THE TWO HALVES PULL IN OPPOSITE DIRECTIONS, which is the whole difficulty. The KEY
must stay English: the rail tests it to make the row inert, and the charts rail
refuses to filter on it, so a localized key would make the bucket CLICKABLE in
Arabic and filter on a value no row carries — an empty board, reading as a data
problem. The LABEL must not stay English. So OTHER_KEY is the sentinel,
otherLabel() reads dashboard.other, and they live next to the function that
produces the bucket so they cannot drift. Eleven locales, catalogue first.
This is the value/label trap for the third time — religion families, the language
tiers, and now a bucket in generic code. The third is the worst place for it,
which is why the rule now travels WITH the code that creates the value rather than
being remembered at each render site.
check:legend-mirrors grows arm 4, asserting both directions: translate the
sentinel and it fails; render the key instead of the label and it fails. Both
mutants run.
check-one-palette now scans the new module as well. Its subject — private palette
declarations — stayed in the plugin half, so nothing moved; adding the library
path means a palette smuggled into kit code later is caught by the same rule
instead of escaping into the library.
Library index regenerated in-commit. Source only. STUDIO: needs a rebuild of
research-dashboard and dashboard-plus-grid. The internal/ hunk is four lines in
check-one-palette.mjs.
The Driver's ask, and his negotiating point with the DX-life author depends on it
being true: the components cloned from their /regions page must be REUSABLE,
PARAMETERIZED components in the shared library, not private to one plugin. He named
the failure mode himself — someone opens the regions plugin folder and finds the
components are not reusable after all. I checked before building, and for the UI he
named he was right: only the DATA and LOGIC had been promoted.
So library/components/regions/RegionSectionCard.vue takes title, summary, countries
and footerLabel, emits what was clicked, and knows nothing about where regions come
from. The plugin version fetched its own data and owned the layout, which is exactly
what made it unusable anywhere else.
THE DESIGN TRAVELS WITH IT. The card carries the region-card, countries-grid and
country-link CSS lifted unchanged — including the RTL chevron rules, where a
"forward" chevron must mirror in Arabic while a disclosure caret must not, and where
[dir="rtl"] .region-card__icon at 0-2-0 would otherwise beat the 0-1-0 --open
modifier. A consumer gets the look, not a lookalike. It needs dir on an ancestor for
that mirroring, which is documented in the file as a requirement.
★ THE CARD OWNS THE DISCLOSURE ROW, after the first cut got it wrong. I put the
"View country posters" toggle in the consumer's slot — and immediately had to copy
the footer-link styling and the rotating caret back into the plugin, re-creating the
duplication this extraction exists to remove. A disclosure inside a card is a card
feature; only the revealed CONTENT is the consumer's, so PosterWall stays in the
slot with its fourteen props and does not become every consumer's API.
★ THE CHEVRON IS INLINE, NOT A <use href="#id">. The plugin declared the symbol once
and every card referenced it. A shadow-root id is scoped to that root, so the same
card mounted elsewhere would reference a symbol that does not exist — and an
unresolved <use> renders NOTHING, silently. The plugin's own comment warns about
that class for external hrefs; a cross-root id fails the same way.
check:regions-uses-library is registered and asserts BOTH halves: the plugin imports
from @map/ and renders the component, AND no longer holds the markup or the CSS it
delegated. A reusability claim only a human can audit is one that rots; this one
fails the day someone re-inlines a copy. Two negative controls keep it honest — the
plugin still uses its own .rl-root classes, and the other tab is asserted NOT to use
the card yet, so the check describes what is extracted rather than what I hope for.
Two of my own mechanical edits produced valid JS and broken content on the way here,
both caught by checking rather than by the parser: a line-numbered splice that
dropped PosterWall's closing /> (fixed by locating boundaries by CONTENT), and a
line-filtered CSS deletion that left 9 orphaned declarations and braces at 12/21
(fixed by removing rules brace-aware, then re-validating the concatenated stylesheet
rather than trusting check:sfc-parse, which only proves the JS parses).
?codes= filters by LANGUAGE, which is what a partner's list usually is and the wrong
filter for a list of peoples. The reach table measured the gap in its own rows: its
Mandarin row counts 36 groups, those 36 speak 30 codes, and 55 Doxa groups speak those
30 — so a link from a 36-group row drew 55 pins. Akha's 33 drew 57; SUN's 93 drew 116.
The extra groups are real and also have nothing; they belong to a different row.
So the map takes ?groups=<slug list> and filters by people group. The slug is
doxa.life's own — carried from the roster into the pin rows, and read in the builder
off the prayer URL their API built from it, never constructed from a name, because a
slug this build invented could name somebody else's people. All 369 of those rows
carry a prayer URL, measured before relying on it.
Row and map now agree by construction: 0 of 35 rows disagree, and the Mandarin link
renders "36 / 2,107" on the map against a row claiming 36. The gate asserts exact
equality for a three-slug link and 0 for an unknown slug — the failure that would
quietly turn a partner's 40-group embed into the whole roster.
The longest link is 1,577 characters, SUN's 93 slugs; well inside what browsers carry,
but worth knowing before anyone adds a row with a thousand groups.
★ LANDING 4 AS I SCOPED IT IS NOT POSSIBLE, AND THE SPEC WAS WRONG ABOUT THAT.
go-int asked for the facet rail next and I agreed. Then the first step of a
landing — look before moving — found that DashboardFacetRail imports FOUR
plugin-local modules: useDashboardAggregations, useDashboardShell,
useTreeLegendColors and useTreeLegendLabels. Moving the rail into library/ would
have made a library component import plugin composables, which
check:no-cross-plugin-imports rightly refuses. And two of those four are
research-specific by my own spec's reckoning — useTreeLegendColors carries the
DOXA colour strategies and is supposed to STAY in the plugin — so the rail cannot
become library code by moving alone. Its colour and label dependencies have to be
inverted into injection first, which section 8 named as a risk and then ordered as
if it were free.
So landing 4 is three commits, not one, and this is the first: the piece the rail
stands on.
THE SEARCH WAS NEVER A SHELL CONCERN. buildSearchAccessors, makeSearchMatcher and
filterRowsBySearch were exported from useDashboardShell, and EIGHT files reached
into the shell to get them — the table, the map panel, the charts rail, the facet
rail, the research filters, the top bar, the profile and the config. A module that
eight things import is a module, not a corner of another one. Nothing in it is
domain-specific: the accessors come from config.facets / config.columns /
config.searchKeys, and the matching comes from the already-shared fuzzy.js.
Bodies are verbatim; this is a move. The shell re-exports the three names for one
cycle, so none of the eight importers changes in this commit — a barrel line
rather than a shim file, so there is nothing to delete later, only a line to
remove when the eight point at the library directly.
The now-dead fuzzy.js import is removed from the shell rather than left. An import
nothing uses is the kind of thing that survives three more landings because it
looks load-bearing.
WHAT THIS DELIBERATELY DOES NOT TOUCH: the shell keeps rowMatchesFilters and the
URL-hash escape helpers, which are what check:filter-multivalue reads out of it by
path. I checked that before slicing, so no gate path moves in this commit.
Library index regenerated in-commit. Source only; no rebuild needed beyond the
usual, since every importer still resolves through the shell.
Both are zero-domain in the sense the spec meant: they import only `vue` and read
rows through accessors. Bodies are what research-dashboard shipped apart from the
two parameters the spec named and one policy object, and the old paths re-export
for one cycle.
★ THE FIRST STEP FOUND TWO GATES THAT READ THESE FILES BY PATH, which is why it is
now the first step. Landing 1 nearly disarmed check-facet-cascade the same way.
internal/check-csv-export-shape.mjs does not import useCsvExport; it READS the
source and extracts esc() out of it. Left at the plugin path it would have read
the one-line shim, found no esc(), and failed with "could not extract esc()" — a
gate accusing a working exporter because its subject moved.
check-filter-multivalue.mjs — MINE — lists the same file in its PANELS array.
Left at the plugin path it would have read the shim, found no predicate to
object to, and PASSED VACUOUSLY. That is worse: a gate going quiet about the
thing it guards. Both move in this commit.
THE COORDINATE IS A PARAMETER; WHAT MAKES A COORDINATE VALID IS NOT. `coords`
replaces only the READ of row.longitude / row.latitude, which is pray-tools'
spelling and not a universal one. Every guard stays shared: Number() on API
strings, 0 accepted per axis, the exact 0,0 null-island sentinel rejected, the
out-of-range check. A kit that let each caller re-implement that validation would
hand the next dashboard the same four bugs to rediscover.
★ AND THE OPTION WAS DEAD WHEN I FIRST WROTE IT. `coords` was accepted in the
signature and never passed to selectionBounds, so a caller could set it,
type-check clean, build clean, and watch it do nothing. This tree has paid for
that shape before — a defineProps on a component the custom element never binds —
and it is worse than a missing feature because it READS as supported. Nobody files
a bug against a knob they assume they misused. check:kit-options-reachable now
asserts, across all four shared composables, that every option accepted is used.
THE CAMERA'S EIGHT CONSTANTS ARE ONE DECISION, so CAMERA_POLICY exports them
together. The failure that prevents is a second dashboard re-picking them one at a
time and arriving at a camera that feels different from Big Picture's for no reason
anybody wrote down. It is read-only and deliberately not yet overridable: making
each a per-caller option means threading eight of them through every use, and no
consumer has asked for a different camera. Exporting the decision so it can be
cited is the half with a use today; saying so is better than shipping eight
options nobody passes.
THE EXPORTER GAINS exportLabel and sources. "Research Dashboard" was this
dashboard's name, and the provenance line said nothing about where the data came
from — which is what a provenance line is for. Both default to today's output, to
the byte.
★ AND THE NEW GATE HAD THE COMMENT HOLE TWICE IN ONE SITTING. First it searched
the raw body, and the comment beside the fixed call site says "`coords` IS PASSED
HERE" — so the mutant that removed the argument left it green. Then, stripping
comments only per-part, it split the signature's comment PROSE on commas and
reported that useCsvExport accepts `and`. Comments are text; every text search has
to say so, in both directions. That is the fourth variant of one mistake this
week, and all four were caught by running the mutant rather than by reading.
Library index regenerated in the same commit, 103 items. Source only.
STUDIO: needs a rebuild of research-dashboard and dashboard-plus-grid. The
internal/ hunk is one line in check-csv-export-shape.mjs.
Dispatch work item (5) asked for useTextHighlight in library/. RULE 2 wants a named
SECOND consumer before anything moves there, and it exists: regions-dashboard's
UupgsList and people-groups-grid both consume the same Fuse-compatible shim, both
read match.indices, both wrap hits in <span class="search-highlight">, and both
append the tail only on the last range. Line for line the same construction, apart
from variable names.
So library/composables/useTextHighlight.js exports highlightRanges(value, indices),
and UupgsList calls it. Its local escapeHtml goes too: every caller was inside the
highlight body that moved.
★ ESCAPING NOW COMES FROM library/utils/html.js, AND THAT IS A CHANGE FOR BOTH
CALLERS, stated rather than smuggled. Three implementations existed and differed:
library/utils/html.js escapes & < > " ' AND backtick; returns '' for null
UupgsList no backtick; guarded null via String(v ?? '')
people-groups-grid no backtick; NO NULL GUARD — s.replace(null) THROWS
Both deltas move in the safe direction — one more character escaped, one crash
removed — and neither changes the output of any value that renders today. The gate
pins both, with controls proving each delta is a real difference rather than a
no-op: the old copy really does leave a backtick unescaped, and their escaper
really does throw on null.
people-groups-grid is NOT migrated here: it is another owner's file, so it is
handed over as source. The gate asserts it still builds its own highlighter, so
this check states the truth rather than implying the job is finished.
check:text-highlight is registered (script line, gates:post token, hand-inserted)
and negative-tested: an implementation that escaped nothing fails it, and the
output is asserted to depend on the indices.
gospel-plugin's decision, in their words: "most partners will drop a custom
element or an iframe and never install vue-i18n ... an Arabic ministry site
embedding this map serves its readers English while the bundle already carries all
11 catalogues. That is a bug, not a decision."
So all four gospel translators drop `{ fallback: enLookup }` and take the
helper's catalogue default, which resolves through tStatic: the reader's locale
first, English as the last resort. Their three conditions are met and each is
asserted, not assumed:
1. resources-map passes NO namespace — its keys span gospel.* and pins.*;
2. the fallback walks the catalogue ROOT, which tStatic does by construction;
3. params survive, via the null-safe fillParams.
★ THE ROW THEY ASKED FOR, and it is the only thing here that could catch the
regression this migration risked: a BARE host under ar must render
pins.approximate in ARABIC — not the key, not gospel.pins.approximate, not the
English. It reads the SHIPPED ar/ and en/ pins.json and rebuilds tStatic's own
resolution order over them, so it measures the real catalogue rather than a stub.
Three negatives beside it, plus a control that both catalogues really carry the
key, so the comparison is measuring a difference that exists.
enLookup is deleted from all four, and the EN_MESSAGES import from three —
data-tables keeps it, because it still builds a map from EN_MESSAGES.gospel.res at
:182. Checked before deleting rather than after: check:no-free-identifiers would
have caught a wrong removal, since removing something still used creates a name
that is valid JS until it runs.
Two corrections that came from other agents reading this work:
- studio: the JSDoc carried <area>.<key>-shaped examples, which
check:i18n-keys-exist reads as real keys and reports missing. They reworded it
twice because my file copy restored it. Reworded, with a standing note in the
file so a third copy cannot bring them back.
- the register said each site "takes the CATALOGUE fallback, as it always did".
True of the two composables, FALSE of gospel's four, which were moved here by
their owner. A false statement inside a gate message is worse than no message,
because a reader has no reason to doubt it. Now accurate per row.
check:i18n-keys-exist is red on messages.engineNotLoadedDetail. NOT mine and not
touched here: it is in cities-internet/connectivity-map.vue, whose own comment says
the key "arrives with upg100's ade12e4c", and the call already guards for its
absence.
C4, first wave. Twenty places each stored the same fact — how to translate when
the host never installed vue-i18n — and two of them, usePplrT.js and
useNeighborT.js, were whole-file near-duplicates at Jaccard 0.82. That pair was
the curation audit's evidence that the guard was being COPIED rather than shared.
Both now call library/composables/useOptionalT.js and keep their public names, so
no call site changes.
★ THE SPEC ASKED FOR (namespace, fallbackCatalogue) AND THE CATALOGUE IS
UNNECESSARY. Every copy imported its own English JSON and hand-walked it after
stripping the namespace prefix. But library/i18n builds `messages[locale][area]`
keyed by the JSON's own filename, and tStatic() already walks that tree and
already falls back to English. So `import enPplr from '.../en/pplr.json'` was a
second copy of bytes the bundle already had, and the helper takes one argument.
★ AND IT IS NOT A SIZE REGRESSION, which was the one good reason the narrow
import might have existed. The build NARROWS library/i18n's catalogue glob per
bundle to the areas internal/i18n-areas.mjs finds in that bundle's source graph
(check:i18n-treeshake). A bundle that says 'pplr.' gets the pplr area, not the
other thirty. Verified before writing the helper, because "the obvious dedup
makes every bundle carry every locale" would have killed it.
Resolution order, each step earning its place: the host's own vue-i18n when it
installed one, so a host managing locale keeps control; then tStatic, which
honours the runtime locale and falls back to English; then the key itself, so a
missing string is visible rather than blank. The middle step also covers i18n
being present but empty — i18n.t() returns the key unchanged, which every copy
of this pattern already treated as "not found".
Verified by _qa-cycle/02-builder-cycle/_chk-c4-optional-t-equivalence.mjs: 18
assertions across all three host states, with a branch-for-branch equivalence
check against the old hand-written implementation and two negative controls. It
extracts and executes the module source, because useOptionalT imports vue-i18n
and an i18n index that calls import.meta.glob, which plain Node cannot run.
useDashboardLayout is the second zero-domain piece: it imports nothing but `vue`
and reads no row. The four mentions of a people-groups grid in it are comments
naming an example of what a host stacks underneath. Body verbatim apart from the
one change the spec required before it could be shared, and the same one-cycle
re-export at the old path.
THE KEY WAS THE REASON THIS COULD NOT JUST BE MOVED. The engine persists the four
divider positions to localStorage under a single constant, so every dashboard
wrote to the same slot. That was invisible while there was exactly one dashboard
— and SHARING THE ENGINE IS PRECISELY WHAT MAKES A SECOND ONE POSSIBLE. Moving it
without fixing this would have shipped a collision into the kit's first reuse:
drag a divider in one dashboard, reload, and the other has moved. Nothing errors,
neither is wrong on its own, and the reader has no reason to suspect the other
element on the page. SPEC-DASHBOARD-KIT section 8 named it as this landing's risk;
the fix belongs in the commit that shares the engine, not in the one that first
trips over it. The key now carries the profile's own id, so a second dashboard
gets its own slot without its author knowing this key exists.
AND NAMESPACING MUST NOT SILENTLY DISCARD A LAYOUT. Every reader who has dragged
a divider has a value under the old shared key; a new key would simply orphan it
and reset them for no reason they could see. The engine reads the old key once
when the new one is absent. It never writes or deletes it: a migration that also
deletes cannot be rolled back, and leaving the old value costs one localStorage
entry.
check:layout-key-namespaced guards all three. It EVALUATES the storageKey
expression the shell actually passes, with two different dashboard ids, rather
than retyping the rule — a gate carrying its own copy of the thing it checks
passes while the product is broken, which is the lesson from
check:linked-vocabulary. Negative-tested: a bare literal fails A, an expression
that ignores the id fails B, and writing or removing the legacy key fails C.
★ THE BUS_SRC LESSON WAS APPLIED BEFORE MOVING, NOT AFTER. Landing 1 nearly
disarmed check:facet-cascade because that gate reads its subject from a path. I
checked first this time: no gate reads useDashboardLayout by path, and the
storage-key literal appears in exactly two places, both of them in this diff.
The library index is regenerated here, per landing, for the same reason as
landing 1 — 101 items now.
SOURCE ONLY. Stacked on D1. STUDIO: needs a rebuild of research-dashboard and
dashboard-plus-grid. package.json is the parent's copy plus two lines beside
check:leaf-engine-per-provider, so D1's registration is preserved.