Commit graph

375 commits

Author SHA1 Message Date
doxa-maps-88655c70
0f0bebd9c2 Serve locally the way production serves, so a bad URL fails here instead of live
bun run serve was python3 -m http.server, which resolves a directory request to
its index.html. The live static layer returns 404 for the same request. So a gate
could pass locally on a URL that does not work in production, and nothing local
could ever catch it.

That is not a harness quirk, it is a blind spot with a green light on it: every
defect inside the permissive gap passes locally by construction and can only be
found in production, which is the worst place to find it. It cost a false
"the publish failed, zero filters live" reading on a publish that was
byte-for-byte correct.

serve-strict.mjs returns 404 for a directory, exactly like production, with no
index fallback and no directory listing. It also refuses to read outside its
root. Loopback only, inherited from the script it replaces, whose own comment
records that the bind was once 0.0.0.0 and answered on the box's public IP while
a gate was running. The permissive server is kept as serve:permissive rather
than deleted, so the difference can be demonstrated.

It earned itself immediately: verify-shell-scroll held two bare-directory URLs
and failed the first time it ran against the strict server. Both now ask for
index.html, which is the form the manifest already ships for all 28 profiles,
and the gate passes again -- both wheel gestures moving the grid 1500px onto
screen.

Fixing the server rather than the URLs is the point. The repo was already
production-correct: 28 of 28 manifest URLs and 82 gate references use
index.html. What was missing was anything that could fail when one did not. A
rule you have to remember fails under time pressure; a server that returns 404
does not.
2026-09-23 03:09:25 +00:00
doxa-maps-88655c70
2b5ff1328b Show the eighth filter, and wrap the row instead of scrolling it
The Driver named eight filters -- people, region, block, country, language,
religion, prayer, engagement -- and the rail rendered seven. engagement_status
was already fully built: a label and getter, the Status section in the tree, and
the Engagement column in the table. It had simply never been registered in the
dropdown list, so the one piece missing was a single entry.

Adding it exposed the constraint that goes with the count. The dropdowns are a
fixed 168px on a 200px pitch, so eight need about 1428px. At a 1440 viewport that
just fits; at 1280 the row reported scrollWidth 1414 against clientWidth 1252 --
a horizontal scrollbar, which is exactly what the Driver asked not to see.

The row now wraps rather than scrolls. Wrapping is the right lever because the
fixed 168px width is itself a Driver decision: a flexible width was what made the
dropdowns jump as a selection changed. The justify-content pair is kept, not
simplified -- its own comment records that `safe` degrades space-evenly to
flex-start rather than pushing the first item to an offset a scroller can never
reach. That hazard belonged to the scroller and costs nothing now, so it stays.

Verified on a settled tree at three widths. 1440, 1280 and 1024 all render eight
filters across two rows at identical offsets, with the row reporting no
horizontal overflow and every filter fully inside the viewport.
2026-09-23 02:49:38 +00:00
doxa-maps-88655c70
1652559e06 Teach the synced-CSS gate what a selector is, and turn it on
The gate reported 14 conflicts across two files. Thirteen were false and it was
blind to real ones, and both came from a single cause: it keyed a rule on the
last class before the brace, so any qualified rule was filed under its own base
rule. A dark-mode override, a .dsm-dark variant and a :host(:fullscreen) rule all
merged into the unconditional rules they were meant to override, and the pair was
then reported as the file disagreeing with itself. In every case both copies were
identical and the code correct.

Merging is last-write-wins, so the same bug also HID conflicts. Poisoning
.dsm-root inset from 0 to 7px in the shadow copy produced no finding at all,
because the fullscreen rule's own inset:0 was merged in afterwards and restored
it. A property could be shared, genuinely divergent, and invisible.

The key is now the full selector, and at-rules are part of it. The header already
claimed media-query variants were treated as distinct rules; nothing implemented
it, and PeopleGroupDetail's 18px name with a 16px @media override read as the
file contradicting itself when the shadow copy simply has no mobile rule -- which
this gate's own contract explicitly allows. At-rule bodies are brace-matched
rather than regex-matched, since a lazy pattern stops at the first inner brace.

The shadow literal is also sliced AFTER its opening backtick. Slicing from the
anchor left useShadowStyles(` at the head of the CSS, which a full-selector
pattern absorbs into the first rule -- .dsm-root was captured as
"useShadowStyles(` .dsm-root" and silently dropped.

That leaves one true finding, and it was real: .action-buttons padding-top was
12px as a Vue child and 4px in a shadow root. Both values date from the initial
commit, so there is no later intent to infer; aligned to the shadow copy's 4px
because that is the copy that reaches the shipped bundles, so it preserves what
users have been seeing rather than changing it.

check:synced-css:self-test poisons three properties in memory and requires each
to be caught. inset is there deliberately -- it is the one the old implementation
missed, so it is the case that proves the fix. Now in build:unlocked, which it
could never be while it was red.
2026-09-23 01:22:36 +00:00
doxa-maps-88655c70
1c01992a88 Gate the tab radius so a superseded rationale cannot quietly restore the pill
The 999px stadium was a real instruction that shipped, and its rationale is still
in the layout file as history. A superseded rationale that still reads as current
is how a reversed decision gets undone -- a reader trusting it would restore the
pill believing they were fixing a regression. The comment now says not to; this
makes that enforceable.

It asserts the TOKEN rather than the value, because the requirement is that the
tabs, the collapse button and the filter toggles agree. A shared knob guarantees
that; three matching literals only coincidentally satisfy it. The expected
fallback is READ FROM .vtl-collapse rather than hard-coded, so a gate asserting
8px cannot pass while the collapse button moves to 10px and the two surfaces
silently diverge. Both synced copies are checked, so a fix applied to one host
fails here rather than shipping correct in one and wrong in the other.

.vtl-cap keeps its pill and is deliberately not asserted: the complaint was the
ratio of radius to size, and a gate banning 999px everywhere would over-apply the
correction.

check:tab-radius:self-test poisons the source in memory and requires both
regressions to be caught -- the pill restored, and a bare literal beside the
token. Both caught, tree untouched. Wired into build:unlocked beside the other
source-level checks so it fails in a second rather than after the vite loop.

Noted while wiring: check:synced-css is NOT in the build chain, which is how two
files have stayed in conflict. Left alone here -- turning it on is its own change
with its own failures to fix first.
2026-09-23 01:08:10 +00:00
doxa-maps-88655c70
d4a8184ad7 Match the tab radius to the rest of the app, and open on the tab you were last on
The vertical tabs were a 999px stadium on a 48px row. That was a real earlier
instruction -- imitate the YouTube active row -- and it shipped, but measured
against the product it is the only thing shaped that way: the collapse button is
8px, the filter toggles are 8px, and 8px is already the most common radius in
the layout file. They now read var(--vtl-radius,8px), so a consumer has one knob
and the three surfaces cannot drift apart. The 48px Material row height is
untouched. The YouTube rationale stays in the comment as history with an explicit
note not to restore the pill from it, because a superseded rationale that still
reads as current is how a reversed decision gets quietly undone.

The .vtl-cap badge keeps its pill. The complaint was about the ratio of radius to
size -- a 16px chip at 999px is a chip, a 48px row at 999px is a stadium.

The studio opened on nothing and said "Choose an element from the list." It now
resolves a tab in priority order: the URL hash, since an explicit shareable
request should beat a remembered one; then localStorage, so a reload is not a
fresh start; then the first row, so the pane is never empty. This lives in the
studio rather than the layout because VerticalTabsLayout is controlled via
v-model:active and deliberately holds no selection state.

Storage is keyed by pathname so two shells on one origin do not fight over one
key, every access is wrapped because localStorage throws in private mode and an
exception in setup would blank the whole subtree, and the key is only written
after the row is confirmed to exist so a bad key cannot poison the next load.

Verified in the built shell on a settled tree: first load auto-selects with no
hint and stores the key; clicking big-picture moves the selection and updates
storage; a reload restores big-picture; and a deliberately poisoned key falls
back to the first tab and rewrites itself rather than rendering an empty pane.
2026-09-23 01:03:15 +00:00
doxa-maps-88655c70
3c594386b4 Give the dashboard a page-scroll layout so the grid beneath it is reachable
The Driver could not scroll down to the people-groups grid. The cause was a
layout mismatch rather than any one property: research-dashboard is a
fixed-viewport app whose table scrolls internally, and the cap-4 composite
stacked it above a grid and expected the page to scroll, pinning the band to one
screen so height:100% would resolve. At a 900px viewport that left the table a
269px window over 54834px of rows, so a wheel anywhere in the lower third landed
on the table and never reached the page.

research-dashboard now accepts layout:page-scroll through profileConfig, the
channel ProfileLoader already exposes as the profile-config attribute. A prop
would not work here: the registered custom element is ProfileLoader, which
renders the profile with no props bound, so a declared prop type-checks, builds
clean and never fires. Default is unset, so every existing embed is unchanged.

In that mode the shell height is auto, the map row carries an explicit height so
it cannot collapse, and the table keeps an internal scroll bounded at 62svh.
Both extremes were measured and both are wrong: at 34% of a screen the table is
a keyhole that captures every wheel event, and released to overflow:visible it
renders all 54834px and puts the grid at y=57777 -- reachable in principle,
useless in practice. A tall bounded panel is neither.

Verified in the cap-5 shell on a settled tree: wheeling over the map and
wheeling below it now both move the page 1500px, which is its entire scroll
range, and the grid lands on screen at top 851. The two gestures agreeing is the
real proof -- the broken state read 300 against 2311, the signature of something
consuming the wheel. The standalone leaf is unchanged: no page-scroll class,
900px shell, internal table scroll intact.
2026-09-23 00:53:29 +00:00
doxa-maps-88655c70
b40fd5b48d Take overscroll-behavior back off the table, and record what the wheel actually hits
The previous commit set overscroll-behavior:contain on .rd-grid to stop it
swallowing the wheel. That is backwards. contain prevents a scroll which bottoms
out in an element from chaining to its parent, and chaining to the parent is the
behaviour that would let the page keep moving once the table is exhausted.
Measured before and after: the symptom did not move at all, because a table with
54834px of content never bottoms out in the first place.

It is left at the default.

The diagnosis that produced it was reached by geometry, and geometry was the
wrong instrument. getBoundingClientRect placed this element at y=673-880, well
below the y=420 gesture, which appeared to exonerate it; a deep shadow walk had
earlier reported its scrollTop moving, which appeared to convict it. Both are
readings of state at some moment, not of what the browser dispatched. A
capture-phase wheel listener reading event.composedPath() settles it: at y=420
the path is rd-td, rd-row, rd-grid, rd-table-wrap. Use the event path when
geometry and behaviour disagree.

The underlying bug is still open and is a layout mismatch rather than a CSS
property: the research dashboard is a fixed-viewport grid that gives its table
34% of the band and legitimately owns the wheel inside it, hosted in a composite
that expects the page to scroll past it. That needs a design decision, not a
fourth attempt at a one-line fix.
2026-09-23 00:40:33 +00:00
doxa-maps-88655c70
285ba39c31 Stop the data table from swallowing the wheel, and grade the gesture honestly
The Driver could not scroll down to the people-groups grid. Three separate
diagnoses blamed the map, including one of mine that shipped. Measured in the
cap-5 shell: a ten-tick wheel with the cursor over the dashboard moved the outer
pane 300px while .rd-grid's own scrollTop went 0 to 2606. The wheel was being
consumed by the table -- a virtualized grid whose scrollHeight is 54834px inside
a ~207px window. That is ordinary scroll-chaining: a nested scrollport eats wheel
events until it reaches its own end, and with 54 thousand pixels of table it
never reaches the end. The Mapbox canvas sits above the cursor and was never
involved.

overscroll-behavior:contain does not stop the table scrolling; it stops it
chaining, so the remainder now reaches the pane.

The gate that should have caught this had two defects of its own. It measured the
gesture the Driver makes and then asserted on a different number, so it passed at
2611px while his gesture moved 300. When that was fixed the threshold was written
as "moved less than 200px" -- a figure picked without comparing it to the value
already measured as broken, which was 300, so it still passed. The threshold is
now derived from a measured baseline: the same gesture performed lower down the
page, where it is known to work. Two identical gestures on one surface should
move it comparably, and there is no constant left to rot.
2026-09-23 00:31:35 +00:00
doxa-maps-88655c70
81fbf7c89f Do not report a filter bug when the gate's own map handle went stale
The panel destroys and re-creates its Map on a re-render, and the dev server's HMR
does this freely, which leaves window.__rdashMap pointing at a torn-down instance
whose getSource() throws "Cannot read properties of undefined (reading
'getOwnSource')". Measured one run in three failing that way on the dev surface
while the product was fine — the first invocation through the package.json script
failed for exactly this reason and the next passed unchanged.

