The paper turns landscape when the map is wider than it is tall (11in x 8.5in, a 2200x1700 image),
and the gate still asserted portrait only, so it failed a correct print. It now accepts either
orientation of US Letter, at least 1600 px on the short side and 2100 on the long one, and fails an
image whose shape is not its sheet's. The shape test is a negated <=, so a missing image fails.
Twenty-six references in capitals, which the earlier case-sensitive pass
could not see. Every requirement, date and measurement is kept: the 2026-09-23
instruction that removed the engagement and status filters, the MapLibre ask
with its Mapbox-compatibility condition, the blue-water-black-galaxy style,
the red ramp and the bin that collided with the heartbeat zone, the white
rings around the blue dots, the zoom that did not work inside the bounded map.
Two that are not quotes and were handled differently. The reduced-motion gate
opens with a medical reason — a reader of this map is photosensitive, and a
camera that sweeps the globe on every filter is a strobe — which is the whole
justification for the gate, so it stays as a fact about a reader rather than
about a person. And BpResearchHeader's "CAT-1 DRIVER CONTRACT" was a code
term; it is now the CAT-1 HEADER CONTRACT, checked for other references first.
Eleven lines broke and were found by re-reading every changed line, not by the
count. Removing an attribution left seven continuation lines holding the
closing half of a quotation that no longer opened, and four sentences carried
a pronoun for the speaker who had just been removed. The reference count was
correct at every point while the prose was wrong.
grep -wi driver across the three plugins: 0. The widened gate reports 40
swept, 14 still baselined. All six SFCs parse.
One hundred and forty-three comments across the research dashboard, the research
map, Big Picture and the two composites attributed a decision to a person: by
name, by quotation, by date, or as "per Driver". Shipped code should carry the
reason a thing is the way it is, not who said so. A reader of the giveaway repo
has no way to ask that person anything, and the name tells them nothing the
reason does not.
So a quotation becomes the requirement it was quoting, a ruling becomes the
ruling, "the Driver reported" becomes what was reported, and a decision still
waiting on an answer now reads as provisional and pending a product decision
rather than as somebody's pending word. Where a comment already stated the
reason -- most of them did -- only the attribution went.
Two things were removed rather than rewritten. A personal medical fact appeared
twice ("the Driver is photosensitive"), once in a comment and once in a gate's
console output where it printed on every run; the motion-safety requirement
stands on its own and now reads that readers may be photosensitive. Internal card
ids that named nobody stayed, since they point at this repo's own history.
The bulk went in three batched passes of meaning-preserving substitutions, and
that is where the risk was: a blind replacement produces sentences that are
grammatically wrong but still look plausible in a diff. Re-reading every changed
line found thirteen -- "the the report describes", "including the two that were
had just complained about", "because was asked for WHITE rings" -- each repaired
individually. The count of remaining references caught the tail three separate
times, when a file type, an escaped apostrophe, and a scripts/ path each fell
outside a filter I had assumed was total.
Comment bodies and two console strings only. All twenty-one files parse, both
meta.json files are still valid JSON, and no declaration moved.
Only the browser seat runs browsers, and only inside internal/browser-slot.sh.
That rule was remembered, not enforced: an agent ran a gate it took for a
headless source check and it launched Chromium outside the slot.
internal/lib/playwright-guarded.mjs is now the one way a repo file reaches
playwright: its launch(), launchPersistentContext(), launchServer(), connect()
and connectOverCDP() exit 75 (NOT MEASURED) with a message naming
browser-slot.sh unless DOXA_BROWSER_SLOT is set, and browser-slot.sh exports it
for the command it runs, so a bash -c grandchild inherits it. All 72 files that
imported playwright switch to it (internal/lib/browser.mjs and 64 internal gates;
plugin-designs scripts: doxa-research-map 3, my-upg-100-list 2,
research-map-plus-grid 2, research-dashboard 1): a mechanical import swap.
check:browser-gates-guarded (gates:pre) fails a direct import;
bun run browser-gates lists the 97 scripts that launch a browser. isBrowserGate
moves to internal/lib/browser-gates.mjs so the runner and the check share it.
The Driver, 2026-09-28: an export-poster button must open the PRINT DIALOG with a poster preview,
never a direct download. Simple Research was doing exactly the forbidden thing — render a PNG, hand it
to a download helper — so a reader got doxa-prayer-poster.png in their downloads folder with no
preview, no paper size, and no way to print it without opening it somewhere else first.
The dialog IS the preview. printImageBlob() puts the rendered poster on a sheet of exactly the spec's
size (@page 8.5in x 11in, margin 0 — the renderer already composed the margins in) and raises the
dialog over the map. It goes in library/composables/printInPlace.js, which is THE print mechanism here
and already carries the Driver's earlier ruling that killed the pop-up path: an iframe cannot be
blocked and needs no user gesture, so the same call works for a human click and for an agent.
The ellipsis comes back on the label. It was stripped while the button handed over a file directly,
and an ellipsis promises a dialog — which is now what happens.
★ AND THE GATE ASSERTED THE OLD RULING, which is the part worth naming. check-export-one-click passed
while the button did the thing the Driver has now ruled against: it waited for a download and checked
the PNG's dimensions. A gate that encodes a superseded ruling does not just miss the defect, it defends
it — the next person to "fix" this button would have had to break a green gate to do it. It is replaced
by check-export-poster-prints, same four cases (leaf and gallery, en and ar), asserting the new
contract: one click, some frame calls window.print() within 120s, the printing document is the
1700x2200 poster on an 8.5in x 11in sheet, and NO download arrives. window.print is stubbed in every
frame — the native dialog is modal and would hang the run — so this measures everything up to the
dialog and not the browser's own chrome, which is the right boundary for a headless gate.
Why the download half is asserted separately: a button that opens the dialog AND saves a file still
breaks the rule, and a download that lands late is still a download, so it is judged over the whole run
rather than at a moment.
Verified: gates:pre 12/12, check:doc-scripts and check:no-credentials-omit PASS with the rename.
NOT verified here: the new gate needs a browser, and this agent does not start one — rega runs it.
rega, row 43 case 3 at 835ec1ac: the all-empty guard catches the cascade on
MapLibre and NOT reliably on Mapbox — in ar/mapbox the single event is still
EMPTY and the grid stays at 2,107. Three of four.
THIS IS THE THIRD TIME I HAVE TUNED A NUMBER FOR THIS ONE DEFECT, so the number
does not get another value. 60ms was "long enough for the cascade" and was not.
3 x 60ms was "long enough for the cascade" and is not either. I cannot measure
the cascade from inside the element, and every value I pick is therefore a
measurement of whichever machine I picked it on — which is the lesson already in
COMMON.md, arriving a third time because I applied it to the duration and not to
the DESIGN.
So the number's JOB changes. It was a guess at how long the cascade takes, which
is unknowable from here. It is now a CEILING on how long an empty report waits
before it is believed, which is a choice — and the two fail in opposite
directions. Too short reintroduced the bug three times; too long merely delays a
genuine clear by an amount no reader perceives. The loop now re-reads until the
state is NON-EMPTY, which is the condition that actually matters, and gives up
only at the ceiling.
900ms, chosen to sit under the composite's 1.2s push window so the two cannot
interact, and to be generous rather than tight — the failure mode of generous is
invisible.
Driven through the rule outside the browser: a cascade that fills at 400ms reports
FILLED at 420, one that fills at 800ms reports FILLED at 840, and a genuine clear
reports empty at 960. The old budget gave up at 180.
check:language-code-link's section G already asserted BOUNDEDNESS, and it failed
when I changed the shape — correctly, because that is the invariant it guards. It
now asserts the ceiling instead of the count, and says in the file why the shape
changed while the invariant did not: without SOME bound a genuine clear is
deferred for ever and the grid can never be un-narrowed. Both arms
negative-tested.
Source only. STUDIO: rebuild doxa-research-map.
NEEDS EYES: rega's ar/mapbox run is the one that matters — the other three already
pass, so a green ar/mapbox is the whole result.
rega, row 50: a region row click emitted {region:"asia"} and then an EMPTY event,
identically on both engines. The grid stayed narrowed only because the
composite's 1.2 second push window happened to swallow the second one — and a
pass that depends on a swallow is not a pass. rega said so, and was right to
refuse to score it green.
THE CAUSE IS MINE AND IT IS NOT A TIMING QUIRK. emitFilterState has always opened
with `if (applyingExternal) return`: when the HOST pushes state DOWN through
externalFilters the store changes, and reporting that back up is an echo of the
host's own instruction. That guard worked because the emit ran SYNCHRONOUSLY
inside the watcher, while the flag was still set. My coalescing moved the emit
60ms later, and applyingExternal is released on nextTick — so by the time the
emit ran the flag was false and the echo went out.
I fixed a burst and broke a guard that depended on the burst's timing. Changing
WHEN something runs is a change to everything that depended on when, and I did
not re-read what else was standing on that synchrony.
So the decision moves to schedule time, where the flag still means what it says:
a change that arrived from the host is not a change the reader made, and is not
reported back. The check inside emitFilterState stays as a second line of
defence.
This is the same shape as the dialect case on the branch below, and it is why
that one is a guard rather than a longer timeout: both are "the emit is no longer
where it thought it was".
★ AND THE GATE CAUGHT THE SAME HOLE IN ITSELF FOR THE THIRD TIME. The assertion
searched the scheduler for `if (applyingExternal) return`, and the comment
explaining the fix QUOTES that line — so deleting the code left the gate green.
It strips comments before asserting now. A check its own documentation can
satisfy is not a check, and I have now written that mistake three different ways:
a probe that yields zero when its assumption is wrong, a mutation aimed at one of
three identical bindings, and this.
Source only, stacked on sr/dialect-empty-cascade. STUDIO: rebuild
doxa-research-map (the same rebuild that branch already needs).
rega, row 43 case 3 at 52eeb0db: the coalescing landed — one event per click,
exactly as intended — and in 3 of 4 runs that one event was EMPTY,
{language:"", languageCode:"", languageCodes:[]}, so the grid stayed at 2,107.
The name fix works whenever it fires; the event just carried nothing to name.
THE CASCADE PASSES THROUGH A CLEARED STATE. mapStore.selectLanguage() sets
selectedDialect = null BEFORE it sets the language, so a dialect click walks the
store through "nothing selected" on its way to the answer. The settle timer fired
inside that window and read exactly what was there. The composite then did the
right thing with what it was told: an all-empty report IS how this element says
"the reader cleared everything".
MY FIRST FIX WAS A TIMEOUT, AND A TIMEOUT WAS THE WRONG KIND OF ANSWER. 60ms was
enough in my seat and not under headless swiftshader. That is the third time I
have tuned a duration on one machine and called it settled, so this one does not
get lengthened — lengthening moves the failure rather than removing it.
The guard is SEMANTIC instead. An all-empty read arriving straight after a filled
one is the one genuinely ambiguous case — it is what a mid-cascade read looks
like AND what a real clear looks like, and nothing in the payload separates them.
So it is re-taken rather than believed: the transient fills in and is reported
correctly, while a real clear stays empty through every re-take and is reported a
few tens of ms later, which no reader can perceive. The re-takes are BOUNDED
(3 x 60ms) so a clear can only ever be delayed, never swallowed, and the whole
budget stays far below the composite's 1.2s push window so the two cannot
collide. The budget resets on every fresh user action.
Note what is NOT assumed: the retries re-read the store on their own timer rather
than waiting for another watcher fire. In the failing runs the watcher fired only
ONCE, so a fix that depended on a second fire would not have worked.
check:language-code-link grows a section G for it, asserting both halves — the
guard exists, and it is bounded. Without the first the transient ships; without
the second a clear can never be reported at all. Both negative-tested.
Source only. STUDIO: needs a rebuild of doxa-research-map.
rega, row 43 case 3, both engines and both locales: clicking a dialect emitted
the right languageCode every time and narrowed the grid in only ONE of four runs,
while the name it reported read ", Afro-Asiatic__Arabic__Sudanese". Two defects
in one click, and both are mine.
THE NAME WAS A TREE NODE ID. selectedDialect.key is family__baseLang__dialect,
and the watcher fifteen lines above the offending template says so in as many
words — I read that comment and then used the key as a display name anyway.
selectedLanguage is also empty by the time a dialect is active, so the template
produced a leading comma in front of an internal identifier: a string no reader
could make sense of, in the one slot meant for a human name. The dialect's
originalLabels are the API labels the legend grouped ("Arabic, Sudanese"), which
are both the right words and already in the reader's locale. When a dialect
groups several labels all of them are reported, because that is what the
selection covers.
THE GRID LOTTERY WAS A BURST MEETING A GUARD. Clicking a dialect does not change
one store value; it clears the language, sets the family and sets the dialect,
across ticks. So the watcher fired three or four times and this element
dispatched three or four map-filtered events for ONE click. The composite ignores
anything arriving while it is pushing state into its children — a guard it needs,
because the grid re-emits during a push and the round trip would otherwise cancel
the selection just made — and that window is 1.2 seconds. Whether the LAST event,
the one carrying the settled state, arrived inside or outside the window was
timing. Three runs lost it; one did not, which is exactly the 1-in-4 rega
measured.
The fix belongs in this file rather than the other end: the guard is correct, and
the burst is what this element should not have been sending. One click, one
event, nothing to drop. A short settle window rather than nextTick because the
cascade crosses ticks (the legend bridge does async work); 60ms is far below the
1.2s guard and below what a reader notices.
I am NOT touching the composite's 1.2s window in this commit. It is now only
reachable by a genuinely rapid second click, and changing a guard that exists to
stop a self-cancelling round trip is not something to do in the same commit as
the bug it was masking.
check:language-code-link grows a section E for both, asserted against the source
because both are shapes. Three mutants, and the SECOND one found a hole in my own
check: searching the whole file for `emitFilterStateSettled()` is satisfied by the
helper's own declaration, so reverting the WATCHER left the gate green while the
burst came back. It now reads the watcher line specifically. A check its own
subject can satisfy is not a check.
Also corrects 22 to 23. rega counted 23 codes under the "Arabic" tier and was
right: 22 came from a cruder comma-split grouping of mine, while the tier's real
three-arm rule selects 23. The gate's computed numbers were always right; only my
prose was wrong. Two more comments still saying 22 live on sr/kit-d1-multivalue-filters,
which is already in studio's queue — I have not rewritten a branch someone is
landing; they need the same one-word change once it lands.
SPEC-DASHBOARD-KIT D1. filterSet[key] may hold an array meaning "any of these";
keys still AND together. The array support is four lines. The reason this is a
landing rather than a patch is the other half.
`a(r) === filterSet[k]` WAS WRITTEN OUT SEVEN TIMES — the shell's filteredRows,
the facet rail twice, the map panel, the table, the charts rail and the CSV
exporter. Seven copies of a one-character rule are survivable while the rule
stays one character. The moment it grows an array case, six of the seven keep
comparing an array with ===, every one of them quietly matches NOTHING, and the
panels disagree with each other about what is on screen. That is not abstract:
the facet rail computes the COUNTS a reader acts on before clicking, so a rail
still using === would print counts for a scope the map and table no longer show.
So the predicate now lives in the bus and every panel calls it. The
de-duplication is what makes the four lines safe.
WHAT A CLICK DOES IS UNCHANGED, deliberately. A plain click still REPLACES. D1
gives the bus the capability; making a rail row additive on an unmodified click
would change what every existing click does to the flagship, which is a product
decision and not this one. The array path is reached programmatically today.
AND THAT PATH IS THE PAYOFF: "Arabic" now narrows the grid. The language tier
covers 22 ISO codes, so no single value expressed it, and the composite had to
send nothing and leave the axis unfiltered — honest, and visibly incomplete,
because the map narrowed and the grid did not. The whole code set now crosses and
the grid ORs it. Verified per tier on the committed fixture rather than on one
example: for all 805 tiers the joined set selects exactly the rows the map
selected.
★ THE SEPARATOR IS `|`, AND MY FIRST DRAFT GOT THAT WRONG IN A WAY WORTH
RECORDING. I joined on `,` and broke label matching outright: 120 of the 864 live
language labels are comma-inverted ("Arabic, Mesopotamian"), so a selection made
BY LABEL was chopped in half and matched nothing. A separator that can also occur
inside a value is not a separator — the rule the URL hash states one file over,
which I had just written and then violated. check:filter-multivalue caught it
before it left this machine, and it caught it because it asserts a behaviour I
was KEEPING (label matching) and not only the one I was adding. Asserting only
the new behaviour would have shipped it.
EMPTY IS NOT A FILTER. An empty array clears the key rather than matching
nothing — a UI deselecting its last chip produces exactly that array, and
treating it as "narrow to zero" is the silent-empty-screen failure the hierarchy
contract already exists to prevent. A one-element array collapses to the scalar
it means, so the hash, the chips and every adapter keep their existing shape
until somebody actually picks two.
THE SURFACES THAT CANNOT SHOW A LIST SAY SO HONESTLY. labelFor resolves each
value and joins them, at the one owner, so the chip bar, the research-filter
chips and the CSV provenance line cannot drift apart. A dropdown shows NO
selection when the key holds several: ticking the first of three would state
something false about the board, which is worse than stating nothing, and the
chip still names every value so the selection is never invisible. The URL hash
carries a list and drops the display slug for it — the slug is one human name for
one code, and there is no honest slug for twenty-two.
check:filter-multivalue runs the real bus, greps the panels for surviving copies
of the predicate, round-trips the hash including a value containing a comma, and
lifts the grid's valueMatches to prove both ends OR the same way. No browser.
Green: check:filter-multivalue, check:language-code-link, check:facet-cascade and
its self-test, check:kpi-unit, check:csv-shape, check:no-free-identifiers,
check:sfc-parse, check:mjs-parse, check:descriptions, check:no-orphan-gates,
check:doc-scripts, check:script-flags, check:quoted-scripts.
SOURCE ONLY. Stacked on kit landing 1, which moved the bus into library/.
STUDIO: needs a rebuild of research-dashboard, dashboard-plus-grid,
doxa-research-map, research-map-plus-grid and people-groups-grid. package.json is
main's copy plus two lines beside check:fits-screen.
rega, on MapLibre, in BOTH locales: Languages > Language > "Arabic" sent
map-filtered with {language:"Arabic", languageCode:""}, the composite handed the
grid an empty code, and 2,107 rows became 0. An empty grid reads as "no
results" — the worst shape a wiring bug can take, because the reader believes
the data.
ONE LINE, AND IT WAS MINE. The emitter looked for a row whose language LABEL
equalled the selected name. A language tier's label is not a label any row
carries: the legend builds family > language > dialect and derives the language
tier by splitting the API label at the comma. Measured on the live source —
2,107 rows, every one carrying primary_language as {value,label} — there is NO
row whose label is "Arabic". There is "Arabic, Sudanese", "Arabic, Levantine",
"Arabic, Gulf", twenty-two codes in all. The exact match could never hit, in any
locale, which is why it failed identically in en and ar and why the locale fix
that prompted this code was right about the disease and wrong about this line.
This is the derived-value trap one tier down from where I last wrote it up: a
value that crosses a component boundary has to exist on a row. "Arabic" is a
grouping the legend invented; primary_language.label is a field.
So the rows are now matched the way the legend's own node matches pins — base,
"base," prefix, " base" suffix — rather than by a fourth hand-typed spelling of
a rule that already exists in two places. 61 tiers resolved an empty code
before; "Arabic" was one of them.
AND ONE CODE ONLY WHEN THERE IS ONE. "Arabic" means 22 ISO codes, so no single
value expresses it. Emitting one would filter the grid to a fraction of the
selection while looking correct; emitting the tier NAME is what showed zero. The
honest answer is to emit nothing for that axis and leave the grid unfiltered —
the map is narrowed, the grid is not, and the reader can see both. languageCodes
carries the whole set alongside, so this becomes a real narrowing the day
filterSet goes multi-value (SPEC-DASHBOARD-KIT D1) rather than a rewrite.
Measured on the fixture: 764 tiers now resolve one code and filter the grid; 41
cover several and abstain.
A DIALECT IS A LANGUAGE SELECTION TOO, and it emitted nothing at all — the
watcher never observed selectedDialect, so drilling from "Arabic" to "Arabic,
Sudanese" changed the map and left the grid reporting the tier above. Its
originalLabels are exact API labels, so it is the one unambiguously resolvable
case.
check:language-code-link, headless, no network at run time. It LIFTS the
emitter's match rule and the composite's gridLanguage() out of the shipping
source rather than carrying its own copy — a gate with its own copy of the rule
passes while the product is broken, which is exactly how this survived a green
check:linked-vocabulary. It proves the emitter's predicate and the legend's
filter expression agree on every (label, tier) pair, that every tier selects at
least one row, and that an ambiguous tier narrows nothing. The fixture is the
864 distinct label/value pairs from the live source, deduplicated rather than
sampled so the counts above are about the whole set. Four mutants; reverting the
emitter to the exact-label rule reproduces rega's finding exactly — 61 tiers
selecting zero rows, "Arabic" named first.
STUDIO: needs a rebuild of doxa-research-map and research-map-plus-grid.
★ THE CAMERA DID NOT JUMP, AND THE DRIVER IS PHOTOSENSITIVE.
rega measured it in a real browser: with prefers-reduced-motion set, the
grid→map framing still animated for ~5.0s on MapLibre and ~6.7s on Mapbox —
indistinguishable from no preference at all, where a jump is ~0ms. The
preference was read correctly here and thrown away one layer down: useMapFly's
`flyToCoords(coords, zoom)` takes NO options argument, so the `{ duration: 0 }`
being handed to it went nowhere. Its neighbour `fitBounds(bounds, padding,
options)` does accept one — two functions in one file, different signatures, and
nothing at the call site to show it.
So the reduced-motion branch now drives the engine directly (jumpTo, or
fitBounds with duration 0) instead of depending on whether a helper forwards an
option. The eased paths are unchanged and still go through mapFly, so ordinary
motion keeps one owner. The library asymmetry is reported separately; it affects
every reduced-motion caller of flyToCoords, not just this one.
check:reduced-motion-jumps proves the DECISION headlessly — it lifts the real
camera block out of the SFC and runs it against a mocked matchMedia and a
recording map, over four shapes of selection:
several peoples reduce=true → map.fitBounds(duration 0) false → mapFly.fitBounds
one people reduce=true → map.jumpTo false → mapFly.flyToCoords
tight cluster reduce=true → map.jumpTo false → mapFly.flyToCoords
globe-spanning reduce=true → map.jumpTo false → mapFly.flyToCoords
It cannot prove the engine honours duration 0 — that stays rega's half. It
proves we ASK for a jump, which is the half that was wrong, and it runs in
milliseconds instead of six seconds of watching movestart→moveend.
★ AND THE SCREEN-READER BLIND SPOT. A source audit of both my maps found 31
user-visible strings that no check could see: 24 with no catalogue entry at all
(invisible to check:composite-locale-leaks, whose criterion is that the
catalogue already holds the string) and 7 that HAVE a translation in eleven
languages and were still typed into the template. Most are aria-labels, titles
and tooltips — text only a screen-reader user or a hovering mouse ever meets.
Our localization verification had been "what can I see", and the blind spot was
exactly the readers who most need the words.
All 31 are wired now; the audit reports 0/0 for this lane. Two were duplicates
resolved by REUSING the existing key rather than adding a second: "Export CSV ↓"
→ dashboard.export_csv with the arrow kept as a glyph, and the chip's "Clear
all" → controls.reset_all, since clearing every filter is the action that button
already names. Two keys for one action is how a control ends up saying two
different things in two languages.
The keys themselves live in library/i18n and are a separate rule-5B handoff, so
until they land every one of these falls back to the English it reads today.
THE RENAME. The built tree is published as dx-maps now, and both gates had
public/doxa-maps hardcoded. A wrong tree name does not error, it 404s — and a
gate that 404s reports a clean page, which is the most expensive kind of green.
They read TREE_NAME from the one helper instead, so the next rename moves them.
★ AND THE RE-VALIDATION THE REPAIR DEMANDED CAUGHT A SECOND BUG — IN THE GATE.
Re-running the clustering gate against its own baseline, Network came back at
1,700 lines: exactly MST's number, where the same build had given 23,896. Not a
regression. The gate settled on "two consecutive reads agree", and the previous
mode's lines stay on the map for as long as the new mode takes to compute — so a
stable read can be the OLD picture. It now waits for the drawing to CHANGE from
what was on screen when the button was clicked, and only then waits for it to
stop moving; if it never changes, that is recorded and the assertions say so.
Re-validated after the fix, matching the baseline exactly:
off 0/0/0 · native 37/37/0 · mst 0/0/1700 · network 0/0/23896
A repair nobody re-measured is worth nothing, and this one was wrong twice
before it was right.
After the dx-maps rename, its public/doxa-maps literal made it SKIP (exit 2) without measuring.
Validated against its baseline on the renamed tree: 4/4 one-click exports, 1700x2200 PNGs,
0 errors, as before the rename. Also: usage examples and present-tense comments in the
gallery and planner name /dx-maps/ or the configured tree; both bundles rebuild byte-identical.
Dragging the side menu's pin-dim slider changed nothing: it wrote a store field
that did not exist, the same defect as the clustering picker beside it and found
while fixing that one.
The obvious fix would have created a worse bug. The pin layer's circle-opacity
already has an owner — legend dimming writes a ['case', …, 1, 0] expression — so
a flat slider value from a second writer would silently erase dimming, or be
erased by it, depending on which control the reader touched last. Nothing would
have looked broken; the map would just have stopped answering one of them.
So there is now exactly ONE writer and the value is the PRODUCT:
opacity = slider × (1 for a matched pin, 0 for a dimmed one)
The slider scales the whole layer, dimming still dims relative to it, neither
wins. Seven call sites that each wrote opacity by hand now go through it.
MEASURED, reading the live paint property rather than the store:
clustering off 1
clustering on, 0.5 0.5
+ a dimming selection ['case', <religion is B>, 0.5, 0]
slider back to 1.0 ['case', <religion is B>, 1, 0]
One thing the gate had to learn, and it is written down in it: a REGION
selection does not dim at all — it sets a layer filter, so non-matching pins are
removed rather than faded. Testing that path would have reported "the slider
erased the dimming" about code that never writes opacity.
★ ALSO: the Globe/Flat control's 24px touch target now applies at EVERY width.
The first fix scoped it to `@media (pointer: coarse)`, reasoning that a mouse
needs no touch floor. Wrong twice: WCAG 2.5.8 is a TARGET SIZE criterion, not a
touch one, and the query meant phones passed at 34px while desktop stayed at
73.5×23 — the same one-pixel failure, now invisible to a phone sweep. Measured
after: 73.5×34 at 390, 430 and 1440.
Choosing "Native" in the research map's side menu drew nothing. So did MST, and
so did Network. A control that does nothing is worse than one that is absent:
it tells the reader the map cannot do what it can.
TWO CAUSES, AND FIXING EITHER ALONE WOULD HAVE LOOKED LIKE A FIX.
1. mapStore never declared `clusteringMode`. The picker wrote to a property no
reactive system was watching, so useMapClustering's `mode` kept its own
internal default and the click changed nothing.
2. The legend's Clusters checkbox force-set 'mst' "regardless of what's in the
store", with the mode watcher "kept as a no-op for forwards compat" — its
own words. Even with (1) fixed, one tick of that checkbox would have snapped
Native back to MST: a control that works until you touch the other one.
So the two controls are now two views of one state. clusteringMode IS the state
('off' means disabled); the picker sets it; the checkbox maps onto it, promoting
only 'off' to 'mst' so the checkbox alone behaves exactly as it always has. They
can no longer show different things. The dim-slider's clusterPinOpacity got a
real home in the same pass, for the same reason it was missing.
★ THE GATE ASSERTS ON WHAT IS DRAWN, NOT ON WHAT IS SET. `clusteringMode ===
'mapbox'` would have passed the moment (1) was fixed, while the map still drew
MST lines — state was exactly the evidence that let an inert control ship. The
map now reports, per clustering layer, whether it exists, whether it is visible
and how many features are rendered; the gate clicks the real buttons and reads
that. MEASURED, before and after, on the same tree:
before off 0/0/0 native 0/0/0 mst 0/0/0 network 0/0/0
after off 0/0/0 native 37/37/0 mst 0/0/1700 network 0/0/23896
(circles/counts/lines)
It also asserts no orphan layers between modes, and that MST and Network differ
— two labels for one behaviour is the same defect as a dead option.
One measurement bug caught on the way, worth recording: a fixed 6-second pause
reported MST as drawing nothing while Network drew thousands of lines. MST over
~2,100 pins is quadratic and had simply not finished. The gate now waits for the
drawing to settle rather than guessing a duration — a gate that calls a slow
feature broken is worse than no gate.