393 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8550e58fbe |
Stop the browser-gate aggregator's description from counting
package.json described verify:browser-all as running "ALL 7 browser gates". The GATES array in internal/verify-browser-all.mjs holds nine. Two were appended below the count — check:fixed-anchoring and check:shell-scroll, both added the same week — and the prose was never touched, so a reader who ran the command and tallied seven would have concluded two gates had not run. This is the same shape as the runbook's "all 6 DX guards PASS" corrected earlier today, and the fix is the same: stop stating a number that lives in another file. A prose count of a list that grows elsewhere rots on the next append, and it rots silently, because nothing compares the two. The description now says it runs every gate in the array and explains why it refuses to give a figure. The script already prints its own GATES.length at runtime, which is the number that cannot be wrong. Also repairs a garbled sentence in the same string — "Only smoke:share the `verify:browser-all:plugin-gallery` script mode is out of scope" appears to be two edits collided. It now says plainly that the plugin-gallery mode is separate because it needs the vite dev server. One line changed; check:quoted-scripts and check:doc-scripts both pass, which matters because those gates compare this file against the docs that quote it. |
||
|
|
174300c211 |
Make the shipped publish block abort, because the version that could not took the catalog down
The golden-path doc's Step 4 gave the reader a copy-pasteable publish sequence
with no `set -e`, and its verification step was a COMMENT — "# 3. VERIFY every
manifest bundle's <name>/<name>.js resolves BEFORE push". A comment does not
verify anything. Pasted as written, the block clones, rsyncs with --delete, does
nothing, and pushes.
That is both halves of an incident this project already paid for, recorded as
AP-publish-verify-not-gating-plus-midchurn-rsync in the deploy-cycle notes: the
verify was chained into a compound command so a failing check still fell through
to the push, and rsync --delete ran from a source a concurrent build had just
emptied, faithfully mirroring that emptiness into the catalog. The live maps
catalog went to 404.
Step 3 is now three real commands — check:sri, check:embed-targets, and a git
diff that aborts if anything outside app/doxa-maps changed — and `set -e` is the
first line so any of them aborts before the push. Added `bun run check:settled`
as step 0, because `clean` empties public/doxa-maps at the START of every build,
which is what makes --delete catastrophic rather than merely wrong: it deletes
the live tree to match your momentarily-empty one.
The doc was already right to stay narrow ("the fuller runbook with every gotcha
lives in the project's deploy-cycle notes"), and it already carried postBuffer
with a good explanation. `set -e` is not a gotcha to defer — it is what makes
the four-step block safe at all, so it belongs with the block it protects.
Verified rather than assumed: all three commands exist in package.json, the
block extracted on its own passes `bash -n`, and the exclude-pathspec abort was
run against a real diff — it correctly passes when the diff is scoped and would
fire otherwise. Five gates pass.
Also corrected the runbook's own first instruction while checking it, in the
_qa-cycle notes (not a tracked file). It read "EXIT 0 + all 6 DX guards PASS".
No subset called "DX guards" exists anywhere in the repo, and the count was
wrong besides — build:unlocked chains 32 steps including 25 distinct check:*
gates, and that set grew by five in one flight. A hardcoded gate count is a
snapshot of a moving target. The chain is &&-joined under set -e, so EXIT 0
already means every step passed; that is the assertion that stays true as gates
are added, and the note now says so and gives a one-liner to list the current
set rather than copying it.
|
||
|
|
83ba336d8c |
Say what the Tauri build actually produced, because it produced something
Two docs understated deploy method 5. README-SECTION-3 headed the section "built + contract-proven; no binary emitted", and develop-a-plugin closed its table with "2, 4 and 5 are scaffolded but no binary/install has been shot". Measured: `cargo tauri build` has run and its outputs are on disk. deployment/src-tauri/target/release/doxa-maps-desktop is a 67,379,880-byte ELF 64-bit LSB pie executable, and target/release/bundle/deb/ holds a 60,554,898-byte .deb. Eight source files are git-tracked under deployment/src-tauri/, and check:tauri exits 0. So "no binary emitted" is false. What is true is narrower and worth stating precisely: no window has been opened, because this build box is headless with no Xvfb, so nothing has confirmed the app paints rather than launching to a blank frame — the exact soft failure the section below already warns about. "Not shot" means nobody has watched it run; it does not mean nothing was produced. The closing sentence now separates the three built-not-shot methods, because what is missing differs for each: no APK for mobile, no captured install for WordPress, and for Tauri artifacts that exist but have never been launched. The bodies of both sections were already accurate and are untouched. Only the status labels were wrong, which is the part a reader skims. Found while reconciling the ideation sources. The 5-cap spec (01-ideation-research/coolest-ideation/5-cap-5-deploy.md, 2026-09-22) is CURRENT and correct on all five rows — verified each: 43 embed.iframe entries in the manifest, 19 SRI hashes, capacitor.config.json with check:mobile green, the WordPress PHP with check:wp-embed green, and the Tauri artifacts above. The /plugin-designer skill is now the stale one, and the direction of the error has reversed. It says method 5 is "ASPIRATIONAL — not scaffolded. No Tauri config/dir exists in the repo", and instructs the reader not to repeat the spec's claim. That catch was CORRECT when written: the spec then said "proven POC", which was unbacked. The spec has since self-corrected, carrying a dated 2026-09-22 block reading "BUILT, not shot ... only the window LAUNCH is unverified", which matches the files. The skill's correction froze at the old reading and over-swung from overclaimed to nonexistent. That is a hazard worth naming: a correction record — "document X is wrong about Y" — becomes wrong itself the moment X is fixed, and nothing re-reads it. Correction notes need the same staleness discipline as the claims they correct. The skill lives in the session config directory with no git repo, so the fix is surfaced on BUILD-PLUGIN-DESIGNER-SKILL-MEANS-REAL-WORK as a single precise edit rather than made here. Seven gates pass, including check:tauri, check:capability-citations, check:capability-exemplars, check:doc-scripts and check:quoted-scripts. |
||
|
|
4b39abf126 |
Regenerate the library index during the build, so it cannot fall behind
check:library-index existed, was correct, and had been failing for about 19.6 hours. Nothing noticed because it runs only when a person types it: build:unlocked chains 24 gates and this was not one of them. check:no-orphan-gates passes on it, which is worth understanding rather than treating as a gap in that gate. It asks whether every gate is REACHABLE — "named in package.json or imported by something that is" — and check:library-index is named there. Reachable and wired are different properties. 64 gate-shaped files exist; 24 run on every build; the rest only run on request. The fix is to regenerate rather than to check harder. check:composite is also mtime-based and is in the chain, but it guards build output whose freshness depends on build ORDER — a real invariant only a human can resolve. reference-library-index.md is DERIVED from library/, so a staleness failure has exactly one remedy: run the generator. Wiring the check alone would fail builds and hand the author a chore; wiring the generator makes the staleness impossible, which is the only way the file's own header — "DERIVED — cannot drift from the code" — can be true. Both are now in the pre-build group, generator then check, beside the other source-only gates and before `clean`. Safe to run unconditionally: measured deterministic (byte-identical on re-run) and 237ms. The check is kept after it so a generator that silently writes nothing still fails the build rather than passing quietly. Updating package.json made check:quoted-scripts fail, which is the gate doing its job, and it surfaced that the quoted copy in README-SECTION-3 was ALREADY stale before this change: it omitted check:tab-radius, check:synced-css, check:wp-embed and check:doc-scripts. An omitted step in a quoted list is invisible — there is no wrong character to notice, only a check the reader believes is not in the chain. The doc now matches what runs. Verified by a full build: the generator ran, the check passed, vite built every bundle, exit 0. The index is not dirty afterwards, which is the determinism claim holding inside the real chain rather than in an isolated test. Five lines in the build log match /error|FAIL/ and all five are gate DESCRIPTIONS containing those words, not results. |
||
|
|
18b3366c2b |
Regenerate the library index, which had fallen a day behind
check:library-index was FAILING: the derived menu was 70,718 seconds — about
19.6 hours — older than library/composables/useMapFly.js, which this flight
edited to give fitBounds an explicit duration. The file's own header says
"DERIVED — cannot drift from the code", which is only true of a file that gets
regenerated, and nothing had regenerated it.
The regeneration added one entry, and it is not cosmetic: viewportScrim.js was
missing from the index entirely while being imported by six files —
PosterDialog, SemanticTreeLegend, PosterWall, DataTable, LegendTools and
my-upg-100-list/poster-wall. A real, widely-used utility that an inheritor
reading the library menu could not discover. 85 reusable items now catalogued.
Measured the wider hygiene question while here, since the standing work list
asks for non-reusable components to be demoted out of the shared zone: of the 82
items the index advertises, exactly ONE has zero code importers —
useMapSelection.js. The other 81 are live. So the shared library is in good
shape and there is no demotion sweep to run.
The first measurement said fifteen components were unused, and it was wrong in a
way worth recording. It counted importers among BUNDLES only, so anything
imported by a sibling library component read as dead — PosterPreview.vue has
five importers and would have been demoted. Counting importers anywhere, not
just in plugin-designs/, is what reduced fifteen candidates to one.
useMapSelection is left in place deliberately, because removing a file from the
shared zone is a different kind of change from regenerating a derived doc and
deserves its own commit. It is carded instead. Worth knowing before that
decision: it is 192 lines, unchanged since the initial commit
|
||
|
|
34ef223619 |
Name the UPG-100 legend's tabs, instead of shipping the placeholder
The legend read "GENERATION 1 / 2 / 3" above a list of world regions. A UUPG
list has no notion of a generation, so the label described nothing and invited
the reading that the tabs filter the map by some cohort. They do not.
The cause is a fallback, not a typo. SemanticTreeLegend derives its tab strip
from the `tabs` prop and, where an entry is missing, substitutes
`{ id: 'gen'+(i+1), label: 'Generation '+(i+1) }` so the strip has something to
render. This bundle passed no `tabs` at all, so all three were placeholders.
country-posters and doxa-research-map both pass real tab sets; doxa-simple-map
passes an explicit null. This was the only bundle relying on the fallback.
What a tab actually is: a DEPTH LEVEL of the tree, which is why selecting a node
jumps the strip to that node's depth. The three levels were read out of
data/legend-tree.js rather than guessed — regions are assembled by grouping
leaves under regionFor(), zones are the middle nodes (Frontier Asia (uncovered),
India, Deaf (affinity bloc A017)), and the leaves are the individual groups each
dot belongs to. Hence Region / Zone / People Group, each with an `info` note in
the shape AFFINITY_TABS and REGIONS_TABS already use.
The ids are bundle-scoped, and that was measured rather than chosen on style.
tabLabel() resolves `tabs.<id>` against the shared i18n catalog FIRST and only
falls back to the English label when no entry exists, so a tab id is a global
namespace. The first version used id:'region' and the legend rendered "WAGF
Region" — library/i18n/locales/en/tabs.json already maps that key to the
country-posters taxonomy. id:'group' collides the same way and would have been
right here only by coincidence, since the catalog means the ROP-2.5 sense. The
prefixed ids own no catalog entry, so the labels in this file are what render.
Desktop only, deliberately. LegendMobile computes
`hideTabs = nodes ? !tabs : _isFlatTabMobile`, so with nodes supplied and no
tabs the mobile sheet hides the strip entirely — it never showed the
placeholder. Passing tabs there would reveal a strip that is currently hidden,
which is a design change rather than a bug fix.
Verified by driving the built bundle in Chromium and reading the three chips
back: Region / Zone / People Group, each carrying its info tooltip, where the
same probe returned "WAGF Regioni / Zonei / People Groupi" before the id fix.
Then the README shot was regenerated and read as an image — the strip renders
REGION / ZONE / PEOPLE GROUP and the column header now says REGION where it said
GENERATION 1. Element count moved 311 -> 314, the three info buttons.
Eleven gates pass, including sfc-parse, template-literal, sri, composite,
one-palette and synced-css.
Found while verifying BUG-UPG100-DOTS-HARD-TO-SEE, which is genuinely fixed: the
dots carry a solid dark stroke that frames them against both the pale zone fills
and the white basemap, confirmed by reading the image rather than the card field.
|
||
|
|
bd504063a1 |
Shoot the README with the token the bundles ship, and make the fidelity gate ask
Two related fixes, both about a measurement that had outlived the thing it
measured.
The shot harness fetched its Mapbox token from doxa.life/api/maps/token on every
run. That token belongs to the Mapbox account `doxalife` and is URL-restricted
to doxa.life, which is why the harness also had to spoof a Referer header to get
any tiles at all. Meanwhile the built bundles carry a different token entirely —
`gosystems`, baked in from vite.doxa-maps-build-config.json — and that one is not
restricted. Measured with no Referer: mapbox-streets-v8, country-boundaries-v1
and bathymetry-v2 all return 200 on the baked token and 403 on the fetched one.
The harness was doing extra work to use the weaker of the two.
It now reads the build config first and keeps the fetch as a fallback. Beyond
simply working, a README image is evidence about the SHIPPED artifact, so it
should render with the credential the artifact ships with; rendering with a
different token means the picture stops proving what a reader downloads. The
local read is also deterministic — the network path degraded silently, setting
the token to empty when doxa.life was slow, after which every shot was taken
token-limited and still written to disk.
check:shot-fidelity had the same problem one layer up. It fired its tileset
findings whenever any file referenced a mapbox:// tileset, and printed
"Referer doxa.life -> 200 · 127.0.0.1 -> 403 · no referer -> 403" as though it
were a live reading. It was one, on 2026-09-21, against the token that shipped
then. After the token swap in
|
||
|
|
7311434a71 |
Commit the built bundles for the ten fixes that are already serving live
The working tree carried a rebuild of all 19 bundles that was never committed. Ten source commits had landed since the last build output went in: the page-scroll layout that makes the grid reachable, the eighth filter and its wrapping row, the tab radius token, the studio's remembered tab, the poster print window, the fitBounds duration, the configurable pin colour, and the synced-CSS gate repair. These are not unshipped fixes. check:catalog-currency fetches all 19 bundles from the live origin and reports 19 current / 0 stale, and research-dashboard.js compares byte-identical at 413021 bytes against what nuxtbase.app serves. The publish happened; only the git record of public/ lagged. Committing it closes the gap so the next reader of this history does not conclude the tree is ahead of production when it is level with it. Worth recording how the verification nearly went wrong. Checking the live bundle by hand with LIVE=$(curl ...) gave a hash that shared not one character with the local file, which reads as a clear mismatch. Command substitution strips trailing newlines, so the fetched copy was 413020 bytes against 413021 on disk, and one absent byte changes every bit of a sha384. The single-byte size difference was the only visible clue. Fetching to a file instead makes them identical. The gate and the ad-hoc check disagreed, and this time the gate was right. The rule that has been serving here is to suspect a gate when it contradicts plausible reality; the sharper form is to suspect the newer, less-exercised instrument. The gate fetches through a Node buffer and had just run that path nineteen times. The curl pipeline was a minute old. Nineteen gates pass on exit code, including composite freshness, SRI consistency, catalog identity and catalog currency. Grading them by their last printed line does not work and briefly showed ten false failures: these gates print a verdict and then several lines explaining what they do not cover, so the last line is teaching prose, not a result. Exit status is the contract the build chain already uses. Also drops two untracked scratch probes from 2026-09-22 that have served their purpose. |
||
|
|
239a983471 |
Say which embed targets we actually verified, and which we only expect to work
The hand-off doc said "Verified places a pasted embed works" and listed seven platforms. The repo holds an artifact for exactly one of them: check:wp-embed renders the real WordPress shortcode against the real built manifest, asserts it emits the same SRI hash the catalog ships, and carries a negative case for a malformed integrity value. Squarespace, Wix, Google Sites, Notion and Trello have no artifact anywhere -- they are third-party platforms nobody here has an account on. The platforms are plausible and the mechanism is identical everywhere: each accepts an HTML snippet or a URL embed over https, which is all the embed code needs. So the list stays, split into what we proved and what we expect. The word "verified" was the part that overreached, and in a doc an inheritor reads as ground truth it means "someone tried this" when nobody did. Worth recording how nearly this hid: grepping the repo for "Squarespace" returns nineteen files and "Wix" twenty-two, which reads like evidence. They are bundled copies of this very paragraph inside built .js files. The claim was confirming itself -- the same shape as a pgrep that matches its own command line. |
||
|
|
ad223d3b1a |
Read the pin colour and stroke from config instead of hardcoding them
The selection ring beside these already read cfg.map.selectedColor while the pin fill and its stroke were literals, so half the pin styling was parameterised and half was not, for no reason. Both now follow the same shape: a config read with the current value as the fallback. This matters beyond tidiness. The heartbeat threshold was made a variable so the dashboard can point at a dataset that is not these 2107 people groups, and a different corpus can want a different pin -- the orange was chosen against THIS choropleth, and its legibility is a property of the pair, not of the colour. Leaving the pin fixed would have made the dashboard reusable in its data and fixed in its palette. Verified: with no config the built map reports circle-color #ef6c00 and circle-stroke-color #ffffff, byte-identical to the literals they replaced. Not verified: that an override takes effect. Two runtime injections left the defaults in place, which is expected rather than broken -- like SEL_COLOR, this is read once when the element upgrades, and I could not prove my injection beat that moment. The config channel is proven by check:heartbeat, but statically, never through a live attribute. The comment says so rather than implying a capability nobody has exercised. The white stroke is noted as load-bearing: it carries the pin over the dark bins where the orange falls to about 1.03 contrast, and it is what stopped the pins reading as eyes. A host that sets it to the fill colour brings that back. |
||
|
|
c07098bd77 |
Give fitBounds a duration, because FLY_CONFIG alone leaves it instant
Selecting a row jumped the camera instead of easing to it. The shared helper spread FLY_CONFIG into the fitBounds call, which looks like it animates and does not: FLY_CONFIG carries speed and curve, which flyTo understands and fitBounds ignores. Without an explicit duration, fitBounds is an instant cut. The flyTo paths in the same file were always eased, which is why the tree rows animated and the map did not -- different mechanisms, not one broken setting. Measured before: the camera moved 1.45 to 6 with two distinct zoom values across fourteen samples. After: it interpolates through 1.453, 1.806, 3.429. options still spreads last, so a caller passing duration 0 still jumps. That is why the default lives here rather than in FLY_CONFIG, which is shared with callers that must not inherit it. One thing this box cannot settle: whether 900ms FEELS right. Sampling with requestAnimationFrame returned six frames in 1.6 seconds -- SwiftShader software rendering paints at roughly 4fps here, so perceived smoothness is not measurable on this machine and my earlier "hard cut" reading at 90ms intervals was aliasing against a starved renderer. The easing is proven present; the duration is a judgement for a real browser. |
||
|
|
9fff7c4af2 |
Let the poster print-preview window actually open
The preview branch was unreachable for every user. window.open with `noopener`
returns null by specification -- severing the opener relationship is precisely
what the flag does, so there is no handle to hand back. The document.write that
builds the print sheet ran against that null, so every click fell through to the
fallback: the raw .webp in a tab, no print dialog. The card claimed it opened an
in-browser print preview with a dialog.
Measured in a default browser, no headless flags: window.open('','_blank',
'noopener') returns null, the identical call without the flag returns a usable
handle. I had attributed the null to this headless box last tick, and that was
wrong -- it is what the flag means, everywhere.
noopener stays on the fallback, which only navigates to a URL and never needs a
handle, so the protection is kept where it costs nothing. The window we write
into carries same-origin markup we generate, so the opener reference it regains
is our own.
Verified after rebuilding: the click now opens ONE window titled "Doxa UPG-100
poster — Northwest India" carrying the @page rule, where it previously opened two
and wrote into neither. That window then shows "Poster not available yet." from
the img onerror, which is correct and documented: the full-size posters are 42MB
and deliberately not in the default tree, so the preview degrades honestly
instead of hanging. Set bundlePosters or point assetsBase at a host serving
posters/ to see the image.
|
||
|
|
5b3618d0d6 |
Check that the CLI doc still quotes package.json correctly
Applying last tick's lesson -- grep for the class, not the instance -- found a third surface teaching the old serve command. README-SECTION-3-cli-commands.md transcribes the package.json scripts so a reader need not open the file, and it still showed python3 -m http.server two commits after serve became serve-strict.mjs. An inheritor following the CLI reference would have started the permissive server, the one whose forgiveness hid a URL bug in two gates for a whole flight. Measured before fixing: ten quoted scripts, nine matching, exactly one drifted. I had expected build:unlocked to be stale too and it is not -- the doc truncates it rather than misquoting it, which is the right call for a seven-hundred-character line. Hand-fixing the one line would leave the next script change to rot the same way, so the doc is now checked. The comparison is loose in the three ways a human-facing doc needs: package.json's trailing hash comments are ignored, the doc's own slash-slash annotations are ignored, and truncation passes on a prefix match either way. A quoted script that does not exist in package.json is a hard failure, since that is a command someone would type and get "script not found". Completeness is deliberately not asserted; the doc is a curated subset and demanding otherwise would make it churn on every internal gate. Self-test poisons the doc in memory two ways, a changed body and a nonexistent script name, and requires both to be caught. Both are. Full build passes with the gate in the chain. |
||
|
|
34d4d0efdf |
Fix the same broken token command in reference.md, and say only what was measured
Last tick I fixed the golden path's token fetch and left the identical 403-fetching command in reference.md twice. Fixing the instance rather than the class is the same mistake as scrubbing a string whose generator still writes it. Both are corrected, along with a stale python3 -m http.server line that predates serve-strict. The doc had already MEASURED the restriction and kept prescribing its cause: it recorded that tiles 403 from 127.0.0.1 and treated a blank basemap as the cost of working here, while the command above it fetched exactly that token. My first rewrite then overstated the fix, and the correction is in the doc rather than hidden in a commit. Measured with the doxa.life token: curl gets 403 with no Referer, 403 with a 127.0.0.1 Referer, 200 with a doxa.life Referer -- so the restriction is real and does block the curl path. But a browser gate still fetched 9 /v4/ tiles with zero failures, because the page resolves its token through a ladder and the built bundle carries the working baked one. The fix is to the shell commands; the browser gates were never affected. Two things worth inheriting came out of checking it. The basemap is Mapbox Light, pale by design -- I looked at a screenshot and briefly called it "not painting" because I expected terrain and roads, when the labels and borders ARE the basemap. And verify:map-paint passes under both tokens, since it asserts a live GL context rather than loaded tiles, so it is not the signal to consult about whether tiles arrived. |
||
|
|
7e1baac4a1 |
Hand an inheritor a token that works, and stop claiming one is present without checking
The golden path told a new author to fetch a token from doxa.life/api/maps/token. Measured: that token is URL-restricted to doxa.life, so a tile request without that Referer returns 403 and every basemap in their smoke test renders blank while the pins draw fine. That is exactly the symptom the Driver reported as loading the pins but not the underlying map, and it is why tokenUrl was emptied in the build config the day before. The first command in the hand-off doc reproduced a known bug. It now reads the token from the build config, which is the source that works: same tile request, config token 200, endpoint token 403. That is a pk. public key baked deliberately, protected by URL restrictions rather than secrecy, and check:public-hygiene allows pk. while failing the build on sk. and tk. -- so it is safe to read and never a secret to guard. The doc shows the command, not the key. The aggregator had the same bug and a worse symptom: it fetched from the same endpoint and printed "token present", which meant a string had been obtained, not that it worked. So a run could report a present token while every map gate measured a blank basemap. It now prefers the config token, falls back to the endpoint, and validates whichever it gets with one tile request. Verified in all three states: the config token reports "present and serves tiles", a fabricated token reports "tiles 401 - basemaps will be blank", and the old endpoint token reports "tiles 403 - URL-restricted?". The banner reports what was measured rather than what was fetched. |
||
|
|
9d6b0ecd0f |
Run the WordPress contract test as part of the build
check:wp-embed existed as a script and nothing invoked it -- the same orphan shape as check:synced-css, which had been red and unrun for weeks. This one is green and unrun, which is quieter but no better: a gate the build does not consult is documentation. It is worth running. It renders the real shortcode against stubbed WordPress functions and asserts the output against the real built manifest, so it needs neither MySQL nor Docker. Its load-bearing assertion is that the wrapper emits the same SRI integrity hash the catalog ships, which is what gives the WordPress path the same tamper protection as the direct-script embed. It also carries a negative case: a malformed integrity value must be rejected. Full build passes with it in the chain. |
||
|
|
aa196db1d8 |
Refuse to serve a half-built tree, so a gate cannot mistake one for a regression
A build removes public/doxa-maps before rebuilding it, so a browser gate running during one reads an empty tree and reports a confident wrong answer. verify-shell-scroll said it could not find the dashboard-plus-grid tab twice in ten minutes while a swarm rebuilt underneath it. That reads exactly like a real regression and cost three ticks of this flight. The server now answers 503 for anything under /doxa-maps/ while the manifest is absent, with a body naming the cause. A 503 rather than a 404 is deliberate: 404 means "this is not here", which a gate may reasonably report as a product defect, while 503 with a reason cannot be mistaken for a finding. Assets outside the bundle tree are unaffected. The check belongs in the server because all nine gates fetch through it. Guarding one chokepoint means none of them can misread a torn tree and none of them needs editing, where having every gate take the build lock is nine changes plus a rule every future gate has to remember. The aggregator already refused to start against a missing tree, but only at startup, and a full sweep runs for seventeen minutes. It also blamed a missing server for a 503, sending the reader to start one that was already running; it now names the real cause. The gap the server closes is the one that actually bit: a single gate run directly, at any moment, including mid-sweep. Verified by hiding the manifest in place: a leaf returns 503 with the explanation while /assets/ still returns 404, and both return to 200 when it is restored. |
||
|
|
236d289cc7 |
Point the anchoring gate at index.html too, so it can actually measure
Second gate caught by the strict server. check-fixed-anchoring loaded both hosts by bare-directory URL, which the old permissive local server resolved and the live layer does not, so it was green only because local was more forgiving than production. Worth noting how it failed: UNMEASURED for both hosts, not a false pass. It refuses to report success when it could not measure, which is the difference between this gate and one that would have found zero dropdowns and called that zero anchoring problems. Now passing with 16 triggers anchored across both hosts, Status among them -- so the eighth filter anchors correctly in the composite as well as standalone. A sweep of internal/ finds no remaining bare-directory leaf URLs. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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). |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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).
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|