A gate that reports a product bug when its own reference went stale is a gate that
gets switched off. __live() now proves the handle still works before any assertion
reads through it, and readState() waits for the re-created instance to repopulate
rather than treating a dangling reference as a verdict. A state that is still
stale after the grace period is reported as an infrastructure fault, named as such,
not as a filter failure.

Verified 4/4 consecutive passes on the dev surface where it previously flaked, and
all four negative-test modes still catch their regressions (the retry must not mask
a real failure).
2026-09-22 22:50:21 +00:00
doxa-maps-88655c70
92fae9937d Stop the pin comments claiming a navy pin and a 7:1 floor that no longer ship
Three comment sites still described the pre-heartbeat product in the present
tense, which is the confidently-wrong failure this file warns about elsewhere:

- the measured contrast table cited the old pale-coral hexes
- 'WHAT IT IS NOW. Fill #1a237e ... under a white ring' described a pin that
  no longer exists, and listed 'reverting to orange' as a live rejection when
  the Driver has since asked for orange
- the constraints list asserted 'navy pin >= 7:1 on every bin. PASS', which
  reads as an acceptance criterion someone would try to restore

An orange pin cannot reach 7:1 against a red ramp at any tuning, so the
criterion is replaced rather than quietly lowered: effective visibility is
max(orange disc, white stroke) per bin, worst 1.83.

Also answers the standing 'MEASURE the pale end' note: the two bands swapped
roles. The disc is strongest on the pale bins where the ring is weak, and
collapses on the dark bins where the ring takes over, so both must stay.
2026-09-22 22:49:22 +00:00
doxa-maps-88655c70
2a9198fa3c Assert the shipped heartbeat ramp, not just the panel's fallback
check:heartbeat proved the PANEL FALLBACK was intact and called that "default
ramp unchanged". It is not the same property. The running app resolves the ramp
with dashboard.config.js's map.* layered on top of that fallback, and the gate
never read the real config: assertion F checked only that heartbeatThreshold was
DECLARED, never what it was SET TO.

Measured consequence, reproduced before fixing:

    sed -i 's/heartbeatThreshold: 150,/heartbeatThreshold: 999,/' \
      plugin-designs/research-dashboard/dashboard.config.js
    node internal/check-heartbeat-threshold.mjs   # exit 0

The shipped map could silently change its ramp while the gate reported it
unchanged. Now exits 1, naming the key and its value.

Adds assertion A2: resolve the ramp the way the running app does — panel
defaults with the shipped map.* applied — and require it to equal the historical
5/20/60/150/Infinity with its labels. A (fallback) and A2 (shipped) share one
comparison helper so they cannot drift. A2 also rejects a shipped heartbeatMode
other than 'fixed': a data-derived ramp is right for an unknown dataset and
wrong for this one, where 150 is deliberate. Changing the shipped ramp is now a
deliberate act that must update HISTORICAL in the same commit.

