A bundle can be compiled and present on disk while its profiles never reach the
manifest, and nothing noticed: the leaf floor counts index.html recursively, so one
bundle losing every leaf while another keeps extras stays above the floor; the
embed-target check iterates what the manifest lists, so an omission is invisible to it;
and a bundle count passes too, because one bundle can be swapped for another and the
totals still match. Counting is not covering. The only way to see an omission is to
compare the manifest against the scanner rather than against itself.
The first version of this check cried wolf on its first real run. It reported key-cities
as missing — discovered with four profiles, absent from the manifest, and still absent
after a full rebuild, which made it look persistent and real. It is neither: the build
config gates that bundle by name and generate-tree filters on it, so the bundle is
legitimately discovered and legitimately unpublished. The scanner answers what exists;
the config answers what ships. Asserting the first against the manifest without
consulting the second asserts an invariant that was never true.
That failure mattered more than the bug it was chasing. A gate that reports a deliberate
configuration as a defect gets switched off, and a switched-off gate protects nothing —
so the check now reads the same config the build reads, and names the gated bundles in
its passing output so the exclusion stays visible rather than becoming folklore.
A manifest entry the scanner does not return is reported as a note rather than a
failure: stale build output heals on the next full build, while a missing plugin is
silently absent from the studio for everyone reviewing the product.
Negative-tested: removing any published bundle from the manifest fails with exit 1
naming it; restoring passes; the gated bundle never trips it.
Both shells declared a `title` prop. defineCustomElement maps every prop to an
attribute, and `title` already exists on every HTMLElement as the tooltip, so Vue warned
on every single mount that it would overwrite the native property. Harmless in effect —
nothing here wants a tooltip — but a warning repeated on every mount is where a real
error goes to hide, and this repo has already paid for that once when a thrown setup()
silently removed three buttons behind noise that looked ignorable.
Renaming the prop was safe because nothing outside ever set it: no host passes title=,
no profile-config reads it, and the value is only forwarded to the shared layout, which
is an ordinary Vue component rather than a custom element and so has no collision of its
own. Both shells carried the identical declaration — the same duplicate-implementation
pattern that earlier turned one resolver assumption into two separate outages — so both
were fixed together.
The studio smoke check now samples several tabs instead of only the first, and measures
node depth inside the mounted element rather than counting the slot's children. The old
check read one child for every pane whether the element rendered a full dashboard or
nothing at all, which is the "mounts is not works" trap its own header warns about. It
polls for content rather than sleeping a fixed interval, because a fixed wait either runs
too slow to use on every edit or marks a slow tab broken, and a gate that cries wolf gets
switched off.
Measured after: zero Vue warnings in the browser console.
check:embed-targets was already wired into package.json while the file it runs sat
untracked — so a fresh clone got a script that could not execute. It asserts that every
embed.iframe src in the manifest resolves to a file on disk, which is the load-bearing
check for the iframe deployment method: one of only two of the five that are proven.
That assertion matters more now than it did. The build stopped emitting a chooser page
per bundle, which took the recursive leaf count from sixty-three to forty-four, so the
coarse leaf floor no longer distinguishes "nineteen pages deliberately removed" from
"a partial wipe deleted real leaves". This gate does: it names the missing file and the
manifest path that pointed at it.
Negative-tested: hiding one leaf fails with exit 1 naming both the file and its JSON
path; restoring returns to a clean forty-three.
Checkpointing thirteen files of agent work that were sitting uncommitted while three
agents wrote to a live tree the Driver was watching through hot reload. Uncommitted work
is work at risk, and a tree being edited under an observer oscillates between states that
look like regressions and are really just partial saves.
The second capability-5 shell now mounts its tabs as real elements too, so no iframe
remains in either shell. It was the last holdout: eight tabs, all iframe mode, each
pointing at a hardcoded built-tree leaf that does not exist under the dev server.
The facet rail keeps its shape when a filter narrows it. Selecting one place took the
rail from sixty-six rows to twenty-one — forty-six rows deleted, none dimmed — so the row
a user was about to click vanished under the cursor, which is what "the buttons went
dead" actually was. Rows whose count falls to zero are now re-admitted at their original
slot and depth, dimmed and inert, which also restores the component's own promise to lose
nothing against the tree it replaced.
Expanding a branch and filtering by it are now separate, visible affordances: the whole
row filters, and expand is a real button with its own hit area and label. Rows that were
never clickable are unmistakably inert rather than silently ignored.
Three more facet axes reach the grid — affinity bloc, engagement status and primary
language — each verified to share a field with an existing prop rather than being mapped
on name resemblance. Two derived facets remain unmapped on purpose: they have no matching
row field, and mapping them would filter confidently to the wrong rows.
One commit shipped the requested change and a regression together: a base prefix applied
to an already-rooted URL 404'd all 29 studio tabs, and finding it took 32 minutes. The
root-cause analysis attributes the largest single share of that to there being no gate
that exercises the dev server at all — of 46 gate-ish scripts and 13 browser gates, none
did. The built manifest hands relative refs and the dev manifest hands rooted ones, so a
base-join bug is invisible to every build-time gate and fatal to every developer.
The check finishes in about four seconds against an already-running dev server, which is
the point: the full sweep costs about seventy-five, so it only ever gets run at the end
of a change, which is exactly how a regression rides along unnoticed. It activates one
tab rather than twenty-nine because a single mount already exercises the whole resolver
path — manifest fetch, script resolve, module load, define, mount. If the resolver is
wrong, the first tab fails like all of them.
It asserts the five things that have actually broken: that the root serves the studio and
not a staging page, that the element both mounts and registers, that the tab list is not
empty, that no request failed, and that the activated pane holds real content — because
an element can register and attach while its content never arrives, which is what a
missing API base looks like.
An unreachable dev server exits 2 rather than 1. Conflating "no server" with "broken
product" is how a gate earns a reputation for flakiness and gets switched off.
Negative-tested: removing the tree base makes it fail on the empty tab list, and an
unreachable URL exits 2; restoring returns to green.
The built leaf pages set window.MAP_APP_API_URL; the dev pages set only the Mapbox
token. Every dashboard in dev therefore fell back to a relative /api/people-groups/list,
which Vite answers with SPA-fallback HTML at HTTP 200 — so the fetch "succeeds", the
parse yields nothing, and the facet rail renders zero rows with no console error and no
error card. One studio sweep produced eight such requests. It reads as a broken
component and is really a missing global.
Both dev routes now read the value from the same build config the built leaves use, so
the two surfaces agree by construction rather than through a second hard-coded copy, and
each route resolves the token and the API base from a single config read so they cannot
disagree about their source. Measured after: the research dashboard renders 66 facet
rows and 216 table cells with zero 4xx.
There were THREE staging surfaces, not two. The repo-root index.html was Vite's default
dev entry, and its title was the line that kept reappearing in front of the Driver:
"doxa-maps · Embeddable JAMstack Maps · staging", above a bundle strip that loaded each
chosen map into the page itself. It is archived with its internal/staging.js, not
deleted, and git recorded both as renames so history follows.
Archiving is safe because it was never a build input: rollupOptions.input is built from
per-bundle JS entries, and the root HTML is referenced only on the dev branch. It was
also already unreachable — the dev plugin intercepts /, /index.html, a query-string
root and /__gallery, all measured returning the studio with no staging title before the
move, and measured again after. What remained was a file that would resurface the moment
the middleware changed.
The settled-tree leaf floor drops from 50 to 40 because the architecture changed, not
because the tree got sicker. That floor was set when the build also emitted a chooser
page per bundle; those 19 pages are deliberately gone, which takes a correct tree from
63 leaves to 44. Left at 50 the gate would fail on every correct build, and the cheapest
way to make it green again would be to restore the choosers — so a stale floor does not
protect the tree, it defends the old architecture. The floor still catches the
partial-wipe it was built for, and the load-bearing assertion is now that every
embed.iframe target resolves: 43 checked, 0 missing.
Prefixing a base onto an already-rooted URL 404'd all 29 studio tabs, and no existing
gate could have seen it: the built manifest hands relative refs, so concatenation is
correct there and the assumption is never exercised. Only the dev server hands rooted
refs, so the entire build-time suite passes green while every developer's studio is
broken. A product that has to work on ten laptops cannot be verified by the build alone.
The check discovers every shell that defines a resolve(ref) rather than naming them,
because the two capability-5 shells carried byte-identical implementations and one
latent assumption therefore produced two separate outages that had to be found
separately. A third shell copied from either is now caught on its first run.
It evaluates the shipped function against a stubbed host instead of re-implementing the
rule, so the gate cannot quietly agree with itself while the product disagrees.
Negative-tested: removing the rooted-ref guard fails 4 of 8 cases and reproduces the
exact double-slash path from the outage; restoring it returns to green.
A capability-5 shell mounted inside the studio carries its own tab list and resolves
it against its own tree base, which it derives from its script URL when nobody supplies
one. Nested here that guess was empty, so the resolver took its parent-directory
fallback and requested paths above the leaf at the server root — three bundles 404'd
that way. The requests were non-fatal, because each tab still rendered from its
fallback list, which is exactly why they survived: a silent 404 behind a working
screen. The studio already knows the correct base, since it is the one it resolved
against, so it now passes it down through the existing profile-config path rather than
letting the child re-derive it.
EVERY TAB FAILED TO LOAD. Introducing tree-base for the dev server prefixed it onto
URLs that were already rooted, producing /__plugin-gallery//plugin-designs/<b>/index.js
and a 404 on all 29 tabs. Both capability-5 shells had byte-identical resolve()
implementations, so both carried the same defect; both now return an already-absolute
ref untouched and join through exactly one slash. The built tree hands relative refs
and keeps its base, so this is one rule for both trees rather than a dev-only branch.
THE STUDIO NO LONGER FRAMES ANYTHING. Map elements were nested in iframes pointing at
their standalone leaf pages, to dodge a one-shot customElements.define collision. That
collision cannot occur here: 19 bundles claim 19 distinct tags, none shared, and two map
elements were proven to coexist in one document with the first unaffected by the second.
Every pane now mounts the plugin's real custom element, which is what makes a row both
reviewable and nestable.
Panes keep two lifetimes, and the difference is the WebGL context budget rather than
taste. Light rows stay mounted so switching back is instant and state survives. Map rows
are destroyed on deactivate, because a hidden map holds its GPU context: the browser
caps live contexts, then silently reclaims the OLDEST, so a map visited ten tabs ago
would go blank with no error anywhere. getContext never returns null and contextlost
never fires on the new canvas, which is what makes that failure so hard to attribute.
The dev server serves a leaf route for one element with no chrome, and passes the tree
base to nested shells so they discover the live tree instead of a baked fallback.
THE DEV SERVER LANDED ON THE WRONG PAGE. `bun run dev` served only the staging
gallery at /, and had no route for a bundle leaf, so there was no URL at which the
capability-5 plugin studio could be seen at all. Opening / showed plugins wrapped in
per-bundle frames behind a wall of explanatory prose, which reads as "none of the
elements are working" when in fact the elements were fine and the page was a chooser.
/ now mounts <plugin-studio> directly: a 605-byte host page with no chrome and no
prose, the element filling the viewport, and the Mapbox token baked as the token
ladder's first rung so maps work without anyone appending ?tk=. The gallery is not
deleted — it still generates the built index and is load-bearing for the manifest —
it simply moves to an explicit /__gallery URL and stops being the landing surface.
tree-base is passed explicitly, without which the studio mounts EMPTY. It builds its
tab list by fetching <base>manifest.json and falls back to a path relative to its own
script URL, which in dev resolves to a file that does not exist. The dev server serves
the same manifest-shaped model on its own route, so dev now shows the same tab list as
production: 19 bundles, 30 profiles, five capability groups.
PLUGIN FRAMES SHOWED THE GALLERY. The studio nests map profiles in a lazy iframe
pointed at that profile's leaf page, and dev had no such page, so each frame fell
through to the SPA fallback and rendered / — the gallery, recursively, once per map
tab. A /__leaf/<bundle>/<profile> route now serves the dev equivalent of the built
tree's leaf: one element, its bundle entry, the token, and nothing else.
FILTER DROPDOWNS WERE INVISIBLE. Opening a filter rendered its options underneath
the surrounding content, making the dashboard's primary control unusable.
The cause was CLIPPING, not stacking order, and that distinction is why an earlier
attempt failed. The filter band is a one-row horizontal scroller authored as
overflow-x:auto with overflow-y:visible — but CSS Overflow 3 coerces a visible axis
to auto when the other axis is not visible, so it computes to auto/auto and clips
vertically. Clipping happens during painting, before stacking order is consulted, so
raising the band to z-index 55 changed nothing: it won a stacking fight that was never
the problem. You cannot re-order pixels that were never generated.
The menu is now position:fixed, anchored to the trigger's viewport rect, which takes it
out of the clip chain entirely. Fixed coordinates go stale whenever anything scrolls or
resizes, so they are recomputed on open and while open, listening in the capture phase
so the dashboard's inner scroll regions are seen too.
PEOPLE-GROUP IMAGES WERE LETTERBOXED. A fixed 130px height against a 220px column
cropped each person to a band. The image is now a square via aspect-ratio, and the
column floor rises to 240px, roughly doubling the visible area. object-fit:cover is
kept deliberately — stretching to a square would distort faces, which is worse than
the letterbox it replaces.
Four fixes to the research dashboard and the cap-4 composite, all Driver-reported.
GRID SHOWED ZERO ON EVERY SELECTION. Picking a region showed "1,309" in the chart
above an empty grid below. The two halves spell the same value differently: the
dashboard emits the human LABEL ("Asia", "North America & non-Spanish Caribbean")
while the API stores the machine VALUE ("asia", "north_america_&_non-spanish_caribbean"),
and the comparison was a strict ===, so it never matched. Measured over the 2,107
live records. Adds a shared valueMatches() normaliser used by BOTH applyDropdownFilters
and applyWith, so facet counts and shown rows cannot disagree. Compares value OR label,
case- and separator-insensitively, chosen over a label-to-value lookup table that would
need maintaining per axis and would rot when the API adds a region.
The grid's own dropdowns were never affected, because they build their options from the
data itself; only the host-prop path could inject a label into the comparison. That is
why the standalone grid is fine and the gate passes 13/13 on the same records.
COUNTRY NOW REACHES THE GRID. Clicking India in Top Countries did nothing because
country_code had no matching prop and was deliberately dropped rather than guessed at.
Adds a real countryCode prop and maps it. Country is prop-only, not a fifth dropdown.
LONG FACET LABELS HID THE COUNTS. white-space:nowrap let a long label eat the row, and
the rail scrolls vertically only, so there was no way to recover the number. Labels now
wrap; the bar yields; the count never shrinks.
CAMERA FLEW TO THE NORTH POLE on globally dispersed selections. Fits now trim outliers,
reject invalid and null-island coordinates, and unwrap longitudes across the antimeridian,
so the view lands on the data instead of a pole.
The provider option landed last change but nothing a host passed could reach it:
the dashboard's config is a static import, so the setting existed in source and
was unreachable by an embedder. Real and unusable is the same trap as a nav
control that exists off-canvas. Hot-swappable has to mean a partner can choose
without a rebuild.
profileConfig is the channel every other plugin already uses for this, so this
adds no new mechanism — it stops the dashboard being the one plugin that ignores
it. The merge is shallow and the host wins. Shallow is deliberate: the static
config's nested objects are a coherent set that a partial override would corrupt,
and a deep merge would invite half-replaced facet maps that fail far from the
edit. What a host legitimately sets is top-level scalars.
With no host config the behaviour is byte-for-byte what it was, verified against
the baseline captured before any of this work: same node count, same canvas, same
row count, same engine, same ten tile responses.
The Driver wants MapLibre available without breaking Mapbox: keep every Mapbox
component working, add the option beside it, start with this dashboard.
The change is deliberately additive. provider defaults to mapbox, so with no
config the behaviour is what it was and nothing Mapbox-shaped can regress. A host
opts in through dashboardConfig, which is how every other dashboard setting
already arrives, so the cap-4 composites can pass it down with no new mechanism.
The plumbing already existed and was simply unreachable from here: useMapInstance
takes a provider and loads the matching engine, and the style registry maps every
named style to both engines with the MapLibre side pointing at keyless URLs. This
call passed neither provider nor style, so it silently took the default.
The token stays wired when MapLibre is selected. MapLibre ignores it, and keeping
it means switching back is one word with nothing else to restore, which is what
hot-swappable has to mean to be worth anything.
The Driver's report was exact: the pins load and the underlying map does not —
a grey field with data floating on it. That is the URL-restriction signature. The
baked token was scoped to doxa.life, so tiles returned 403 from nuxtbase.app,
from localhost, and from a bare origin, while the style document returned 200
everywhere. The map therefore booted, drew every dot, and showed no basemap.
The Driver supplied a token with no URL restriction. Measured side by side: it
serves tiles 200 with no Referer, from nuxtbase.app and from localhost, where the
previous one returns 403 in all three cases.
Also empties tokenUrl. It pointed at the endpoint that serves the restricted
token, and that endpoint sits above the baked token in the ladder — leaving it
set would fetch the broken token at runtime and overwrite the good one.
All 43 leaves rebuilt and carry the new token; none carries the old one. Verified
in a browser with no Referer sent, the way a real visitor arrives: nine of nine
tile requests returned 200.
This unblocks the interoperability work, which could not be judged while the map
underneath was blank.
A backtick in live shadow CSS breaks the build exactly as one in a CSS comment
does, and check:template-literal only reads the comments. Proven both directions
last tick: the planted defect left that gate at pass while the build died with a
Babel stack trace pointing far from the edit.
The card concluded this was not achievable, on good evidence — three lookahead
heuristics were tried and reverted, scoring 98 and then 136 false positives on a
healthy tree, because the real terminator of a template literal is also a
backtick and no window of surrounding characters separates the two. The evidence
was right and the inference one step too strong. No heuristic works is not
nothing works, and the card named the answer in its last line: needs a tokenizer.
A parser does not guess where a literal ends; ending it is what parsing means.
Measured against the same tree the heuristics failed on: zero false positives
across 83 files. Splitting the SFC first means it sees exactly the script text
the build compiles, and using the parser the build reports errors from means its
verdict matches what actually breaks rather than approximating it.
Negative-tested with the exact case the other gate misses: a backtick planted in
live CSS passes check:template-literal and fails this one, naming the file and
the line that was edited rather than a stack trace somewhere else. That line
number is the point — the build was always the backstop, just an unhelpful one.
This gate's own header names the failure it exists to prevent — two artifacts
encoding one fact with nothing forcing agreement, where fixing one copy is not
fixing the fact. It then guarded exactly one artifact.
Measured: planting a claim that doxa-vision-card is the capability 1 exemplar
into documentation/reference.md passed both this gate and the exemplars gate.
That is precisely the error this file was written to catch, sitting one directory
outside its scan. doxa-vision-card ships an instances file with two entries, so
the classifier types it parameterized — capability 2, not 1.
The documentation folder states the tier correctly today, so this is not fixing a
live defect; it closes the hole that would let the same one recur unnoticed in a
second place. Negative-tested: the planted claim that used to pass now fails with
the file named, and removing it returns to green.
The same stale citation also lives in the plugin-designer skill, which is outside
this repo and so beyond any gate here. That half stays a Driver edit; the repo
now holds the checkable half.
I excluded smoke:share from the aggregate two ticks ago and the reason was wrong.
Every clause of it was true — the script takes required arguments, and its
gallery mode does need the vite dev server — but the conclusion did not follow. I
had tried exactly two invocations, bare and --plugin-gallery, and never --url,
which the script's own header documents first. It runs fine against the static
tree: 8 of 8 pass, each Share button reopening the correct custom element with a
live canvas.
That is the same shape I logged as an anti-pattern last tick, now found in my own
work: a not-applicable verdict is more durable than a wrong bug report, because
it closes the question. It cost the suite its highest-consequence surface for two
ticks. A share URL is the one artefact a partner pastes into an email, and it
fails silently.
It runs as smoke:share-all, carrying its own eight --url flags and --browser
chromium, since Firefox headless has no WebGL on this box and the default engine
stops. The eight leaves are the set derived by grepping for ShareButton, not a
guess. The simple-map case is the discriminating one: it shares to a different
leaf than the one opened, so the deep link is really being followed rather than
an SPA fallback rendering something plausible.
Two shell traps on the way. A VAR=x prefix does not apply to $VAR on the same
line, because the shell expands the argument before the assignment scopes to the
command, so the URLs came out with an empty base; deferring through sh -c fixes
it. And a glob in a block comment closed the comment early.
Every map image shipped data floating on an empty navy void — no coastlines, no
borders, no labels. Two cards had this blocked on the Driver adding origins to
the Mapbox token's allow-list, for four ticks.
It did not need the Driver. Measured both directions: a tile request with no
Referer returns 403, the same request with the doxa.life Referer returns 200,
and the style document returns 200 either way, which is why the map still booted
and every gate passed. The shot harness serves from 127.0.0.1 and so sent no
Referer at all. These are doxa.life's own documentation images, generated by its
own repo from its own token; the restriction exists to stop third parties burning
the Driver's quota, and it was blocking the owner.
Route interception looked like the careful way to scope it and silently does not
work — Chromium ignores a referer supplied through route.continue, so tiles still
403'd. Only the context-wide header applies, and that breaks same-origin iframes:
the iframe-host shot went from 187 elements to 15.
So it is opt-in per shot, and the predicate is architectural rather than
anecdotal: any surface that nests content in an iframe opts out. I set it for the
one shot that failed first, then watched both cap-5 shells collapse on the next
run, and generalised. Map leaves get their basemap; shells keep their content.
Verified by reading the images, not the file sizes — a size jump had already
misled me once this tick.
It showed an empty pane reading "Choose an element from the list." The README's
headline image for the highest capability level demonstrated nothing, and every
shot gate passed it, because freshness, distinctness and element counts are all
satisfied by an empty pane. The shot now selects the first rail row before
capturing, so the picture shows what the studio is for: a nested SPA rendering
inside the shell that lists every element by capability. The file went from 167KB
to just over a megabyte, in line with its sibling.
Found by reading the image, the same way the mobile error card was found
yesterday. Reading one picture has now caught two defects that eleven gates
passed.
Add check:shot-fonts. The studio labels each row with a capability emoji, and
this machine has no emoji-capable font, so all 28 rendered as tofu boxes in the
committed PNG. The markup is right and the product is fine in a real browser —
the shot machine could not draw it — so the fix is not to strip the emoji. The
gate asks fontconfig whether this system can render the glyphs the UI actually
uses, and names the font install rather than the markup.
It is deliberately not in build:unlocked: it reports an environment condition
that is currently true here, and a gate that always fails in the build makes the
build useless. Its first version also flagged the star this repo uses as a
comment marker, which every text font renders; the range is now narrowed to the
pictographic planes.
Two composites nested a child by tag and URL without a profile-config. Those
children do not render a component directly — their bundle entry mounts
ProfileLoader, which reads profile-config.profile to choose which view to load.
With none, the element still mounts and still reports its tag, so every
structural check said both children were present, while the loader rendered
"missing or invalid profile-config prop" where the map should be. The grid half
of each page worked, which is how it survived.
Found by reading a regenerated README screenshot, not by any gate. The mobile
screenshot showed an error card and an empty pane; the fixed one shows the real
research map with its data, controls and progress figures.
research-map-plus-grid was mine, shipped that way. stack-demo predates it and had
the same defect against research-dashboard. Both now pass the shape their own
generated leaf pages use, so nested and standalone mounts ask for the same thing.
check:composite-srcs now requires a profile-config on any child whose bundle
mounts ProfileLoader, derived from which entries import it rather than a list.
The first version of that check tested for the word and passed on its own
explanatory comment; it now matches the quoted attribute key, and a negative test
that mutates only the key fails as it should.
Also regenerates three derived artifacts that had drifted behind this flight's
changes: the library index, the capability exemplars table, and all twelve README
screenshots.
All seven browser gates were orphans: each had a script, and not one was invoked
by anything else. They were reachable only by someone who already knew the name
to type, which is exactly the person who does not need the list. This repo has
been bitten by this before — the Gate-1 acceptance test existed, worked, and was
invoked by nothing, so it could never report anything. An orphan gate is worse
than a missing one because it looks like coverage.
The data guards already solved this; verify-all.mjs says it plainly — a guard
nobody runs is documentation. That file has no reference to playwright or to the
built tree. It is the data half. This is the browser half.
Every gate listed here was run before it was listed, following the rule the
earlier audit set: listing a program you have not executed puts unvalidated
entries in the hand-off surface. All six pass, and the aggregate now runs them
end to end: capabilities, embeds, map paint, the Gate-1 chain, accessible names,
and phone navigation.
smoke:share is excluded on purpose and says so. It takes required arguments and
its gallery mode reads a registry only the vite dev server serves, so listing it
would make the suite fail for something that is not a defect.
Preconditions are asserted up front and exit 2, so a missing server reads as a
setup problem rather than six mystery failures.
The file header said tab 1 was bp-big-picture, mounted directly via its real tag.
Both halves are false: tab 1 is dashboard-plus-grid, and every tab is an iframe —
there is no direct-tag mount left in this shell. A reader trusting that text would
look for a mount path that does not exist and maintain the wrong bundle believing
it was the one users see. The big-picture bundle is still a valid element; it is
simply one of the parts already composed into the tab-1 composite.
The locale switcher also poked slot zero by hand, writing a profile-config named
big-picture onto what is now an iframe, which ignores it. Harmless, and that is
why it survived: the effectiveLocale watcher was doing the real work all along,
branching on the tab's mode and re-pointing the iframe src that carries the
locale. Removed rather than repaired, since a second hardcoded locale path is one
more thing to keep in sync for nothing. Verified by driving the switcher: the
active frame moves from locale=en to locale=es.
The direct-tag branch in the template is kept. It is generic, keyed on the tab's
mode rather than a bundle name, and remains the right way to nest a future
token-free element. Only the comment claiming tab 1 uses it was wrong.
Yesterday the cap-5 shell shipped unusable on a phone: the rail was an off-canvas
drawer, the toggle lived inside a header the shell does not render, and the only
other control sat inside the drawer itself at x:-244. Every tab but the one that
happened to open was unreachable, with no error and a page that painted fine.
That survived because it is an intersection bug. Header-off is correct, and an
off-canvas mobile drawer is correct; the defect exists only where they overlap.
So this checks the intersection directly — each cap-5 shell as it ships, at a
phone viewport — and asks whether any on-screen control opens the rail.
It then presses that control and requires the rail to arrive. Presence is not
function: a present-but-inert opener is the same dead end wearing a disguise, and
asserting only presence is how the original bug survived a card marked verified.
This is not covered by the accessible-name gate. Neither property implies the
other: the off-canvas button had a perfectly good name while being unreachable,
and reachability is geometry, not markup, so no source grep can answer it.
Negative-tested by re-creating the real bug — removing the floating opener makes
it fail naming that shell, while the header-bearing studio still passes.
The Driver's ordering: desktop opens on the Big Picture dashboard; mobile opens
on the research map and does not show Big Picture at all. Both capability-4
composites already existed, so this is the shell choosing between them. A row in
the nav model can now declare onlyWhen desktop or mobile; everything below the
first rung is identical on both, which is why only the first two rows carry it.
One nav model rather than two, so the teaching order lives in one place. Two
parallel models would be two things to keep in sync and the failure would be
silent: a row added to one and forgotten in the other simply never appears.
Crossing the breakpoint can remove the row that is currently active, which would
leave the user on a mounted but unlisted tab with no highlight. The shell now
snaps to the first surviving row, and establishes the viewport class before
opening the first tab so a phone never opens Big Picture for a frame.
Also fixes a pre-existing bug this exposed: with no header there was no way to
open the rail on mobile. The drawer sits off-canvas, the only toggle lives inside
the header, and the collapse control is inside the drawer itself — measured
off-screen at x:-244. Every tab except the one that happened to open was
unreachable. A floating button now appears only when narrow, headerless and shut,
so no existing consumer can see a change.
A cap-5 shell nests its children as iframes, and window.MAP_TOKEN does not cross
an iframe boundary — each frame is its own window. A nested map cannot inherit
the parent's token, so it works only because the child leaf carries its own baked
copy. Proven in a browser: all thirteen nested frames in the cap-5 shell hold a
real token, including a map two levels deep. That was a one-off run; this makes
it standing.
The existing smokes cannot catch this. Both inject window.MAP_TOKEN with
addInitScript before the page runs, which is right for what they test but means
the test supplies the thing under test. A user opening a shipped leaf has nothing
injecting a token for them.
Also asserts the reverse: no token may be compiled into a .js bundle. Bundles are
cached, SRI-pinned and reused across hosts, so a token baked into one cannot be
rotated without a rebuild and leaks into every embed.
Reads the built manifest rather than walking directories, so it checks the set
that actually ships. Counted 43 leaves, matching an independent filesystem count.
Negative-tested both directions: stripping a leaf's token and planting one in a
bundle each fail with the artifact named.
posterUrl() said .png. There are 378 poster .webp files and no matching .png, so
the helper could only ever have produced a 404 — but it was never called, because
the export and print paths each built the same URL inline. A dead helper carrying
a wrong constant is worse than no helper: it is primed to break whoever tidies up
by routing a call through it. Point it at .webp and route both callers through
it, so the format lives in one place.
The file header still said posters "DO NOT EXIST YET. Nothing has been generated."
378 previews are generated, shipped and served live, and the wall paints from
them. A reader following that line would go build a generator that already ran.
Replace it with what actually ships: previews by default, full-resolution posters
excluded because bundling them pushed the tree past what the catalog would accept.
The desktop composite pairs the research dashboard with the grid. This is its
mobile sibling: the monolithic 7-tab research map on top, the same grid beneath,
stacked to scroll. Which one a shell shows is an aspect-ratio choice made at the
SPA level, so neither plugin knows about the other and both stay embeddable.
The grid's own search and filters are ON here. The desktop composite turns them
off because the dashboard header owns those controls and drives the grid through
props; the research map has no such header, so switching them off would leave the
grid with no way to be searched at all.
Composes its children by tag and bundle URL rather than importing their .vue
files. Importing the research map directly would bypass its bundle entry, which
is where its three bundle-private colour strategies, its profile registry, its
Pinia and i18n pair and the RTL text plugin are installed — and losing those is a
runtime defect in a green build, because a thrown setup() removes the subtree
with an empty error.
Add check:composite-srcs, which resolves every nested src against the built tree
and asserts every nested tag is registered. A wrong relative depth is a 404, and
the stack layout swallows a failed section on purpose so one broken child cannot
take the whole stack down — which means the cost of that resilience is a silently
empty band. Negative-tested both ways: breaking the depth and renaming a tag each
fail with the file and path named; restoring returns to pass.
The header claimed, in the present tense, that this workflow serializes builds
at cluster scope. The forge reports 0 recorded runs across 288 commits, so that
serialization is not in force: the local flock is doing all of it. State the
measurement and the caveat rather than let an inheritor rely on a gate that has
never fired.
Ran the browser-free gate suite inside a fresh clone -- the inheritor's
conditions. Five passed; check:dxlife went red with "clone not found at
/root-dx-life - nothing to check".
That is not a defect. The gate deliberately uses three values (0 unchanged,
1 drift, 2 no clone) because it exists after a check that returned the PASS
value BECAUSE IT FAILED: `cd root-dx-life && git status --porcelain | wc -l`
printed 0 for a directory that is not a git repo. Collapsing "cannot judge"
into "pass" is exactly the bug it was written to prevent.
But `bun run` and any && chain see only zero/non-zero, so the middle value
evaporates precisely where a reader consumes it. The fix belongs in the
documentation, not the gate: added a preconditions row stating what it needs,
what exit 2 means, and that it is NOT in build:unlocked, so a clone still
builds.
Verified the whole inheritor path again after the Cargo.lock change: clone
(2358 files, lockfile present) -> bun install exit 0 -> bun run build exit 0,
18 bundles / 27 profiles, with check:dep-names and check:no-abs-paths both
running inside the clone's own build.
deployment/src-tauri/.gitignore listed /target, /gen and Cargo.lock together
with reasoning that fits the first two. /target is 2GB of compiled output and
/gen is emitted by tauri-build on every compile -- both genuinely
regenerated. A lockfile is not build output: it is the RESOLUTION RECORD, the
only artifact that makes cargo build reproducible.
Cargo's rule splits on crate type -- commit for a binary, ignore for a
library -- and cargo metadata reports this crate as kinds=['bin']
(src/main.rs, no lib.rs). Checked with cargo rather than inferred from the
file layout.
The stakes are concrete: Cargo.toml pins nothing tighter than
tauri = { version = "2" }, while the lockfile pins 430 packages. Without it
an inheritor building next month resolves a different dependency tree than
the one that produced the shipped 64 MB binary, and method 5 is already
"built, not shot", so its build must at least be repeatable.
Verified before committing 109KB: cargo metadata --locked --offline succeeds,
so the lockfile is consistent with the manifest, and it contains zero local
paths or credentials.
check:tauri gains a fifth assertion -- Cargo.lock present AND TRACKED.
Tracked-ness is the point: the file sat on disk the whole time and was still
absent from every clone. Negative-tested against the real state, not a
simulation: the gate FAILED naming the file while it was untracked, and
passed once staged.
Makes the cheap half of last tick's P0 automatic. A folder rename had
rewritten three npm package names, so bun install failed in a fresh clone
while everything here passed -- the real packages were already in
node_modules from before the rename, making the breakage invisible to every
build and every gate.
Checks two things, offline, in milliseconds: the declared name resolves under
node_modules/, AND the installed package's own name field equals the declared
key. The first alone catches the capacitor case; the second catches a name
that resolves to a DIFFERENT package, which a path-only check would miss.
Together they detect the corrupted-name class without a network round-trip,
which is what lets it run on every build rather than in a nightly job nobody
watches.
It does not replace the clone test. A gate can only see what is IN the repo;
only a clone sees what the repo fails to do on a clean machine.
Negative-tested four ways: recreating the EXACT P0 (the three
@deployment/capacitor/* names) FAILS naming all three; a name that resolves
to a package whose own name differs FAILS; an absent node_modules SKIPs
honestly rather than passing or failing; an empty dependency list FAILS via
the vacuous guard. Wired into build:unlocked and confirmed running inside a
full build (17 declared, 17 verified, exit 0).
Tested the north-star path as an inheritor would, by cloning the repo from
Forgejo and running the documented first command. It FAILED:
error: GET https://registry.npmjs.org/@deployment%2fcapacitor%2fcli - 405
error: @deployment/capacitor/cli@7 failed to resolve
The rename of capacitor/ to deployment/capacitor/ did a blind string replace
that rewrote the NPM PACKAGE NAMES too. @capacitor/android, @capacitor/cli
and @capacitor/core became @deployment/capacitor/*, which do not exist on
npm. Restored the real names, pinned to ^7.6.9 -- the version actually
installed -- and fixed the two places that repeated the wrong name in prose.
This is the inheritor-only failure shape, in a form the new absolute-path
gate cannot see. The local tree works perfectly because node_modules/
@capacitor/* was installed BEFORE the rename, so every build and every gate
passes here. The breakage lived only in the DECLARED names, visible solely to
someone installing from scratch.
Verified by doing it: a fresh clone now runs bun install to exit 0 (206
packages, all three @capacitor/* resolved) and bun run build to exit 0,
producing 18 bundles / 27 profiles -- identical to the working tree.
The repo is clean -- git grep over every tracked file returns zero /home/
paths. That is a real hand-off property: a clone works on any machine, which
is what "the path Doxa inherits" requires. But nothing enforced it. Proven by
experiment: a /home/lk10x/... line planted in internal/settled-tree-gate.mjs
passed check:public-hygiene, check:parse AND check:quoted-scripts. The tree
was clean by accident, not by construction.
This is the worst failure shape for a hand-off. An absolute path works
perfectly for whoever wrote it and breaks only on the inheritor's machine, so
no test run by its author can catch it. The sibling ideation tree already
paid that cost: two docs cite a workspace root that no longer exists, so a
reader following them silently finds nothing.
Not a flag on check:public-hygiene, which already detects these patterns but
walks a SERVED tree where node_modules never exists -- pointed at the repo
root it produced 1,427 findings, nearly all from node_modules/.vite. Its
scope is right for its job. This scans `git ls-files`, the set a clone
actually receives, which excludes node_modules and build output with no
ignore list to maintain.
Negative-tested three ways: the exact planted path that beat three other
gates now FAILS with file:line; a /Users/ path in a doc FAILS; a checkout
with too few tracked files FAILS rather than passing vacuously. Wired into
build:unlocked and confirmed running inside a full build (783 files, exit 0).
It then caught its own documentation -- my reference.md row quoted the
literal patterns as examples. Rewrote the example rather than allow-listing
the file, which would have blinded the gate to a real path in the repo's
largest doc.
deepFind returns the FIRST match and stops. Every count-based assertion needs
all of them -- how many rows, anchors, hints, canvases -- and because this
file offered no such helper, each ad-hoc probe hand-rolled its own shadow
walker. Across one session of verification work those produced four false
results:
a naive walker double-visited through nested roots: 48 cards for 24;
a single-level querySelector found 0 anchors where there were 155;
an .rd-row count included a detail panel sharing that class, so a correct
filter read as broken;
a bare "<script" prefix match counted the word scriptUrl in a comment and
flagged a valid file.
Two of those nearly became bug reports against working code. A missing helper
does not stop anyone writing the check; it moves the check somewhere nobody
reviews.
The seen-Set dedupe is the load-bearing part: a node reachable through two
roots must count once, which is the first failure above.
Backward compatible by construction -- the third parameter is APPENDED, so
callbacks taking (deepFind, arg) are untouched. Verified rather than assumed:
a (deepFind, arg) callback still resolves; deepFindAll returns 24 cards with
returned === distinct; share-roundtrip-smoke (a real consumer) still passes;
check:a11y-button-names and check:parse both pass.
The OPEN WORK line asks for Playwright coverage over every plugin AND ALL
SHARING. Sharing had never been exercised across the set this flight: the
gate needs a served tree, a token, per-plugin --url flags and (on this box)
--browser chromium, so a bare `bun run smoke:share` only prints usage.
Derived the real coverage set -- grep -rln ShareButton plugin-designs/*/*.vue
gives 8 built leaves -- and ran it: 8/8 pass. Every Share button emits a URL
that resolves 200 text/html and reopens the correct element with a live
canvas.
The pass count alone would have proved nothing. The failure this gate exists
to catch, an SPA fallback or a host stripping the hash deep-link, renders A
map for every share URL, so all 8 pass while all 8 links are wrong. The
discriminating evidence is six DISTINCT reopened tags, each matching its own
plugin, canvas:y on all eight.
reference.md now carries the copy-paste invocation, verified by running it
verbatim from a clean shell (8/8, same six tags). It also corrects a detail:
the doc said Firefox, which is the default but stops on this box -- the tool
detects its own WebGL limitation and names the chromium flag in its output.
A standing work item reads "gospel-resources + cities-internet maps must
render with REAL resource links" -- one line batching two profiles whose
contracts are opposite. gospel-resources genuinely renders 155 real eBible
links. cities-internet renders zero, deliberately: its own header says
"PERSECUTION-SAFETY: this profile carries connectivity + population +
language only. It has NO gospel-resource, church or believer field and must
not gain one."
So taking that line literally means adding a gospel-resource field to a
profile whose header forbids exactly that. The instruction is stale in the
worst way -- not merely wrong, but pointing at work that would remove a
safety property.
The rule existed only as a comment in one .vue header. It now has a section
in reference.md under Data sources, with the measurement behind it: zero href
bindings across all three cities-internet profiles, 1,567 live nodes and 0
anchors, and a `source` field that is API provenance rather than a
destination.
Also recorded that "persecution-safe" means three different things in this
repo -- field separation here, placename precision in key-cities, a
dependency-free table in DashboardDataTable -- so nobody generalises one into
the others. Only the first governs what data a profile may hold.
The exemplars table was authoritative but ~60 rows, so correcting a wrong
citation still meant reading it and DECIDING which row to quote -- the exact
step where a wrong citation gets re-invented. Added a generated five-row
summary that answers "cite this" per tier by a stated rule, and named the two
known-wrong citations it replaces.
Both live in the /plugin-designer skill, which sits outside this repo and so
cannot be gated from here (a checker pointed at it would pass on this machine
and skip silently everywhere else). Measured: unified-research-dashboard as
the cap-4 reference names a bundle that DOES NOT EXIST -- it became
research-dashboard (cap-3) plus dashboard-plus-grid (cap-4). And
doxa-vision-card as the cap-1 example ships an instances.json with 2
instances, which makes it cap-2 parameterized by the project's own rule.
The first version of the rule picked stack-demo for cap-4. It satisfied the
rule perfectly -- name equals profile, shortest -- and was still the wrong
answer to hand someone, because big-picture is the canonical cap-4 composite
named in the Gate-1 chain. Added a demo/template deprioritisation and
recorded why: a reproducible rule is not automatically a good one, so check
what it actually selects.
All five picks now match the skill's own reference implementations, except
the two that were wrong.
Applying last tick's lesson systematically: swept every numeric claim about
the catalog across all docs. Only one had aged into something misleading.
"Measured 2026-09-21: 17 of 17 bundles identity-proven" was TRUE when
written, and the gate reads the live manifest so nothing was broken. But a
reader today sees 17 beside a catalog of 18 and cannot tell whether a bundle
went missing or the sentence simply aged. Re-ran check:catalog-identity: 18
of 18, PASS. Restated with the delta named.
The other three hits are fine and deliberately left alone: the "21 bundles"
in the CLI doc is prose explaining why a count was REMOVED, the 18 in
reference-capability-exemplars.md is auto-generated, and "5 plugin
capabilities" is a constant rather than a count.
Added the general rule beside the number: a dated measurement is not a stale
claim, but it still misleads once the underlying figure moves, so re-run the
gate and restate rather than leaving a reader to guess.
The figure came from a loop that sampled only the first 30 leaf pages of a
41-item set, then got repeated as if measured. An exhaustive grep over
public/doxa-maps gives 41. Fixed here and in the 5x5 spec.
A sampled count that lands in a hand-off doc reads as measured, because a
plausible number invites no scrutiny. Counts stated in docs should come from
an exhaustive query.
Two of smoke's failures were the gate reporting a product decision. The
assertions date from when the root page embedded a live preview. Measured:
public/index.html has four occurrences of "profile-config" and all four are
escaped snippet and glossary text -- the gallery is a capability-sorted
navigation catalog that links to bundles and embeds none.
No coverage is lost, which is the only reason this is safe. The
generated-profile block asserts the same two things against a real leaf page
picked from the manifest, and that surface passes. 21 built leaf pages mount
with a profile-config at load. The shell copies were duplicates aimed at the
one surface that by design satisfies neither. Both counts are still REPORTED
in the shell log line, so a behaviour change stays visible.
My carded recommendation was to re-aim these at plugin-studio. That was
wrong and I checked before acting on it: the cap-5 studio leaf has exactly
one [profile-config] element at load -- its own host -- and zero nested
elements or live iframes. It mounts panes on activation. A load-time
assertion cannot see behaviour that starts on a click.
Negative-tested, after two invalid attempts. Rewriting the bundle src to a
404 makes smoke FAIL exit 1 on "imported no bundle that registered its
element". The first two attempts produced a green PASS for bad reasons -- one
edit never wrote, and the other removed an attribute from a JS-created
element while a bare static one remained -- so the mutation is now asserted
to have landed (before=1, after=0) before the result is believed.
generate-gallery.mjs:542 requests assets/vendor/vue/vue.global.prod.js, and
generate-tree.mjs staged only vendor/tabulator -- so that request has 404'd
on every build there has ever been.
It survived because loadVue() degrades in three rungs: self-hosted, then the
jsDelivr CDN, then a plain-DOM render. On any networked machine rung 2
silently covers for rung 1, and the only symptom was a 404 in `bun run
smoke`, a gate nobody was running. Good degradation hid a missing build step.
It mattered anyway: the comment above loadVue promises "self-hosted first
(portable / offline / no-CDN)". Measured in a browser with no outbound
network, the gallery fell all the way to the plain-DOM degrade -- usable, but
not the Vue shell, and the offline claim was false. A tree dropped on a
foreign host or opened over file:// is exactly what that rung is for.
Vendored from node_modules/vue 3.5.42, placed in data/vendor/ (the real
source -- generate-tree rmSyncs public/assets/vendor and re-copies from
data/), tracked in both places exactly as vendor/tabulator already is.
Verified: build exit 0 with zero missing-asset warnings; the gallery now
loads Vue 3.5.42 FROM THE VENDORED COPY (not the CDN), the Vue app mounts,
nav renders 5 rows. `bun run smoke` went from 4 failures to 2.
A correction en route: I first blamed `bun run clean` for deleting the file.
clean.mjs only wipes public/doxa-maps, never public/assets -- the real cause
was generate-tree.mjs:1189 rmSyncing public/assets/vendor and re-copying it
from data/vendor, which is why tabulator persists and a hand-placed file
does not.
Running the gate list I completed last tick, two entries reported FAIL:
smoke:gallery and smoke:share. Neither is a defect. smoke:gallery needs a
dev server on :5174 and says so; smoke:share needs --url arguments and prints
usage. Both exit non-zero, so in a batch run they read exactly like the real
failures the list exists to surface.
A checklist that turns a precondition into a red line trains its reader to
ignore red lines, which is how a real failure gets skipped. Added a
preconditions table stating what each needs and what a bare run actually
does, measured by running them.
Also recorded that the token-dependent gates DEGRADE rather than fail:
without MAPBOX_TOKEN, smoke:embeds reports map profiles as timeouts -- it
once read 27/40 for that reason and 40/40 with a token. A timeout there means
"no token" far more often than "broken plugin".
Two claims corrected before shipping. I wrote that the token endpoint needs a
browser User-Agent; measured, a plain curl gets 200 identically -- that
requirement belongs to pray.doxa.life, a different host. And I tested rather
than asserted the limitation now in the doc: verify:map-paint PASSES on a
leaf whose tiles 403, because a live GL context, a sized canvas and
attribution are all true while the basemap stays empty. The gate is correctly
scoped; the name invites over-reading, so the scope is now written down.
26 gates live outside build:unlocked; only nine were listed in reference.md.
An inheritor reads that table as the list of what to run before publishing,
so seventeen checks were effectively invisible -- including the three that
caught the most defects this flight: catalog-currency, smoke:embeds and
verify:map-paint.
The cost was already being paid. Two unlisted gates were RED, and had been
for two sessions:
check:quoted-scripts the doc quoted a build chain missing the
check:descriptions step added two ticks ago
check:capability-exemplars the generated tier table predated golden-path-check
Neither is a product defect -- both gates were working exactly as designed,
comparing documentation against machine truth. Nobody ran them. That is the
failure mode a standalone gate has by construction, and the table is the only
mechanism that prevents it.
Fixed both (doc updated to match package.json; exemplars regenerated to 27
rows), then filled the table to 25 of 26 -- the one omission is an alias for
a script already listed. Each new row states what the gate catches and, for
the ones that cannot live in the build, why.
Corrected a number mid-edit: I first wrote "nineteen gates" from an earlier
tally that had excluded the regenerator helpers. Measured again: 26.
The paint gate listed four leaves -- the ones under investigation when it was
written. The manifest says twelve profiles carry isMap, so eight painted
leaves were ungated, including BOTH cap-4 composites; dashboard-plus-grid is
tab-1 of the cap-5 SPA, so the flagship view was outside the gate that exists
to prove maps actually paint.
Every added leaf was run through the script first and PASSED before being
listed. A gate must start green or it gets disabled and protects nothing.
Full run: all 11 paint, exit 0.
my-upg-100-list/poster-wall is deliberately excluded and the reason is in the
file. It is correctly flagged isMap -- poster-wall.vue:191 makeHiddenMap()
builds a real gl.Map -- but on demand during poster export, off-screen at
left:-9999px. At load there is no canvas, so the gate's "laid out, did not
paint" verdict is accurate while the product is fine. Listing it would mean a
permanently red gate, which is worse than an honest exclusion.
Negative-tested: poster-wall FAILS exit 1 (a leaf that really does not paint
at load), and a 404 URL FAILS rather than passing vacuously.
Card 1 Step 1 asks that the merge lose nothing. No gate owned that. A local
bundle that 404s live was counted "unreachable" and skipped, and unreachable
never gated the exit, so a catalog with an entire plugin deleted passed:
CATALOG-CURRENCY: 17 current - 0 STALE - 1 unreachable (of 18 local bundles)
CATALOG-CURRENCY: PASS <- exit 0
Proven by deleting one bundle from a copy of the catalog and pointing the
gate at it. The hole fell between two checks that each answered their own
question correctly: identity proves the live bytes are self-consistent, and
currency proves they are the same build; neither asked whether anything was
absent. The tell was in the PASS text itself -- "every REACHABLE live
bundle". A qualifier inside a success message describes what a check does
not cover.
MISSING is now distinguished from UNREACHABLE. A 4xx is the server stating
the file is not there, which is a publish defect and fails. A connection
error or timeout is an environment condition and still counts as
unreachable, so an offline box still SKIPs and never fails a build.
Negative-tested four ways: the real catalog still PASSES (18/18, 0 MISSING);
a deleted bundle now FAILS exit 1 naming the exact URL probed; a tampered
bundle still FAILS as STALE; offline still SKIPs. Also fixed a reporting bug
introduced en route -- the failure printer rebuilt a .js path from the entry
name, mangling leaf URLs, and a wrong URL in an error message sends the
reader hunting for a file that was never requested.
The previous commit staged only plugin-designs/golden-path-check/, so the
regenerated public/ tree was published to the catalog but never committed
here. Live and the working tree both read 18 bundles / 27 profiles while the
commit still said 17 / 26 — no live inconsistency, but the repo stopped
describing what it ships.
This repo tracks its build output, so source and output move together: every
prior change commits public/doxa-maps/manifest.json alongside the .vue files
that produced it. A narrowly scoped `git add` is what broke that here.
The north star is that an admin agent can author a plugin, build it, publish
it and see it live. Every step was documented and individually verified, but
never walked in one pass by following the doc as written. This plugin is that
walk, authored from the documented three-file minimum.
It stays useful afterwards: it reads the manifest.json it was published
beside and reports what the catalog actually contains -- bundle and profile
counts, how many profiles describe themselves, and whether it is listed. If
you can see it answering, the path worked. Deliberately dependency-free: no
map engine, no token, no data API, so it can mount when everything else is
broken, which is when someone reaches for it.
The walk found a real defect in my own plugin, which is the point of doing
it. The first version wrapped the element in ProfileLoader (copied from a MAP
bundle) without createPinia. ProfileLoader builds three stores in setup(), so
setup threw -- and a thrown setup removes the subtree silently. Build green,
manifest listed it, nothing complained. Only smoke:embeds caught it: "shadow
DOM nearly empty (1 nodes)". That is exactly what Step 3b exists for and why
the doc puts it before publish.
The fix is the cap-1 pattern doxa-vision-card already uses: mount the root
SFC directly. ProfileLoader earns its cost for a map plugin; it is the wrong
wrapper for an element that draws no tiles and takes no token.
Verified: 17 -> 18 bundles, 26 -> 27 profiles, auto-discovered with no config
edit; renders 19 shadow nodes reporting live catalog state; embed suite 41/41.
Last tick fixed 15 of 26 profiles that shipped with an empty description.
Without a gate that fix has a half-life: the north star is "develop a plugin
and see it live", so new plugins arrive often and each one is a fresh chance
to regress this silently. The repo already validates @element, but only
against a MALFORMED value — an ABSENT @description fails quietly, the
generator writes an empty string, every gate passes, and the defect only
surfaces to a human who opens the studio and reads.
Reads the BUILT manifest, not the .vue sources, on purpose. Re-deriving the
description here would mean reimplementing generate-tree's TAG_RE — which is
exactly what hid two of the fifteen last tick, when a hand-written copy of
that regex reported success while the real rule extracted nothing. Check the
artifact the consumer reads, never a reimplementation of how it was produced.
MIN_CHARS is calibrated against the real corpus, not guessed: shortest
genuine description 62 chars, median 81, longest 192, so the 25-char floor
sits clear of every legitimate entry while still catching a stub.
Negative-tested four ways, including end to end: deleting a @description tag
from a .vue source FAILS THE WHOLE BUILD (exit 1) naming the profile and the
file to edit; restoring it returns the build to green. Also fails on a short
stub and on a near-empty manifest (the vacuous-pass guard), rather than
passing for lack of anything to check.