dashboard.config.js is read by brace-matching its `map:` literal rather than
import(): it pulls in @map/* aliases and a JSON module, so importing it would
need Vite's resolver — a dependency that buys nothing for a bag of scalars.

The self-test now runs three cases, each required to fire the assertion that
exists for it, and poisons in memory so a crash cannot leave the tree edited:

    [literal]         resolver replaced by the old array        -> must fire B
    [shipped-default] shipped heartbeatThreshold changed        -> must fire A2
    [shipped-mode]    shipped heartbeatMode flipped to quantile -> must fire A2

The header previously claimed the gate was "negative-tested" on the strength of
the first case alone. True of that case, misleading overall, so it now states
per-case coverage and what is NOT covered (colour contrast, and whether the map
paints at all). Testing that the FEATURE works — a configured threshold moves
the edge — is not testing that the REGRESSION is caught, and only the second
protects the requirement that today's map must not change.
2026-09-22 22:44:10 +00:00
doxa-maps-88655c70
04c2a3d2eb Gate the rule that a facet selection must change the dots — and that the dots say what they mean
The prayer filter was reported as "not applying" to the map. Measuring it first
changed the fix: the filter was never broken. Selecting "Has Prayer" already
narrowed the rd-bubbles source 2107 -> 689 features, exactly matching the
dropdown's own tally, disjoint from "Needs Prayer" (1418), summing to 2107 and
round-tripping on clear.

What was broken is that circle-color was the flat literal '#ef6c00' — read back
off the live layer, the bare string, not an expression — so every dot painted the
same orange whether it had prayer or not. A prayer question got an answer with no
prayer information in it, and the surviving dots looked identical to the ones the
user started with. That is why a working filter reads as "not applying".

This gate asserts all three halves, because any one alone is a false pass:
  1. the selection changes WHICH dots are on the map, by count AND by identity
  2. every surviving dot satisfies the level that was selected
  3. while a prayer filter is active the colour is a data-driven expression
     reading peoplePraying, not a flat literal

Assertion 3 is the one that matters: 1 and 2 were already green while the bug was
visible. Identity is checked as well as count because the filter removes from the
tail of an alphabetical list, so the first five slugs are byte-identical before
and after — a gate reading the head of the list would report "unchanged".

NEGATIVE-TESTED in four modes, each isolating one assertion, all verified caught:
  colour  reverts only the paint (the reported bug)    -> caught by assertion 3
  props   strips peoplePraying                          -> caught by assertion 2
  frozen  blocks the source update entirely             -> caught by assertion 1
  both    reverts the lot                               -> caught
A gate that cannot fail is worse than no gate.

Verified on both hosts against the BUILT tree: the cap-5 shell and the standalone
leaf. Browser gate, so not in build:unlocked.
2026-09-22 22:42:42 +00:00
doxa-maps-88655c70
1de86ede8f Rebuild the shipped bundles for the heartbeat-red ramp and orange pins
The ramp's top bin is now a label red (#cd2027 — the OKLCh cluster that
Heinz, Campbell's and fire-engine all sit in) so the countries holding the
most unreached people groups read as a heart rather than as clay.

Pins move to Material Orange 800 (#ef6c00) and the selection ring takes over
the navy the pins vacated (#1a237e): an orange pin against the old neon-orange
selection ring measured 1.07 contrast, which would have made the selected
state invisible.

Legend labels the top bin 'heartbeat zone', derived at binLabel() so a host
that moves the threshold cannot strand the name on a bin that is no longer
the top one.
2026-09-22 22:36:23 +00:00
doxa-maps-88655c70
34cbb39a5c Make the heartbeat-zone threshold a variable, not a constant
The choropleth's bin edges were a 5-stop literal (5/20/60/150/Infinity) in
DashboardMapPanel.vue. Those numbers are not a property of choropleths; they
are a property of THIS dataset — ~2000 people groups in focus. Pointed at a
200-group dataset, a 150 cutoff puts every country in the bottom bin and the
map renders uniformly pale: no crash, no error, correct code, meaningless map.
A dataset assumption wearing the costume of a constant.

The top band is the HEARTBEAT ZONE — the countries holding the most people
groups. Its edge now comes from dashboard.config.js as map.heartbeatThreshold,
alongside the other per-dataset facts (facets, columns, kpis). Re-pointing the
dashboard at a new dataset is a config edit, not a source edit.

Splits the old fused BINS array into the two independent facts it always was:
BIN_COLORS (measured against the navy pin, dataset-independent) and the edges
(pure dataset assumption). Fused, moving a threshold meant restating every hex
— silently reverting the contrast work — and restyling meant restating every
edge. Set either alone now.

Setting heartbeatThreshold alone rescales the three lower edges by their
original ratios, so the ramp keeps its shape (long tail of small counts, short
head of large ones) at any dataset size. Legend labels derive from the edges,
so a moved threshold cannot leave a stale "150+" on the legend. Opt-in
'quantile' mode derives the edges from the data actually loaded; it is NOT the
default, because 150 is a deliberate number for this dataset and a derived
default would silently change today's map.

Two bugs found and fixed while measuring:
- Edges could land at 1, creating a bin no count can enter (colorFor tests
  `count < upTo`): the ramp painted 4 of its 5 colours while the legend
  advertised 5. Appeared at threshold 40 and again in quantile mode.
- profileConfig merged shallowly, so a host passing the single key it cares
  about — {map:{heartbeatThreshold:40}} — replaced the whole map object and
  dropped mapStyle:'paper'. The one override a host is most likely to write
  was the one that broke the basemap. `map` now merges one level deep; every
  other key stays shallow.

No defineProps: the registered custom element is ProfileLoader, which renders
the profile with no props bound, so a prop declared on the profile would read
as configurable and never fire. The reachable attribute path is ProfileLoader's
existing profile-config JSON, verified end-to-end.

Default rendering is unchanged, verified by measurement rather than eyeball:
bin assignment over the 114-row committed fixture (59 countries) plus a 17-point
count sweep is identical before and after, with and without config supplied.

internal/check-heartbeat-threshold.mjs executes the resolver from the live SFC
and asserts the bins MOVE when the config moves — a grep for "150" would both
false-positive (it is the legitimate default) and false-negative (a
re-hard-coding at 200 sails through). Its --self-test re-hard-codes the ramp in
memory and requires the gate to reject it.
2026-09-22 22:25:38 +00:00
doxa-maps-88655c70
fa0b7d1099 Keep the development-era internal code name out of shipped files
The repo is handed to the Doxa team, and a code name from its build history
means nothing to an inheritor. Two places carried it, both regenerating:

  - public/.build.lock was TRACKED. internal/build-lock.sh stamps it with
    `by=$(whoami)` under flock on every build, so each rebuild committed the
    building machine's username. Scrubbing the string would have been useless;
    the file is pure runtime state nothing reads but the lock, so it is now
    ignored via public/.gitignore instead.
  - check-no-absolute-paths.mjs quoted a real home path in its header to
    explain the rule. The prose works the same with /home/<user>/.

check:no-codename makes it stick. It scans tracked files only (the set a clone
receives), names every offending file:line in one pass, and refuses to pass
vacuously on a short file list.

Two things it has to get right or it becomes noise. "10x" is ordinary English
for a multiplier and library/scripts/research uses it correctly, so only the
full two-part name matches. And the same letters appear as the unix username
inside /home/ paths, which is a portability defect check:no-abs-paths already
reports better — those are listed, not failed, so the two gates cannot
disagree about one string.

It carries no ALLOW entry for itself. A gate must never be its own witness,
and an exemption is a hole a real leak could hide in, so the pattern is
assembled from fragments at run time and the file scans itself on the same
terms as every other.

Negative-tested: planting the name in README.md fails naming README.md:2,
exit 1; removing it passes. All five spellings (LK10X, LK 10X, lk10x, LK-10X,
lk_10x) planted across five different shipped surfaces are reported together
in one run, exit 1; reverting returns exit 0.

Also quotes check:heartbeat into the CLI doc, which check:quoted-scripts
requires and which landed in package.json without it.
2026-09-22 22:18:18 +00:00
doxa-maps-88655c70
2ec6c00e68 Stop the map from swallowing the wheel, and grade the gesture the user makes
syncScrollZoomToHost decided whether Mapbox should keep scrollZoom by sampling
the layout at 0, 1200 and 4000ms and then never asking again. That is a race,
not a sync: the cap-5 dashboard tab lazy-mounts on activation, so at every one
of those instants the scrollport ancestor had not reached its final height. The
map kept scrollZoom enabled and ate the wheel. Wheeling over the map moved the
grid 300px; wheeling below it moved 2611px. The cursor is over the map, so the
people-groups grid under the dashboard was unreachable.

The condition is layout-dependent, so observe layout instead of guessing when it
settles: a ResizeObserver on the map container, the scrollport that owns the page
scroll, and body, plus a resize listener. Every event that can flip the answer now
re-asks it.

verify-shell-scroll already measured this. It computed movedByMap for the gesture
over the map, then asserted only on movedTotal, which sums in a second gesture
below the map -- so it reported PASS at 2611px while the real gesture moved 300px.
It now asserts on the number it was built to measure. Worth remembering as its own
failure family: a gate that measures the right thing and grades a different
variable, which fails honestly but never on the condition it exists for.
2026-09-22 22:06:59 +00:00
doxa-maps-88655c70
d4ab3a8e09 Anchor the gallery's assets to the tree, not to the page directory
Some checks are pending
build-gate / build (push) Waiting to run
The archived gallery resolved ASSETS as BASE + 'assets' — this PAGE's own
directory. That was correct only while the gallery WAS the root page, where the
two coincide. Since it moved to /__gallery it asked for /__gallery/assets/...
Measured on the built tree with the page at /__gallery/:

    /__gallery/assets/vendor/vue/vue.global.prod.js ... 404
    /doxa-maps/assets/vendor/vue/vue.global.prod.js ... 200

Every leaf page already agrees where assets live: a leaf at
/<tree>/<bundle>/<profile>/ bakes assetsBase "../../assets" -> /<tree>/assets.
So anchor to the tree root, which TREE_URL already names.

This degraded QUIETLY rather than blanking, which is why it outlived the move:
loadVue() falls back to the jsDelivr CDN, so the page kept working while
silently losing the portable / offline / no-CDN property the vendored copy
exists to provide. After the fix the built page loads Vue from
/doxa-maps/assets/vendor/vue/ and reports zero 4xx/5xx.

TREE_URL moves above ASSETS because ASSETS now depends on it; both are `var`,
so the old order would have yielded the string "undefined/assets" rather than
an error. Its explanatory comment moves with the declaration.

The dev server passes assetsBaseUrl explicitly, so this is the built-tree half
of the fix its comment already documents for dev.
2026-09-22 22:03:58 +00:00
doxa-maps-88655c70
04eccdfa7a Gate the scrim-covers-viewport rule, and say what was not verified
Some checks are pending
build-gate / build (push) Waiting to run
A `position:fixed; inset:0` scrim is only viewport-relative while nothing
establishes a containing block for fixed descendants. VerticalTabsLayout sets
`contain:layout` on .vtl-content, so inside the cap-5 studio every such scrim
covered the CONTENT PANE instead. Measured at a 1280x900 viewport:

    .dt-scrim / .modal-scrim / .lt-overlay ... 1024x793 @ (256,107)
    captured by div.vtl-content [contain:layout]

256 is the rail width, 107 the header height — left uncovered and CLICKABLE
behind what still looks like a modal. The fix (library/utils/viewportScrim.js)
measures the real origin and subtracts it, rather than removing the shell's
contain, which is load-bearing for a different reason.

This adds the gate that keeps it fixed. A source grep cannot: every one of these
scrims greps as a correct inset:0, because the variable is the ANCESTOR, not the
declaration — and which ancestors exist depends on the HOST, not the component.

check:scrim-viewport:self-test reverts each scrim to plain inset:0 and requires
EVERY case to fail; a gate that cannot fail is worse than no gate, and this one
measures a property that looks fine in a screenshot either way. The self-test
also surfaced that .lt-overlay is captured TWICE — div.lt-root [transform] as
well — collapsing it to 233x30 rather than merely to the pane.

SemanticTreeLegend's .stl-export-backdrop is fixed the same way but marked as
NOT verified through the UI: it sits behind SHOW_EXPORT_BUTTON = false, so the
menu that owns it cannot be opened. Correct-if-enabled, not observed.
2026-09-22 21:54:39 +00:00
doxa-maps-88655c70
9533cca050 Land the prayer facet and the scrim helper
Some checks are pending
build-gate / build (push) Waiting to run
The prayer filter the Driver asked for, first in the dropdown order as he
specified. Keyed on people_praying, a live per-row pray-tools field already
read by three shipping consumers, and it reuses getPrayerLevel plus its LABELS
from the prayer-progress colour strategy rather than re-deriving the split --
so the filter can never disagree with the colour of the pin it filters to.

It is a real facet in config.facets with a get() accessor, which is the part
that matters: useDashboardShell builds FACET_ACC from config.facets alone and
filteredRows FAILS OPEN on an unknown key, so a facet declared only in an
adapter would render a working dropdown, land a chip, and filter nothing,
silently.

Also lands library/utils/viewportScrim.js, shared by DataTable,
SemanticTreeLegend and PosterDialog for the modal-scrim fix -- one helper
rather than six copies of the same workaround.

Committed by the coordinator rather than the authoring agents, same reason as
ad6da54: verified work sitting in the working tree is how the shell-scroll fix
was lost earlier today.
2026-09-22 21:50:32 +00:00
doxa-maps-88655c70
ad6da54674 Checkpoint the swarm's in-flight work
Some checks are pending
build-gate / build (push) Waiting to run
Five lanes had 465 insertions across 18 files sitting uncommitted, some for
15 minutes. That is the state that silently lost the shell-scroll fix earlier
today: a verified fix existed only in the working tree, a second agent's edit
to the same file overwrote it, and the reverted version got committed and
described as the fix.

Nothing here is finished and no card is being closed on it. This is a
snapshot so the work survives an overwrite, taken at a moment when every file
had been quiet 15s or more, so no agent was mid-write.

Verified coherent before staging rather than committed blind: template-literal,
mjs-parse, sfc-parse, no-orphan-gates, kpi-unit, composite-reg, script-flags,
quoted-scripts and backlog-status all PASS. check:synced-css fails on
PeopleGroupDetail.vue and doxa-simple-map.vue only -- the same two files and
the same counts as before the swarm started, both untouched by any agent, both
already carded.

Two untracked files included because both are load-bearing: internal/
check-facet-one-field.mjs is wired into package.json with a self-test, and
library/utils/viewportScrim.js is imported by DataTable, SemanticTreeLegend
and PosterDialog -- a shared helper for the modal-scrim fix rather than six
copies of the same workaround.
2026-09-22 21:42:18 +00:00
doxa-maps-88655c70
f00abe1e6c Parse the build scripts before the build runs them
Some checks are pending
build-gate / build (push) Waiting to run
Two different agents broke the build the same way within ten minutes: a
backtick inside a // comment sitting inside a template literal of generated
browser JS. The backtick closes the literal and the parser reads prose as code,
so it fails hundreds of lines from the edit -- one break reported at :386 with
the cause in a comment above it.

check:template-literal did not catch either, and that is not a bug in it: it
scans backticks inside CSS comments, the shadow-CSS shape. A // comment inside
a generated-JS literal is a different grammar. Proven by controlled negative
test -- injecting a backticked term into a // comment in generate-tree.mjs, a
file that gate explicitly lists, left it reporting PASS while node --check
failed immediately. The rule was right; the implementation covered a narrower
case than the rule stated.

Nothing parsed the 70 .mjs build scripts. check:parse covers inline scripts in
built pages and check:sfc-parse covers .vue, so the scripts the build is made
of were the one unchecked surface.

Uses the parser rather than a wider regex. A backtick is legal inside a string,
a regex, a nested template, or a comment inside a template; a pattern precise
enough to catch the bad case would false-positive on the legal ones, and a gate
that cries wolf gets skipped -- worse than the gap. The parser already knows
all of that.

Parses, never imports: these scripts read files, spawn browsers and hit the
network at import time, and a syntax check must have no side effects.

Runs as build step 2, beside check:template-literal, so a broken script fails
in a second rather than after minutes of compilation.

The failure message points at the real cause rather than the reported line, and
tells the reader to grep for every instance in one pass -- node --check reports
only the first, and each rebuild to surface the next costs minutes, which is
how one of today's breaks was being fixed one at a time.

Negative-tested side by side: exit 1 with "SyntaxError: Unexpected identifier
'TREE'" at generate-tree.mjs:369, while check:template-literal reports PASS on
the same file in the same moment.
2026-09-22 21:33:32 +00:00
doxa-maps-88655c70
f33f7afb1c The map must not make lostness look good — green ramp -> ashen clay
The choropleth ran the product ACCENT up the lostness axis. Bins went pale green ->
saturated green as the count of UNREACHED PEOPLE GROUPS rises, so the deepest, richest
green marked the places with the MOST LOST PEOPLE. And #92b195 is not an arbitrary hue:
it is --brand in 48 places, the selected-tab underline, the active-row marker — the
colour this product uses to mean GOOD/SELECTED.

DRIVER: "it does not make sense for the tiles to be green because green means good while
it is not good to be lost, and the darkest spots are where the most lost people are at...
it should not make it look green and nice that these nations are so lost."

The vision is explicit that FAR means "the farthest, 99.9%-lost places... eyes to see
where the Kingdom isn't, and be SENT there". The dark end of this ramp is not a high
value — it is where people are who have never heard. A palette tuned for comfort
anaesthetises the exact response the map exists to provoke. Beautiful is still the goal:
the artwork of a LAMENT, not of a garden.

=== TWO MEASUREMENT FINDINGS, both of which changed the answer ===

1. THE HANDED-OVER CANDIDATES FAIL. EMBER (said 7.04) and CLAY (said 7.03) were
   composited over PURE WHITE. This panel sets style `paper` -> OpenFreeMap Positron,
   whose background layer paints rgb(242,243,240) = #f2f3f0. On the ground that really
   paints: EMBER 6.86, CLAY 6.84 — both BELOW the 7:1 floor; old green 7.09, only just
   over. Confirmed rather than guessed: compositing over #ffffff reproduces 7.04 / 7.03
   / 7.29 exactly.

2. THE LEVER WAS NEVER DARKNESS. The brief framed this as structural — "a hue that reads
   as grave must go DARK, and darkness is what kills contrast". But WCAG contrast follows
   relative luminance, which LIGHTNESS dominates; CHROMA is nearly free. Solved across
   hue 30-50 deg at chroma 0.06-0.12, every restrained candidate lands at 7.15-7.20 — the
   muted ones measure the SAME as the saturated ones. "Lament, not fire alarm" cost
   nothing in contrast. Nobody had separated the two variables.

=== WHAT SHIPS: ASHEN CLAY ===
OKLCh hue ~36-41 deg (the deficit family), chroma held deliberately low (0.013 -> 0.060)
so it reads as ash and dried earth, not terracotta or alarm-orange. The 6-step scale runs
from the product SURFACE #f3f3f1 (= NO_DATA, so the ground is unchanged and lightness
still carries the ORDER) down to the darkest endpoint the pin floor allows on the real
basemap; the 5 DATA bins are the steps below the surface. Interpolated in OKLab so the
lightness intervals are perceptually even.

  NO_DATA #f3f3f1 11.91
  #eee3df 10.82 · #e8d4cd 9.82 · #e2c5bc 8.90 · #dcb5aa 7.99 · #d6a699 7.20

THE THREE CONSTRAINTS, each verified not asserted:
  1. navy pin #1a237e >= 7:1 on every bin at the real fill-opacity 0.78 — worst 7.20.
     PASS, and an IMPROVEMENT on the green it replaces (7.09) and on both candidates.
  2. no red-vs-green dependence. Simulated deuteranopia and protanopia: bin luminances
     stay strictly decreasing, so ORDER survives — read by lightness, never by
     discriminating a hue from green.
  3. accent #92b195 absent, and not even NEAR: it sits at OKLCh hue 148 deg and the
     nearest chromatic bin is 106 deg away. Checked as a hue DISTANCE, not an exact-hex
     match — a near-miss reads as a rendering artefact, which is worse than an obvious
     clash because nobody files it.

Also measured, and never checked before: buildPaint() repaints at 0.92 for in-filter
countries and 0.12 for dimmed ones. Worst pin at 0.92 is 6.53 (old green 6.40 on the same
path) and at 0.12 is 11.06. The 0.92 path is the true worst case for EVERY ramp including
the old one and sits below 7 — a PRE-EXISTING condition this change improves rather than
introduces, recorded so it is measured rather than rediscovered and blamed on the hue.

Second-order numbers re-measured rather than left stale: border #6d766e now 4.23 / 3.84 /
3.49 / 3.16 / 2.84 / 2.56; white ring vs ground 1.11-1.84 (its real job, dot-from-dot
separation vs the navy pin, is unchanged at 13.24); legend swatches 1.26-2.15 on white.

BUG-PINS-READ-AS-EYES-CREEPY verified still held: exactly one circle layer on rd-bubbles,
two colour bands (navy fill + white stroke), no third band, rd-bubble-halo absent. Not
touched, and no band reintroduced.

Repro: _qa-cycle/01-ideation-research/scratch-lament-ramp-contrast.mjs (all ramps, three
targets, both CVD sims), scratch-lament-ramp-solve.mjs (the solve), and
scratch-lament-ramp-swatch.mjs (renders the swatches so the choice can be looked at).
2026-09-22 21:26:24 +00:00
doxa-maps-88655c70
58ba402f87 Let smoke:gallery report what it can actually see
Some checks are pending
build-gate / build (push) Waiting to run
The gate could not pass on this box and it was not the product's fault. Three
defects, all in the gate:

Its default --url was localhost:5174 while `bun run dev` is `bun x vite`,
whose default is 5173, so the documented invocation failed with "is `bun x
vite` running?" against a dev server that WAS running.

It required a WebGL canvas on every map profile. This VPS has no GPU, so all
34 elements failed from an assertion the environment could never satisfy --
a gate that cannot pass, as useless as one that cannot fail, and it buried the
real finding underneath.

Removing that assertion was not enough: the map library still logs "Failed to
initialize WebGL", and every console error counted as a failure, so the same
environmental limit came back through another door.

Now 31 of 34 pass. GL noise is FILTERED, not suppressed -- the count prints on
every run ("3 WebGL page error(s) FILTERED — this box has no GPU"), because a
silent filter is indistinguishable from a gate that never looked, and this repo
has already shipped one of those. Anything that is not the GL-init message
still fails. EXPECT_CANVAS=1 on a machine with real GL filters nothing and
restores the canvas assertion.

Data 404s are now their own finding rather than a clause appended to the canvas
message, where they were only visible through a failure that was itself
environmental -- and where a map that painted fine would have hidden them.

That surfaced the real defect the gate exists to find: the archived /__gallery
page fetches a bare relative manifest.json, so it resolves against / instead of
/__gallery/. The studio at / is correct; it carries tree-base="/__plugin-gallery"
and smoke:studio passes in ~5s. Fixed separately.

Also corrects a usage comment that still said 5174 after the default changed --
the exact line someone copies verbatim.
2026-09-22 21:10:37 +00:00
doxa-maps-88655c70
b9a0cb040c Require a queryable status on every backlog card
Some checks are pending
build-gate / build (push) Waiting to run
Cards were being written with explanatory statuses -- "done — reconciled: …",
"blocked — Steps 1+3 DONE and re-verified…", "ready". The prose is valuable
and several of those strings carry measurements, but it made status
unqueryable: every count used status !== 'done', which is FALSE for
"done — reconciled: …", so finished cards kept counting as open.

Measured across the three phase backlogs: 130 of 395 cards carried a prose
status and 71 of those were already done. Reported open was 176 against a real
105. The P0 figure quoted every tick was 21; it was 14.

That is worse than an off-by-N because a backlog is a decision instrument. It
answers "what is left" and "is the rest human-gated", and inflating the open
count makes a drained board look stalled, which pushes toward manufacturing
work instead of holding honestly. The error also hides: every card reads
correctly to a human, because the prose says exactly what is true. Only the
aggregate is wrong.

Migration preserved every explanation in fields.status_detail -- 130 of 130,
none lost -- and normalised status to the enum. Six cards whose head-word was
not in the map were left alone and resolved by hand against reality rather than
guessed, since a wrong normalisation hides a card instead of counting it.

The gate does not require status_detail. Most cards need no explanation and
demanding one turns a real check into paperwork satisfied with filler.

Negative-tested: restoring one prose status fails by name and prints it.
2026-09-22 20:46:06 +00:00
doxa-maps-88655c70
e1baca1bbb Assert the cap-5 rail opens on the Big Picture, in the specified order
Some checks are pending
build-gate / build (push) Waiting to run
The Driver named the sequence: Big Picture, UPG-100, Prayer Progress, Gospel
Resources. Tab 1 is the landing surface -- the first thing anyone opening the
product SPA sees, and the opening step of the onboarding ramp. That ordering
was held only by the order of entries in NAV_MODEL and by comments explaining
why, with nothing failing if someone reordered them.

Easy to break silently: NAV_MODEL is also where sections, badges and
narrow-screen flags live, so it gets edited for reasons unrelated to ordering,
and a row moved while adding a badge produces no error and a SPA that still
works. It just opens somewhere else. That file's own comments record Big
Picture once being painted twice in the rail, from a one-row section that
re-declared a row below.

Asserts the RENDERED rail, not the source array. Parsing NAV_MODEL was the
first attempt and returned an empty list -- the entries do not carry the id
shape I assumed, and a regex over a 300-line commented array is guesswork
either way. The rail is what the user sees and what the Driver described, and
a source parse could pass while a rendering bug shows something else.

Order, not membership: checking that four names are present would pass on a
rail that opens on Gospel Resources. Prefix-matched so a row may carry its
subtitle.

Negative-tested: swapping the first two expected entries fails by name and
prints want versus got for every row.
2026-09-22 20:39:05 +00:00
doxa-maps-88655c70
5d12841fe7 Make the charts rail a tab strip, with Gospel Resources disabled
Some checks are pending
build-gate / build (push) Waiting to run
The Driver asked "a long time ago" that the chart become a tab so Gospel
Resources could sit beside it, with Charts selected by default. The rail had a
plain "Charts" heading.

Builds the half that is buildable. The container needs no gospel data and no
ruling; what the second tab ENCODES does -- he described it as the same facet
breakdown re-measured on a different axis, "people groups by their gospel
resources availability", and three decisions are still open: the official
lostness number, key-less versus a registered Joshua Project key, and the UI
shape. Guessing any of them ships a wrong picture that looks right, which is
this codebase's most expensive failure mode.

So the second tab renders DISABLED rather than hidden, carrying its reason in
the title attribute. A hidden tab is indistinguishable from an unbuilt one; a
disabled tab that names what it waits on is a status report the Driver sees
every time he looks at the rail.

The strip inherits .rd-h's exact typography -- weight 700, --fs-md, uppercase,
.5px tracking -- so the rail's visual weight is unchanged and this reads as the
same header gaining an option, not as new chrome. The selected tab uses the
accent underline the facet rail already uses for an active row, so one visual
language covers both rails. .rd-h is kept but was verified unused in this file
and everywhere else under research-dashboard/.

Verified the backtick rule that has broken this build three times: zero
backticks inside the useShadowStyles literal (chars 5459-18249); the one in a
JSDoc comment sits outside it. check:template-literal and check:sfc-parse pass.
2026-09-22 20:19:32 +00:00
doxa-maps-88655c70
9cae7fe16a Compare every silently-failing side effect, not just strategy keys
Some checks are pending
build-gate / build (push) Waiting to run
The gate caught one of the four things a baked composite must repeat. The
other three were left to luck, and one of them had already gone wrong: the
composite shipped mapbox-gl-rtl-text zero times while both leaf bundles
shipped it once, so Arabic, Hebrew and Persian labels rendered reversed in the
cap-5 shell. Arab World has 99 rows and Persian-Median 49, which makes that a
wrong name rather than a style issue.

Now compares createPinia, createAppI18n and installRtlTextPlugin alongside the
registerStrategies keys: whatever the leaf entry calls, the composite that
bakes its .vue must call too.

provide('profileModules') is deliberately excluded. It is the fifth side
effect and the only one that throws when missing, and it is also the only one
safe to omit, because a baked composite does not use ProfileLoader. Requiring
it would fail a composite that is correct. The rule is "repeat what silently
disappears", not "repeat everything".

Matched as calls -- name followed by "(" with the import line for that name
stripped first -- because an indexOf on the bare name reads the import instead
of the invocation, which is the trap that made the strategy parser return
nothing and report a confident empty result.

Negative-tested twice, and the first case reproduces the real historical bug:
removing installRtlTextPlugin() fails by name and prints both sides' lists;
removing createPinia() does the same. Pairs compared went 1 to 2.
2026-09-22 20:09:46 +00:00
doxa-maps-88655c70
e94253ed58 Route the north-star doc through the fast studio loop
Some checks are pending
build-gate / build (push) Waiting to run
develop-a-plugin-and-see-it-live.md is the path an inheriting agent follows
end to end, and it mentioned the plugin studio once while sending every
iteration through a full build. Step 3b already argues well that a green build
proves nothing about mounting -- it just pointed only at smoke:embeds, which
needs the build first.

Adds Step 3a: bun run dev, then bun run smoke:studio. Measured 4.7s for 29
tabs with 0 failed requests, against a full build of 43s to 4min on the same
box -- 19 sequential bundles, queued behind any other build, so the spread is
real. An order of magnitude either way, which is the point.

A first draft said "roughly fifty times faster", derived by dividing by a
remembered 4-minute constant rather than by what the build log actually showed
(43s). Replaced with the measured range. That is the same defect this session
flagged on the Forgejo card an hour earlier: a stored number that rots, quoted
as if it were evidence.

Also states why both steps exist rather than one replacing the other: the dev
manifest hands rooted refs and the built manifest hands relative ones, so a
base-join bug is invisible in one surface and fatal in the other.

And names the single staging environment explicitly, pointing at /__gallery as
the archived index kept only because the dev server imports its renderer --
so a reader who stumbles on it does not mistake it for a place to review work.
2026-09-22 20:06:04 +00:00
doxa-maps-88655c70
c959ed6323 Load the RTL text plugin from one place, and in the composite too
Some checks are pending
build-gate / build (push) Waiting to run
The cap-4 composite bakes research-dashboard's .vue directly, so it never runs
that bundle's entry file and has to repeat everything the entry does. It
repeated Pinia, i18n and the colour strategies. It did not repeat this one.

Measured in the shipped bundles:
  research-dashboard.js ..... 1 occurrence of mapbox-gl-rtl-text
  doxa-research-map.js ...... 1
  dashboard-plus-grid.js .... 0

So Arabic, Hebrew and Persian labels rendered reversed inside the cap-5 shell
and correctly standalone. Not cosmetic here: Arab World has 99 rows and
Persian-Median 49, and a reversed label is a wrong NAME for exactly the groups
this map is about. It failed silently -- no error, no warning, just mirrored
text that reads as a font problem to anyone who does not read the script.

Fixed as a shared library function rather than a fourth hand-copy, which is
what the composite-registration card asks for: one definition, two callers,
nothing to drift. Idempotent by construction, since Mapbox throws on a second
setRTLTextPlugin in a session and that throw is swallowed.

Four of the five entry side effects fail silently; only provide('profileModules')
throws, and that is the one safe to omit because a baked composite does not use
ProfileLoader. So "it renders" is no evidence the entry's work was done.

Found by the map-beauty agent, which I verified independently before acting
rather than taking the bundle counts on report.
2026-09-22 19:55:58 +00:00
doxa-maps-88655c70
7afbb9187c Assert a filter trigger keeps its width when an option is selected
Some checks are pending
build-gate / build (push) Waiting to run
.filter-dropdown was flex:1 1 160px with min-width:160px and max-width:240px
-- a range, not a width -- so flex-grow renegotiated the moment the selected
label appeared. The row grew 960 to 1294px and triggers reached 240px.

Not cosmetic. The selections row with Clear All sits underneath the filters,
so a filter that grows on selection re-flows the row below it and the band
stops being a fixed block. The stable width is what lets the centred stack
hold still.

Independently reproduced before gating rather than taken from the agent
report: 168px across all six triggers, identical after selecting the longest
option in each. The negative test then reproduced the broken widths
[240,240,240,184,171,219] -- the same array the original report cited, which
confirms that measurement was real.

Asserts the observable outcome, not the CSS. A grep for --dx-filter-w would
pass while a missing min-width:0 on the label still pushed the box open: as a
flex item without it the label is floored at min-content and refuses to
ellipsise. Both halves are needed, so the gate measures the width.

Selecting the LONGEST option is load-bearing. A short option fits inside the
old range too, so a probe that clicks the first option passes on a build that
still has the bug.

Two hosts, and an unmeasurable host fails rather than skips -- three defects
this week were invisible standalone and appeared only in the shell.

Negative-tested: restoring the flex range fails both hosts by name, naming the
before and after arrays and the total growth.
2026-09-22 19:46:49 +00:00
doxa-maps-88655c70
00bf294d41 Compact the pane-scroll comments, keeping both declarations
Some checks are pending
build-gate / build (push) Waiting to run
An agent collapsed the multi-line notes on .ps-pane and .vtl-host into
one-liners. Both declarations survive in both synced copies -- overflow-y:auto,
overscroll-behavior:contain, and --profile-min-h:0 -- and check:shell-scroll
still passes on the built tree: 2611px scrolled, grid on screen, no map-tab
scrollbar.

Committing straight away rather than leaving it in the working tree. These are
the exact two files where the scroll fix was silently lost last tick: it
existed only uncommitted, a gate passed against those bytes, and a second
agent's edit overwrote them before anything reached git.

Worth noting a false alarm in my own check: grepping for a formatting-
sensitive string reported one copy where there are two, because the compaction
left them with different whitespace. The semantics are identical, which is why
check:synced-css compares property maps rather than text.
2026-09-22 19:18:11 +00:00
doxa-maps-88655c70
35de2f443a Neutralise the dvh floor on hosted panes so a map tab keeps no scrollbar
Some checks are pending
build-gate / build (push) Waiting to run
Restoring the pane scrollport exposed the other half of the same fix, also
lost: check:shell-scroll passed the grid assertion and then failed with "a MAP
tab grew a scrollbar (131px overflow: 900/769)".

131 is exactly 900 minus 769. A hosted map ships
.profile-loader { height:100%; min-height:100dvh } and the two resolve against
different bases -- height against the pane, which sits inside a .ps-stage inset
by the studio chrome, and the dvh floor against the viewport. The floor wins,
the element is taller than its container, and overflow-y:auto faithfully draws
a scrollbar on a tab that should never have one. Making a pane scrollable is
only correct if it stays a no-op for fixed-viewport elements.

The floor is deliberate and must stay for the standalone leaf, where it fixes
the opposite bug: min-height:100% against a body with no definite height
collapses to 0. So it is neutralised on the hosted subtree instead.

Sizing the host is not enough, and this is the part that had gone missing.
.profile-loader lives inside the map's SHADOW ROOT, where a host-page selector
cannot reach it. Inherited properties can, so ProfileLoader reads
min-height:var(--profile-min-h, 100dvh) -- already in the file at line 282 --
and the pane now actually sets it to 0. Three comments described this
declaration; none of them was the declaration.

Not overflow:clip, which bounds the map correctly and also bounds the
scroll-stack composite whose content is 3380px against a pane-height host, so
the grid goes unreachable and the gate returns to 0px scrolled. One blanket
rule cannot serve both.

Both synced copies. Committed before re-verifying.
2026-09-22 19:09:38 +00:00
doxa-maps-88655c70
043b40ddbd Give the shell pane its scrollport back
check:shell-scroll went back to "wheel moved 0px, grid UNREACHABLE" with
2586px of grid mounted. The cause is not a later edit undoing a committed fix:
git history shows .vtl-host never had overflow-y:auto in any commit. Seven
commits, all `position:absolute; inset:0`.

The fix only ever existed in a working tree. An agent edited the file live,
the gate passed against those uncommitted bytes, and a second agent editing
the same file overwrote them before anything was committed. Every observation
was true and none of it was durable. Worse, 762953a was committed while the
tree was in the reverted state and described as landing the scroll fix.

Restored on .vtl-host and .ps-pane, both synced copies of each, and the reason
is now in the file rather than in a session transcript: the composite reports
scrollHeight 2838 against clientHeight 769 here, 2069px of mounted grid with
nowhere to go, while the document measures 900 === 900 so a wheel anywhere has
nothing to move. It is a host gap, not a plugin bug -- the same composite is
perfect standalone because its generated leaf gives the document the scroll.

The comment says the line has been lost once and points at the gate, because
`inset:0` looks like the simpler form and someone will reach for it again.

Committing before re-verifying, deliberately. A gate result is only as durable
as the bytes it ran against, and this bug is the proof.
2026-09-22 19:04:21 +00:00
doxa-maps-88655c70
762953a054 Land the swarm's dashboard work: colour, rails, cascade, scroll
Some checks are pending
build-gate / build (push) Waiting to run
Sixteen source files from five agents, all verified against source gates
before commit: template-literal, sfc-parse, kpi-unit, composite-reg,
facet-cascade, one-palette, script-flags, no-public-binds, no-orphan-gates and
quoted-scripts all pass. public-hygiene reports 0 findings.

Colour. The choropleth ramp is now derived by interpolating the product's own
tokens (surface #f3f3f1 to accent #92b195) rather than adding a sixth grey, and
that fixes a pincer the earlier research had not named: on the old grey-to-black
ramp the navy dot and the white ring failed at OPPOSITE ends, so no fixed pin
colour could win. Worst-case dot contrast moves 1.13 to 7.29. Second-order
breaks fixed with it -- the country outline would have measured 1.08-1.82 as
white on the pale fill, so it is #6d766e at 2.59-4.34, and the legend swatch
border.

Religion colours no longer key by array index. colorFor takes no index at all;
colour comes from the registry by facet plus value. All 31 distinct live
religion labels now match the map, 0 mismatches. Tree-rail grey rows 142 to 0
against the live 2,107-row API.

Cascade. Clearing a parent facet now clears its descendants, in the selection
bus rather than in any component, because every clear path already funnels
through setFilter/clearFilters -- a component fix would have fixed one button
and left four. The hierarchy is derived from config.facetRail[].drill, so it
cannot drift from the tiers the rail renders. Place turned out to be three deep
(region, block, country), not two.

Rails. Tree rows wrapping 18/66 to 3/66; the tree rail is now the wider of the
two at 296 vs 264, with the map cell unchanged because both pairs sum to 560.

Also lands the shell scroll fix and the facet-cascade gate with its committed
fixture -- 73 sampled rows, no network, so the assertion that reproduces the
empty map runs on a fresh clone instead of silently skipping.
2026-09-22 18:57:39 +00:00
doxa-maps-88655c70
ca39d27088 Check a composite registers everything the leaf it bakes registers
Some checks are pending
build-gate / build (push) Waiting to run
In the cap-5 shell the Region donut, country bars and Place tree rendered
entirely grey while the identical build was correct standalone. A cap-4
composite imports the sibling component directly
(dashboard-plus-grid.vue:18), which takes the COMPONENT and skips
research-dashboard/index.js -- the file that actually assembles the bundle:
Pinia, i18n, colour strategies, customElements.define. The composite
re-declares all of it by hand, a manual copy nothing keeps in sync, which is
how the strategies leg was lost. That file already documented this bypass for
i18n and fixed only that one leg.

It is silent by construction: getColorStrategy() falls back to language-family
for an unregistered mode instead of throwing, so the result is a plausible
wrong picture -- grey charts that read as a data problem rather than a wiring
one. Found by reading an image; every number passed.

The gate compares the leaf's registerStrategies keys against the composite's.
A composite registering nothing while its leaf registers nothing is fine: the
rule is about drift between two lists meant to agree. big-picture also bakes a
sibling and registers zero strategies, and that is correct -- neither it nor
people-groups-grid references colorFor, getColorStrategy or mapbox. A first
pass called it a second instance; measuring usage showed it is not one.

Four rounds to get working, each a real bug found by testing the wrong half:

- pre-stripping comments deleted 2,953 of 4,102 characters of
  research-dashboard/index.js, including the call being searched for, so the
  gate claimed "no composite bakes a sibling that registers strategies" --
  a confident claim about the repo derived from parsing nothing. Not stripping
  is also correct: the call is located by requiring "(", so a mention in prose
  cannot match.
- indexOf('registerStrategies') found the IMPORT line first and brace-matched
  its own braces.
- a non-greedy brace regex stopped at the first inner "}" and the key pattern
  missed quoted path keys.

Every fix was verified against dashboard-plus-grid, which survives stripping,
and never against research-dashboard, which does not. A comparison needs both
halves asserted before any fix is believed.

Negative-tested: dropping doxa-region from the composite fails by name and
prints both lists.
2026-09-22 18:55:01 +00:00
doxa-maps-88655c70
2f5cf655f4 Draw the pin as one circle with a white stroke, not three colour bands
Some checks are pending
build-gate / build (push) Waiting to run
The pins read as eyes. The cause is structural: two stacked circle layers
produced, outward from the centre at z1, navy disc r=1.6, white field r=3.4,
navy stroke 0.6px. Dark centre, light middle, dark rim -- pupil, sclera, iris
outline. A comment in the file already called it "a navy/white/navy sandwich"
without noticing what a sandwich of those bands draws.

Concentric rings sharing a centre are the pattern the visual system fuses into
one object, which is why a bullseye is named after an eye. The inner disc sat
at 47% of the halo radius at z1, the proportion that reads as a pupil rather
than a dot with a border, and worst when zoomed out -- where a world map lives.

The outer navy band is the specific culprit: it closes the iris. Removing it
leaves the same colours reading as an ordinary pin, so this deletes a band
rather than changing a colour. Both Driver rulings survive: blue dots, white
rings, one fixed colour at full opacity.

One layer with a stroke is also the documented Mapbox idiom --
circle-stroke-width draws outside circle-radius, so a single layer already
gives disc-plus-ring, and the old code hand-rolled that and collected a third
band for the trouble. Stroke now scales with zoom, the documented way to stop a
ring swallowing the fill at low zoom. Cartographic practice agrees: Axis Maps
and the ICA Commission both put casings on symbols that OVERLAP rather than a
global opaque halo, and Esri names halos the biggest cost in marker drawing
performance -- paid here on a second full circle layer per point.

Research and sources in
_qa-cycle/01-ideation-research/RESEARCH-pin-design-why-they-read-as-eyes.md

Not yet verified: no hardware WebGL here, so the appearance is unproven. The
pale end of the ramp needs measuring, since the outer band was what gave the
white ring a hard boundary on any ground, and a perception complaint needs the
Driver's eye rather than a contrast ratio.

Also removes the table header caption "Filtering or selecting above updates
this list". Its centring history is kept in a comment: it records a reversed
judgement, not a mistake -- v1 put it in a second header, it was moved to the
existing bar on request, and centred by absolute positioning because flex
spacers landed it 55px off centre. All correct, and it still had to go: it
narrates the one behaviour on this surface that needs no narration.
2026-09-22 18:46:16 +00:00
doxa-maps-88655c70
fd41b4a207 Check that a component's two CSS copies agree
Some checks are pending
build-gate / build (push) Waiting to run
A shared component declares the same selectors twice: once in a scoped <style>
block for when it is imported as a Vue child, and once in a useShadowStyles
literal for when it is mounted inside a shadow root, where scoped CSS does not
reach. Both are load-bearing and the standing rule is that they stay
identical. Nothing enforced it.

library/components/PeopleGroupDetail.vue disagreed with itself on eleven
properties, and the shadow copy is a coherent DARK theme under a light-only
rule: .detail-header background white vs #16213e, .image-container #f5f5f5 vs
#2d2d4e, and .people-group-name color #333 vs #fff -- white text, legible only
on the dark background, so fixing the background alone would make the name
invisible. Six importers; the dark hex reached four shipped bundles.

It was invisible because it is a two-host bug: the component looks correct in
whichever host you tested. Fourth of that shape this week.

Three findings while building this, each of which had the gate reporting
success about something it never compared:

- a loose /<style[^>]*>/ matched the words "<style scoped>" inside a JS
  comment 43 characters before useShadowStyles( in SemanticTreeLegend.vue, then
  ran to the next real </style> and swallowed the whole shadow literal into the
  <style> text. Both maps read the same bytes, so that file could not fail on
  any of its 51 apparent shared selectors. Anchored to line start, since an SFC
  block starts at column 0. That also raised PeopleGroupDetail 8 -> 11.
- assigning m[sel] = props let the last declaration win, discarding every
  property that appeared only in a base rule when a media query redeclared the
  selector. Rules are merged now.
- comparing raw text reported comma-spacing as divergence: 5 findings for
  doxa-simple-map of which 2 were formatting. Compared as property maps, so the
  rule is about meaning.

Negative-tested by change rather than by exit code, which with pre-existing
failures proves nothing: fixing .detail-header takes PeopleGroupDetail 11 -> 10
and restoring it returns 11.

Standalone rather than in build:unlocked, because it fails on live defects
today.
2026-09-22 18:40:55 +00:00
doxa-maps-88655c70
a11ec14852 Print each KPI percent sign once
Some checks are pending
build-gate / build (push) Waiting to run
The dashboard rendered "99%  % UNREACHED" and "1%  ADOPTED %". kpiText returns
k.value unchanged for kind:'pct' and that value already carries its percent
sign, while the labels in dashboard.config.js carried a second one.

Each half is correct alone. A label reading '% unreached' is a reasonable
label; a formatter returning a ready-formatted percentage is a reasonable
formatter. Only the composition is wrong, and neither file mentions the other,
which is why it was found by reading the rendered screenshot rather than by
reading either source.

check:kpi-unit asserts no kind:'pct' label contains a '%'. The value owns the
unit because it is produced by the formatter that knows the number; the label
says what is being measured.

Parses the config as text rather than importing it -- the research-dashboard
module graph reaches library/colors/_registry.js, which uses import.meta.glob
and throws under plain node. Text parsing keeps it hermetic enough for
build:unlocked. Comments are stripped first, because the config now documents
this defect right above the fixed line and a naive scan fails on the
explanation of the fix.

It also fails when it finds no pct entries at all, rather than printing a
green PASS: if the config is refactored so the parse stops matching, silence
would read as success forever.

Negative-tested: restoring either label fails by name with exit 1.
2026-09-22 18:28:53 +00:00
doxa-maps-88655c70
8609de969e Scan all of library/ for backticks in shadow-CSS comments
Some checks are pending
build-gate / build (push) Waiting to run
This gate's own header records fixing exactly this bug once already: it walked
plugin-designs one level deep, missed the shadow-CSS components under
<bundle>/src/components/, reported PASS, and the build broke with a misleading
Babel error far from the edit. The note ends "it was wrong about WHICH files,
which is the more dangerous failure because it reports success."

The other half of the list was never fixed. library/ stayed a hand-written
list of three .vue paths while 17 files under library/ carry a
useShadowStyles literal. Injecting a backtick into a CSS comment in
library/components/hints/PinHint.vue left this gate green AND check:sfc-parse
green -- so the documented failure mode was reachable in 36 unscanned files.

Now walks library/ the same way plugin-designs is walked: 47 files scanned
becomes 84. The injected backtick fails by name at PinHint.vue:236.

A hand-maintained file list decays silently, because nothing announces that a
new file should have been added to it. That is what makes this worse than a
wrong rule -- the rule was always right and was simply never shown the file.

The status line also hardcoded "+ 4 library/internal" and so under-reported
coverage while sounding precise. Counts are now derived from the file list.

Noting a wrong turn for the next reader: the first negative test used a plain
string replace and hit the first match at line 140, which is outside any
literal, so the gate correctly ignored it and I briefly read that as the fix
not working. PinHint has two literals; the target was in the second.
2026-09-22 18:25:18 +00:00
doxa-maps-88655c70
5e6bed7d7a Hand the wheel back when a scrollable ANCESTOR owns the scroll
Some checks are pending
build-gate / build (push) Waiting to run
Mapbox calls preventDefault on every wheel over its canvas. The panel already
knew that and disabled scroll-zoom when the page could scroll -- but it asked
whether the DOCUMENT scrolls:

    canPageScroll = document.documentElement.scrollHeight > innerHeight + 4

Inside the cap-5 shell the document is pinned to one screen and an ancestor
ELEMENT owns the scroll. Measured on the dashboard-plus-grid tab at 1600x900,
walking the composed tree from .pgg-root upward:

    div.stl          oy=visible  ch=769  sh=2838  over=2069
    section.ps-pane  oy=auto     ch=769  sh=2838  over=2069   <- real scrollport
    div.ps-stage     oy=hidden   ch=769  sh=769
    document         sh=900      ch=900                       <- "nothing scrolls"

So the condition computed false, scroll-zoom stayed enabled, and the map ate
every wheel with 2069px of grid mounted below the fold.

Proven at the event level rather than inferred: the wheel at (800,600) lands
on canvas.mapboxgl-canvas, and .ps-pane.scrollTop stays 0 after a real
mouse.wheel at both y=600 and y=860, while a programmatic scrollTop = 800
moves it to 800. The scroller was never broken; the gesture was consumed
before it arrived.

That is why three earlier fixes to the containers were each correct and
changed nothing -- they made the page scrollable, and the wheel still never
reached it. The honest question is "is there a scrollable ancestor", not "does
the document scroll"; the document is one possible answer and in an embedded
host it is the wrong one.

The ancestor walk hops shadow boundaries via parentNode.host, because
parentElement stops at one and every plugin here is a compiled custom element.

Browser verification still pending: the build queue is draining several
agent-requested rebuilds, so check:shell-scroll has not yet run against a
settled tree carrying this change. The fix is confirmed present in the
minified bundle.
2026-09-22 18:18:22 +00:00
doxa-maps-88655c70
68c1686cf5 Gate that map, legend and charts share one palette
Some checks are pending
build-gate / build (push) Waiting to run
The dashboard aggregations module used to hold two private palettes the map
had never seen, and picked chart colours with
CATEGORICAL[i % CATEGORICAL.length]. Two failures came out of that: near-miss
regions (Africa #e74c3c in a chart, #ff2e2e on the map) and religion coloured
by array index. No religion key existed in the private table, so every
religion fell through to its position in a list sorted BY COUNT -- and counts
move when you cross-filter, so a religion changed colour as you filtered.
Islam and Hinduism swapped, Buddhism and Christianity swapped.

That is already fixed: both private palettes deleted, every hex now read from
the same strategy modules the map paints with. But the fix is a deletion plus
a convention, and nothing enforces a convention. The next person adding a
facet writes a lookup table because it is the obvious thing to do, and the
divergence returns with no error and no crash -- just two surfaces disagreeing
about what blue means.

Reads the BUILT bundles rather than the source because the registry discovers
strategies with import.meta.glob, which plain node cannot evaluate. That makes
it a test of the glob too: if the registry ever silently discovers nothing,
the bundles lose the palette and this fails, where a source-only check would
still pass.

Comments are stripped before scanning, because both consumer files describe
this defect at length in their headers and a naive scan matches the
documentation of the fix -- the same self-reference that made two gates report
on their own prose this week.

Negative-tested three ways: a reintroduced private palette, an [i % len]
lookup, and corrupting four of ten religion hexes in a built bundle, which
fails as a partial palette and names the missing hexes.
2026-09-22 18:00:51 +00:00
doxa-maps-88655c70
b2fc0e4dda Make the shell-scroll gate runnable, and catch orphan gates
Some checks are pending
build-gate / build (push) Waiting to run
verify-shell-scroll.mjs was written and negative-tested, then left with no
package.json entry and no importer, so nothing could ever run it again. It
also defaulted to localhost:8899 -- an ad-hoc port from an earlier session --
so it failed with ERR_CONNECTION_REFUSED for anyone else and read as a broken
gate rather than a missing server. Now check:shell-scroll, defaulting to
BASE_URL or 127.0.0.1:4173 like every other browser gate, and listed in
verify-browser-all's GATES array.

check:no-orphan-gates fails any gate-shaped file in internal/ that nothing can
invoke. The repo carded this once already; it recurs because writing a gate
and registering one are separate acts and only the first feels like work.

Not every unreferenced file is a lost gate, so the rule is not "everything
must be wired": theme-flash-probe is a dated diagnostic for two fixed bugs,
worth keeping as a reproduction recipe. It now carries an @one-shot tag saying
so, which makes the author state which kind of thing they wrote.

Two self-referential defects in the first version of that gate, both from
reading evidence out of documentation:

- it cited theme-flash-probe.mjs in its own header as an example, then grepped
  for importers, found itself, and declared the orphan reachable. The gate
  written to catch orphans made an orphan pass by mentioning it. A gate must
  never be its own witness, so it now excludes its own file.
- it matched @one-shot anywhere in prose, including its own paragraph
  explaining the tag, and reported "1 tagged" when nothing had been tagged.
  The tag must now be a marker at line start.

Negative-tested: removing check:shell-scroll from package.json fails by name.

The scroll fix itself is NOT done. contain:layout and .vtl-host overflow-y are
confirmed in the built bundle, and a real wheel gesture still moves the grid
0px with 2586px mounted and unreachable. The gate is what caught that; the
diff looked correct.
2026-09-22 17:55:43 +00:00
doxa-maps-88655c70
8e4884469b Make advertised script flags actually reachable
Some checks are pending
build-gate / build (push) Waiting to run
bun run appends arguments to the command string, so a script ending in an
inline # comment swallows them. Proven side by side:

  "node argtest.mjs"                    bun run _t1 --self-test -> ["--self-test"]
  "node argtest.mjs   # a comment"      bun run _t2 --self-test -> []

No error, no warning: the script runs in its default mode and exits 0.

Four scripts advertised a flag that could never arrive, and two of them are
--self-test. A --self-test IS the proof that a gate can fail, so running
check:facet-cascade --self-test printed the ordinary PASS and exited 0 --
which reads as "the negative test passed" when the negative test never ran.
The gate itself was fine: invoking node directly reverted the cascade and
failed 13 assertions correctly, including the real symptom (switching to
another language family leaves the map empty because the orphan still
filters). Only the invocation was broken, so the mechanism that certifies a
gate can fail was a gate certifying itself.

Each flag now gets a dedicated entry that hardcodes it, which also makes the
mode discoverable in bun run output instead of documented only in prose
nobody greps.

check:script-flags fails any script whose comment advertises a flag the
command cannot receive. Negative-tested: adding such a comment back fails by
name with exit 1.
2026-09-22 17:44:02 +00:00
doxa-maps-88655c70
64731f2f3e Document why a gate cannot import plugin source, and stop teaching public binds
Some checks are pending
build-gate / build (push) Waiting to run
Three docs still told a reader to start a server on every interface: the
reference recipe for the share round-trip, the quoted scripts block, and
app-wrappers.md. Agents followed those recipes faithfully, which is how twenty
publicly bound http.server processes accumulated on this box. All three now
bind 127.0.0.1. check:quoted-scripts caught the second one on its own.

Also adds the gate-authoring constraint that just broke a build. Plugin and
library source is written for Vite, not Node, and fails two ways: the @map
alias is unresolvable (hit first), and import.meta.glob is undefined in 18
files including 11 of the 20 plugin index.js. A new check:facet-cascade
imported the dashboard config, reached library/colors/_registry.js
transitively, and threw -- while already wired into build:unlocked, so the
build failed for a reason unrelated to the code being built.

The counts in that section were measured, not estimated. A first draft said
"every plugin index.js" and the real number is 11 of 20; it also blamed the
glob when the first error is actually the alias.

States the two rules that follow: build:unlocked is only for gates that run
clean in plain Node with no browser and no network, and a gate gets run before
it gets wired in -- a broken gate in the build chain is worse than no gate,
because it goes red for the wrong reason and trains people to skip the step.

Records the other box constraints that have each produced a wrong answer:
Node 20 (no fs.glob), bfs find rejecting relative -newermt, no hardware WebGL,
and bunx not existing.
2026-09-22 17:40:11 +00:00
doxa-maps-88655c70
0296b03aa7 Bind local preview servers to loopback only
Some checks are pending
build-gate / build (push) Waiting to run
serve used --bind 0.0.0.0, and every browser gate starts it. A live audit of
this box found nineteen publicly bound http.server processes on 4820-4970 plus
one on 8899, all rooted at public/, with curl to the box's public IP returning
200. Nobody noticed, because a listening socket produces no output and no
failure: the gate it served passed and the process outlived it.

The standing rule "keep the box closed" lived in prose. The behaviour lived in
a flag. Prose cannot constrain a flag, so the rule was never violated by a
decision -- only by a default nobody re-read.

Loopback is sufficient for the whole suite: verify-browser-all,
check:a11y-button-names, check:nav-reachable and smoke:share-all already
default to 127.0.0.1. The wide bind bought nothing.

check:no-public-binds fails any script with an explicit all-interface bind, a
bare vite --host, or an http.server with no --bind at all (its default is also
all interfaces, just less visible in review). It reads the repo rather than the
machine's live sockets, because a socket check would pass or fail on whatever
happens to be running and could not be fixed by editing anything here.

Negative-tested: restoring --bind 0.0.0.0 fails by name with exit 1.
2026-09-22 17:32:33 +00:00
doxa-maps-88655c70
a1005869ce Anchor the filter dropdown in every host, not just standalone
Some checks are pending
build-gate / build (push) Waiting to run
The menu is position:fixed with viewport coordinates. That resolves against the
viewport only while nothing establishes a containing block for fixed
descendants. VerticalTabsLayout -- the cap-5 shell that hosts every plugin as a
tab -- sets contain:layout paint on .vtl-content, inside .vtl-main{left:256px}.

Measured at 1600x900, same component, two hosts:
  standalone research-dashboard ...... dLeft=0   dTop=4    six of six
  inside plugin-studio dashboard tab . dLeft=256 dTop=111  every one

256 is the rail width and 111 the header height. Reported as the menu landing
"in a random spot further down to the right"; it was never random.

An earlier probe measured six triggers at four viewport widths and passed, so
the card was closed while the bug was still there. Every number was accurate.
They were answers about a surface the user never looks at -- widening across
more widths could not have found it, because the variable was the host.

Fix measures the containing block instead of assuming it: a fixed probe at 0,0
inserted in the menu's own parent reports the origin the menu resolves against.
When nothing captures, that origin is 0,0 and the standalone numbers are
unchanged bit-for-bit.

Not fixed by dropping contain from the layout: it is load-bearing there,
stopping a rail collapse from reflowing a heavy mounted map.

check:fixed-anchoring measures both hosts and treats an unmeasured host as a
failure. Negative-tested: reverting the subtraction fails the studio host with
dLeft=256 while standalone still passes -- it fails exactly where the old probe
passed.

Also cards a second symptom of the same cause, not yet fixed: fixed-position
modal scrims written inset:0 stretch to the content pane instead of the
viewport, leaving the rail clickable behind an open modal.
2026-09-22 17:22:23 +00:00
doxa-maps-88655c70
e1d27cb476 Record which kind of verification closed each card
Some checks are pending
build-gate / build (push) Waiting to run
A card was closed on an agent measurement while the bug was still visible to
the Driver, and the card had no field in which that disagreement could live.
The board said done and the product said broken, and nothing in the data knew.

Adds fields.verification (agent|human|both) plus verification_kind
(visual|state) and a conflict flag, back-filled across 179 closed cards, and
a gate that refuses an anonymous close.

The gate does not require human sign-off. Gating the board on human review was
tried and it stalled. What it forbids is losing the distinction, so the
question "which of these did a human actually look at?" stays answerable.

The visual/state split encodes the asymmetry the Driver named: human
verification is more reliable on visual bugs, agent verification on non-visual
ones. A headless probe reads geometry, not perception -- it can measure a menu
at dTop=4 and still be blind to it feeling wrong. So a visual card closed on
agent evidence is a hypothesis; a state card closed on agent evidence is a
result. The counts are reported separately.

Negative-tested three ways: stripping a tag, claiming human verification with
no quote, and marking a conflict card done each exit 1 by name.
2026-09-22 17:00:26 +00:00
doxa-maps-88655c70
7a648f1e1c Rebuild so a fresh clone carries the tree-expand fix
public/ ships to every clone, so build output lagging source means a pull gets working
code and a stale bundle.
2026-09-22 16:48:49 +00:00
doxa-maps-88655c70
9f1f657209 Expand a branch under its own parent, even when a filter is active
Some checks are pending
build-gate / build (push) Waiting to run
Expanding a node that had just been selected pushed its children to the bottom of the
tree instead of under the node. The Driver isolated it precisely: clicking only the
arrow always worked, and clicking the row first and then the arrow did not — which
pointed at the filtered path rather than at the expand logic.

The re-admission of filtered-out rows walked the unfiltered baseline and appended
anything the baseline did not recognise. A child revealed by expanding a selected node
is exactly that: it never existed in the unfiltered snapshot, so it had nowhere to go
but the end. With no filter active that path is never taken, which is why the arrow
alone behaved.

It now walks the current rows and splices the missing ones back at their anchors, so
live order is preserved and the baseline is only consulted for what is absent.

Verified three ways: expanding alone puts the first child at index one; selecting then
expanding also puts it at index one, one level below its parent; and a filtered rail
still keeps its shape, going from sixty-six rows to sixty-seven with thirty-five dimmed
in place and none lost. A ten-step random walk left one hundred and fifteen selectable
rows and no page errors, and Clear All returned the rail to sixty-five.
2026-09-22 16:45:03 +00:00
doxa-maps-88655c70
d79f01590f Put the CSV header on the first line so the export opens as a table
Some checks are pending
build-gate / build (push) Waiting to run
The export carried its provenance comment above the header, and every spreadsheet
importer treats line one as the header — so the scope sentence became the column names
and the real header became the first data row. A single-column line above a sixty-eight
column table is what "glitched" looks like on screen.

Three other explanations were plausible and all three are wrong, measured before
anything changed: the file is comma-separated, not tab-separated, so the tabs in the
report were terminal rendering rather than bytes; the escaper is genuinely RFC-4180, and
embedded commas, quotes and newlines all survive a strict parser round trip; and
injection-prefixed cells were already neutralised. Only the position was wrong, and
chasing the other three would have produced churn and no fix.

The provenance line moves to the end rather than away. It records which filters produced
the file, which is the difference between a dataset and an anecdote, and a trailing
single-column row lands as one populated cell instead of destroying the column mapping.
The field counter that read line two moved with it — left alone it would have counted
the first data row, usually the same number, so it would have looked right and been
wrong.

A new check parses the export the way a spreadsheet would, which no existing gate did:
forty-six checks and not one read the file. It extracts the shipped escaper rather than
restating the rule, so it cannot agree with itself while the product disagrees.
Negative-tested by putting the comment back on top.
2026-09-22 16:30:36 +00:00