The Plugin Studio's corner button read "rudder-voice" (rega, repo: it showed in the README retake). A widget
button now reads studio catalogue key widget:<row key>, falling back to the row label; widget:rudder-voice/
rudder-voice is "Rudder — voice to text" in all 11 locales (Rudder kept as the product name).
BUILD-CORNER-WIDGET-CLASS-RUDDER-IN-PLUGIN-STUDIO (design approved by dx-shepherd). A profile's .meta.json
declares "widget": { "corner": "bottom-right" | ... }; generate-tree carries it into manifest.json, so the
Studio keeps no list. The Studio mounts each widget once in a fixed corner host (z-index above everything),
outside the rail and the pane, collapsed to a button, with its open state remembered. Picking it in the rail
opens the corner instead of mounting a second copy in a pane. The host and the mounted element live in a
page-level singleton on document.body, so a Studio hot update or remount re-attaches the same element and
Rudder keeps its live stream. rudder-voice declares bottom-right. Needs eyes: open, HMR, collapse.
The Driver: a public plugin that is not yet a production map shows under Experimental until it is. A
profile's .meta.json declares "stage": "production" | "experimental" (default experimental; a bundle is
production if any profile says so) and "heavy": true for a live WebGL owner. generate-tree carries both into
manifest.json, so the Studio and the gallery read the same field and neither keeps a list.
The seven production bundles are marked: dashboard-plus-grid, research-map-plus-grid, regions-dashboard,
my-upg-100-list, doxa-simple-map, cities-internet, gospel-resources. The Studio rail keeps its capability
sections for them and puts every experimental bundle (gospel-internet among them) in one final Experimental
section. The gallery's auto-discovered Experimental folder skips a production bundle. gospel-internet-board
joins HEAVY_ELEMENTS (it can own two WebGL contexts). check:studio-rail asserts the split.
Behind any login gate (basic auth, a session cookie, OIDC) the gallery said "Could not load Big Picture":
doxa-maps-spa fetched its same-origin manifest, and plugin-studio its token URL, with credentials:'omit', so
the gate's cookie never went and the request was refused (found on the new dev container; dx-shepherd).
Both now use 'same-origin', which never sends credentials cross-origin, so CDN and CORS behaviour is
unchanged.
check:no-credentials-omit (+ :self-test, in gates:pre) fails any credentials:'omit' in library/ or
plugin-designs/ unless the line says why (// credentials-omit: <reason>). Measured before: exactly these two
sites; it fails on the old code and passes on the fix.
The Driver's ask: one folder per BUNDLE with its ELEMENTS inside, bundles sorted by capability, highest
first. The rail listed every element flat under its own capability, so a bundle's elements were
scattered (gospel-resources' five screens sat in three sections). Now each capability section (5 → 1)
holds the bundles whose HIGHEST capability it is, in name order; a bundle with several elements is one
folder (country-posters, gospel-resources, my-upg-100-list, regions-dashboard, cities-internet), and a
single-element bundle is shown as its element. Composites keep their folded composition trees.
StudioTree identifies a plugin's row by a `canonical` flag rather than "depth 0", so auto-reveal opens
the bundle folder above an element chosen by URL or restored tab. New folder icon; 'elementsCount' and
'elements' in all 11 studio catalogues.
check:studio-rail (headless, gates:post) runs the studio's real rail code on the built manifest and
asserts the layout and one canonical node per plugin (21 bundles, 33 elements); it fails on the previous
rail. Also: poster.json:dpi is declared identical on purpose in check:i18n-never-translated (a unit
symbol kept as-is by project decision), which had turned that gate red on main.
A builder previewing a plugin full screen can hide the studio header (the props panel: language, map
engine, Mapbox token). The header's new "Hide header" button collapses it, as the Research Gallery runs
headerless. A "Show header" button then stays pinned in the top corner, above every plugin, so the
props are always one click away. The choice is remembered in this browser (plugin-studio:header-hidden).
Strings in all 11 studio catalogues. BUNDLE=plugin-studio builds.
Driver, 2026-09-25: "the Mapbox token menu is not z-index on top … it is underlapping the plugins". The
header row and the pane area were siblings with no stacking context of their own, so a menu at z-index 30
lost to plugin chrome (a filter bar at 55, a drawer at 1000). The header now stacks above the content and
the pane area is isolated (isolation:isolate), so no plugin's z-index can rise over the studio's header.
Measured in a browser with a plugin open: the Settings panel and the engine menu are the topmost element at
3 of 3 sampled points.
Driver, 2026-09-25: "the studio at the top needs a localization dropdown, a map dropdown to swap
between Mapbox and MapLibre, and a settings button to add a Mapbox token or the URL it comes from…
the header needs to let you swap between props… so the studio becomes more functionally useful."
The header now reads: Language ▾ · 🗺 Map engine ▾ · ⚙ Settings.
- Map engine: Mapbox (needs a token) / MapLibre (no token needed) / each map's own default. The choice
is handed to every pane as its profile-config provider and published as window.MAP_PROVIDER for
maps nested inside composites; changing it re-mounts the maps.
- ⚙ Settings replaces the separate 🔑 button: the Mapbox public token (sk.… refused) or a token-service
URL, with a status line that says which is in use — and that MapLibre needs none.
- Kept in this browser (localStorage); one menu open at a time. New labels in all 11 locales.
Browser-tested on the built dx-maps tree: the header shows the three controls; MapLibre sets the pane's
provider and window.MAP_PROVIDER and survives a reload; Settings then says no token is needed; Mapbox
plus a pasted token puts both on the pane; "each map's own" clears the provider.
Driver request, 2026-09-25: "add a spot on the studio for placing a Mapbox token, or setting the URL
for where the Mapbox token API is from, like doxa.life has an API token".
A 🔑 Map token button in the studio header opens a small panel:
- paste a Mapbox PUBLIC token (pk.…) — a secret sk.… token is refused, the page is public;
- or a token-service URL: the studio fetches it (JSON {token|tk|accessToken} or a bare string, the
leaf pages' contract) and uses the pk.… it returns. The maps never read a token URL themselves, so
the studio resolves it and hands them the token;
- Clear returns to the token built into the page. A dot and a status line say which is in use.
Precedence: your token > a token from your URL > the host's tk > the page's built-in token. Kept in
this browser's localStorage only; applying re-mounts the open map and publishes window.MAP_TOKEN for
maps nested inside composites. Labels in all 11 locales (i18n parity PASS).
Tested in a browser on the built studio: shows the built-in source; sk.… refused and not stored; a
pasted pk.… reaches the pane's profile-config and window.MAP_TOKEN and survives a reload; Clear
restores the built-in token; a token URL is fetched and applied. https://doxa.life/api/maps/token is
refused cross-origin from another site — the panel says so and keeps the current token.
A host that embeds either composite with profile-config.locale was ignored (check:host-locale-contract:
0 Arabic characters with locale "ar", against 2121 / 2249 at ?lang=ar).
- stack-demo mounts its children by tag and already threaded tk and provider; locale now rides the
same way to the research dashboard, and the grid (which reads cfg.locale itself) gets it only when
the host gave one. 0 -> 358: the grid half. The dashboard half waits on its own bundle providing
appI18n.
- plugin-studio seeded its language from localStorage / the browser and handed that to every pane.
The ladder is now host profile-config locale/lang -> ?lang= / ?locale= -> saved choice -> browser;
a host- or URL-given language is not persisted as the reader's. 0 -> 893 (its own chrome); the rest
is the Research Gallery pane it opens on, which does not pass the locale on yet.
- plugin-studio's language switch rebuilt a pane's profile-config by hand without provider, tokenUrl
and treeBase, so switching language turned a maplibre pane back into a mapbox one. Mount and
switch now share one paneConfig().
inheritAttrs:false on the studio root, so a host's attributes (style,
class, id) stay on the <plugin-studio> element instead of also landing
on the layout inside it: the trap that made the Prayer Walk Planner
76px tall in the gallery. Layout verified unchanged in four hosts on the
built bundle: its own page (shell 1280x900, body 836), a bare partner
snippet (85vh default), a host that sets its own inline height (the
style stays on the host; the shell root has no style attribute), and
the app-shell mounter from BUG-EMBEDDED-PLUGIN-STUDIO-BLACK-SCREEN,
display:block forced on the host: body 656px, rail and tree rendered.
That bug no longer reproduces on the dx-maps side.
internal/measure-map-load.mjs: per production map, the cold, throttled
waterfall (bundle, engine script, style, data, basemap up, visible
tiles loaded) on a served built tree, from the leaf page or, with
--embed, from a partner page that pastes only the embed snippet. It
showed that Vue is already inlined in every bundle and that the engine
waits on the bundle only on partner pages.
A profile's `element` in the manifest is copied from its @element JSDoc,
and that can name a tag nothing registers. country-posters-mfe's says
<country-posters-mfe>, but country-posters' index.js defines only
<country-posters-map>, so the built studio showed "never registered"
on a working plugin (gospel-dashboard had the same shape until
5418a79c). The studio now uses a profile's element only when the
bundle's `elements` (what index.js actually registers) includes it, and
otherwise the bundle's defined element.
Across all 33 manifest profiles this is the only mismatch, and its
published embed snippet already uses the right tag. Verified on the
built tree: country-posters-mfe mounts <country-posters-map> with
content (2,025 elements).
rega's :dir(rtl) mirror for VerticalTabsLayout (5e7f73d0) compiled into
the studio, but nothing set the direction, so the studio stayed
left-to-right in Arabic. The studio now sets dir from the language
list's own dir flag, as doxa-maps-spa does.
The Explorer tree's own CSS assumed left-to-right: indentation, indent
guides, the active-row bar, the part count and the slim-rail activity
icons used left/right properties. They now use logical properties
(padding-inline, inset-inline-start, border-inline-start,
margin-inline-start), so they mirror, and a closed twisty points toward
the start of the line.
Checked at 1280x900: in Arabic the rail sits on the right, the active
bar on its right edge, the guides on the right, and doxa-simple-map
still renders its map; in English the geometry is unchanged (rail 0..256,
stage from 268).
Choosing a language in the studio translated the plugins it mounts but
left the studio itself in English: the title, the count, the filter box,
Collapse all, the section names, Copy embed, the loading and error
messages, and the rail's own Collapse button and aria labels.
The studio's strings now live in library/i18n/locales/<lc>/studio.json
for all 11 locales, held to key parity by check:i18n-parity like every
other catalog. The studio has no vue-i18n instance, so it looks strings
up directly (locale, then English, per key). The Explorer tree takes
them through a `strings` prop. VerticalTabsLayout gains an optional
`labels` prop for its five words; hosts that do not pass it keep the
English defaults, so doxa-maps-spa is unchanged until it opts in.
Verified by switching the studio through every language its switcher
offers (en, es, fr, pt, ar, ru): no English left in the studio chrome.
Technical names (CAP n, element tags, tier machine names in tooltips)
stay as they are.
Two gaps found by opening every plugin in the studio under the dev
server:
- key-cities is gated (vite.doxa-maps-build-config.json gatedProfiles)
out of every public surface the build emits, but the dev manifest
listed it anyway, so the dev studio and the Research Gallery's
Experimental shelf showed a plugin the built tree deliberately hides.
The dev model now applies the same gate: 21 bundles, 33 profiles, as
in the built manifest. readBuildConfig() does not carry gatedProfiles,
so the gate is read from the config file, as generate-tree does.
- golden-path-check takes its tree base from a tree-base attribute, not
from profile-config. The studio only passed the latter, so inside the
studio the check guessed the tree from the page URL, fetched
/manifest.json, got a 404 and showed an empty self-test. The studio
now sets tree-base on every element it mounts; elements that do not
read it ignore it.
Dev sweep after: all 31 listed plugins mount with content, no page
errors, no failed requests. Rebuilt the plugin-studio bundle and
re-baked its SRI.
The studio's rail was hand-rolled vertical tabs stretched into a tree.
It is now an Explorer tree modelled on the VS Code Explorer, keyboarded
per the WAI-ARIA tree pattern:
- CAP 5 -> CAP 1 sections with sticky headers; one-line rows with the
name and a muted element tag; twisty on the left; indent guides.
- Composites fold; their parts hang beneath, one level at a time, each
part opening the plugin's one canonical entry.
- A filter box on top shows matches together with the path to them.
- One view action, Collapse all.
- Auto-reveal: opening a plugin by click, URL hash or remembered tab
scrolls its row into view and opens its section.
- Arrow keys walk the tree, Right/Left open, close and move between
parent and child, Enter opens, a letter jumps to the next name.
- The slim rail becomes an activity bar with one icon per section.
- Fold state is remembered per page.
The tree lives in plugin-designs/plugin-studio/src/StudioTree.vue and
fills VerticalTabsLayout's new #rail slot, so the layout still owns the
collapse toggle, mobile drawer and slim strip. The alias, depth and
branch row fields added to the layout earlier today are removed again:
nothing else used them.
The studio host now has a default height (85vh, min 480px), as
doxa-maps-spa does. Pasting the published embed snippet into a plain
page mounted a complete studio 0px tall. A page's own height or
--plugin-studio-height still wins.
The studio gates that click tabs by text now also match tree rows.
Rebuilt the plugin-studio bundle and re-baked its SRI.
The studio listed the app shell by its profile id, doxa-maps-spa. It now
shows the Driver's name for it, Research Gallery, in the rail, in every
composition tree and in the title bar. Display only: the row key, the URL
hash, the remembered tab and the <doxa-maps-spa> tag under the name are
unchanged.
Rows now carry the real profile id separately from the label, because the
mount handed the label to the element as its profile; a display name
there would have mounted the shell with a profile it does not have.
With every composition tree open, the rail was too long to find your way
around. Each composite now carries a chevron and starts folded, so the
rail first reads as the 35 plugins by capability, each composite saying
how many parts it has. Open one and you see its direct parts; a part that
is itself a composite folds again, so doxa-maps-spa opens to its tabs and
each tab opens to what it is made of, one level at a time. Choosing a
composite also opens it; only the chevron closes it. The same child folds
independently wherever it appears.
VerticalTabsLayout gains two optional row fields: `branch` makes a row
foldable, `ancestors` hides a row until every branch above it is open.
Rows are wrapped so the chevron can sit beside the tab rather than
inside it. doxa-maps-spa sets neither field and renders unchanged.
Extending the composite embed fix to the two remaining composites that a partner
can mount, plus two red gates that arrived on main.
plugin-studio already threaded tk, tokenUrl, locale and treeBase into every pane
it mounts — but not `provider`, so a studio embedded with provider=maplibre
showed mapbox panes and, on a keyless host, panes asking for a token. It now
forwards the engine the same way, validated to the two known values, and accepts
it as an attribute as well (a host showing plugins to partners wants to flip both
at once).
stack-demo declared no props at all, the same hole the two shipped composites
had: <stack-demo profile-config='{"tk":"…"}'> had its token read by nobody, and
on a page with no baked token the nested dashboard reported "No Mapbox access
token provided". It now parses the host config, publishes the token page-wide and
threads tk + provider into the child it mounts by tag.
MEASURED on a foreign origin, minimal config: stack-demo [tk] canvas 1 with 13
api.mapbox.com requests; stack-demo [provider] canvas 1 with ZERO. plugin-studio
[tk] canvas 1 with 19.
★ ONE LEVEL DEEPER, NOT MINE, MEASURED AND REPORTED: plugin-studio still shows
mapbox tiles under provider=maplibre, and the pane chain says why — the studio
hands provider=maplibre to <doxa-maps-spa> correctly, and the SPA then mounts
<dashboard-plus-grid> with a config carrying tk and NO provider. The cap-5 shell
drops it. Same three lines fix it there; routed to its owner rather than reached
into.
Also fixed two gates that were red on main and blocking every builder:
check:script-flags check:gallery-embed advertised --mode/--only in an
inline comment its command ends with, so the flags
could never arrive and the gate would run its default
and exit 0 looking like success. Gave the modes their
own entries and stopped the parent advertising what it
cannot receive.
check:capability-exemplars the generated table had drifted from the manifest
(a legacy bundle arrived); regenerated, 35 rows match.
The separate "What each composite is made of" part is gone. It started
over at CAP 5 below the CAP 1 singles, so reading down the rail jumped
back up the ladder. Each composite's composition tree now sits directly
under that composite's own row, inside its capability section, so the
whole rail reads CAP 5 -> CAP 1 once: doxa-maps-spa with its six tabs and
what they are made of, then each CAP 4 composite with its parts, then
the multi-view, preset and single elements.
Tree rows are still aliases of each plugin's one canonical row, arrow
keys still visit each plugin once, and section counts count plugins, not
repeats. An unpublished part now sorts after every published child.
The "Composed of" panel above the preview is gone. It sat between the
title and the plugin and took the plugin's space; composition already
lives in the rail's "Inside <composite>" trees. The toolbar is back to
one line: name, element tag, Copy embed.
big-picture is no longer listed. It is a dark header of search plus four
filters over the people-groups grid, with the grid's own identical
controls switched off: in the studio it read as the grid a second time
under a misleading name. The real Big Picture is dashboard-plus-grid,
and the app shell already hides big-picture for the same reason. The
bundle is still built and embeddable. The studio's own composes list now
names dashboard-plus-grid instead of bp-big-picture.
The rail now has two labelled parts. "Every plugin, by capability" keeps
one node per plugin in collapsible CAP 5 -> CAP 1 sections. "What each
composite is made of" adds one collapsible section per composite, headed
by the composite, with every element nested into it drawn as an indented
tree at full depth, ordered by capability at each level. A child repeats
in every composite that nests it, as specified.
Repeats are aliases, not new nodes: clicking people-groups-grid under
big-picture selects the same canonical entry as clicking it in CAP 2 and
highlights every copy, so each plugin still has exactly one name. Arrow
keys walk only the canonical rows. A declared child that is published
nowhere (big-picture's bp-research-header) shows as a "part" row, so the
header/grid split inside Big Picture can be named.
The dev server's manifest never carried `composes` edges, so under
`bun x vite` the Composed-of panel was empty for every composite. It now
publishes them from the same meta.json source as the built manifest.
Also: bare sibling profile refs (gospel-resources composes "data-tables")
now resolve, and rail rows use local SVG icons per capability instead of
emoji that rendered as empty boxes without an emoji font.
VerticalTabsLayout gains two optional row fields (`selects`, `depth`) and
shows badge/count on folder headings; existing consumers set neither.
A "Composed of" panel now sits under the plugin title, above the live
preview. For a composite it lists one row per directly-nested child:
name, element tag, and a capability badge, each row a link to that
child's own entry. A leaf plugin shows no panel.
Composition is no longer navigation. The rail had carried a collapsible
folder per composite, which re-listed children that already had their
own home — people-groups-grid held six nav nodes, so there was no single
name to call it by when reporting a problem. The rail is back to one
node per plugin in its own capability tier (31 nodes, 31 manifest rows,
none repeated) and the relationship lives on the plugin's own page.
Depth is one. A nested composite shows as a single row and does not
expand its own children; its breakdown is one click away, on its page.
The badge reads the manifest's declared profile type, so retyping a
profile moves its badge with no code change here. A declared child that
is published nowhere is counted as "not published" beside the heading
rather than dropped in silence — big-picture names bp-research-header,
which is an internal part, not a plugin. The list caps its own height:
the layout's header row is auto-sized, so ten uncapped rows would have
taken height from the preview instead of overflowing.
Rows are buttons with descriptive aria-labels, reachable and activatable
by keyboard. No emoji glyph on a row: this box has no emoji font, so
every one rendered as an empty box, and the badge says it better.
Every composite bundle (one whose manifest entry carries `composes`) now
gets its own collapsible folder in the rail, with the composite itself as
the top row and its composed children underneath, grouped/labelled the
same way the existing capability-tier rail already is. Reuses the
existing folder collapse/expand mechanism (VerticalTabsLayout, the same
one Experimental already uses) rather than inventing a second
interaction. Driven entirely from manifest.json's composes edges, so a
future composite needs no code change to appear. A child can legitimately
appear in more than one section (e.g. people-groups-grid nests into four
different composites) — that duplication is the point, not a bug.
The Plugin Studio header read "18 bundles · 27 elements" while the manifest
holds 19 and 28. The arithmetic was always right: rowsFromManifest skips the
studio itself, because it cannot be one of its own tabs, and 19-1 / 28-1 is
exactly what the header showed.
The problem was that nothing said so. The golden-path self-test — linked from
the golden-path doc as of this flight — reports 19 bundles and 28 profiles from
the same manifest. Two authoritative surfaces disagreeing by one, neither
explaining why, is how a reader stops trusting both; and the studio is the first
thing an admin agent opens, so a number that looks wrong there discredits
everything beside it.
"listed here" is the whole change. It names the population, so the difference
from the catalog total reads as a deliberate exclusion rather than an off-by-one.
The count stays derived from this.rows, which is the honest source — it is a
statement about what the studio shows, and now it says that.
Verified the exclusion accounts for the gap exactly before touching anything:
manifest 19/28, minus the single plugin-studio bundle and its one profile, is
18/27. Then read the rendered header at 1600px — 220px wide, sitting beside the
language selector without crowding it — and confirmed it in the regenerated
screenshot.
Nine gates pass, including check:synced-css: there are two .ps-count rules in
this file, the synced copies that must stay byte-identical, and this change is
JS rather than CSS so both are untouched.
Same shape as the self-test scoping documented earlier today: a number that is
correct for the question it answers, with nothing saying which question that is.
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.
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.
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.
check:shell-scroll went back to "wheel moved 0px, grid UNREACHABLE" with
2586px of grid mounted. The cause is not a later edit undoing a committed fix:
git history shows .vtl-host never had overflow-y:auto in any commit. Seven
commits, all `position:absolute; inset:0`.
The fix only ever existed in a working tree. An agent edited the file live,
the gate passed against those uncommitted bytes, and a second agent editing
the same file overwrote them before anything was committed. Every observation
was true and none of it was durable. Worse, 762953a was committed while the
tree was in the reverted state and described as landing the scroll fix.
Restored on .vtl-host and .ps-pane, both synced copies of each, and the reason
is now in the file rather than in a session transcript: the composite reports
scrollHeight 2838 against clientHeight 769 here, 2069px of mounted grid with
nowhere to go, while the document measures 900 === 900 so a wheel anywhere has
nothing to move. It is a host gap, not a plugin bug -- the same composite is
perfect standalone because its generated leaf gives the document the scroll.
The comment says the line has been lost once and points at the gate, because
`inset:0` looks like the simpler form and someone will reach for it again.
Committing before re-verifying, deliberately. A gate result is only as durable
as the bytes it ran against, and this bug is the proof.
Sixteen source files from five agents, all verified against source gates
before commit: template-literal, sfc-parse, kpi-unit, composite-reg,
facet-cascade, one-palette, script-flags, no-public-binds, no-orphan-gates and
quoted-scripts all pass. public-hygiene reports 0 findings.
Colour. The choropleth ramp is now derived by interpolating the product's own
tokens (surface #f3f3f1 to accent #92b195) rather than adding a sixth grey, and
that fixes a pincer the earlier research had not named: on the old grey-to-black
ramp the navy dot and the white ring failed at OPPOSITE ends, so no fixed pin
colour could win. Worst-case dot contrast moves 1.13 to 7.29. Second-order
breaks fixed with it -- the country outline would have measured 1.08-1.82 as
white on the pale fill, so it is #6d766e at 2.59-4.34, and the legend swatch
border.
Religion colours no longer key by array index. colorFor takes no index at all;
colour comes from the registry by facet plus value. All 31 distinct live
religion labels now match the map, 0 mismatches. Tree-rail grey rows 142 to 0
against the live 2,107-row API.
Cascade. Clearing a parent facet now clears its descendants, in the selection
bus rather than in any component, because every clear path already funnels
through setFilter/clearFilters -- a component fix would have fixed one button
and left four. The hierarchy is derived from config.facetRail[].drill, so it
cannot drift from the tiers the rail renders. Place turned out to be three deep
(region, block, country), not two.
Rails. Tree rows wrapping 18/66 to 3/66; the tree rail is now the wider of the
two at 296 vs 264, with the map cell unchanged because both pairs sum to 560.
Also lands the shell scroll fix and the facet-cascade gate with its committed
fixture -- 73 sampled rows, no network, so the assertion that reproduces the
empty map runs on a fresh clone instead of silently skipping.
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.
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.
More than half the catalog had an empty description in manifest.json, so the
plugin-studio — the surface a Doxa inheritor browses to learn what exists —
showed flagship plugins as a bare "bundle - <tag>" with no explanation, while
a demo bundle explained itself fully.
Cause: the generator reads a JSDoc @description TAG, and these files carried
only prose. Every description added here is a summary of what that file's own
header already documented, not new invention. Claims were checked against the
artifacts: doxa-simple-map's "three presets" was verified against
simple-map.instances.json (prayer, engagement, adoption).
The two cap-5 shells needed a second pass. Their headers are HTML comments
with no leading asterisk, and the real TAG_RE requires `* @tag value`, so the
first attempt extracted nothing.
Result: 26/26 profiles described, 0 empty; the built gallery went from 15
empty / 11 populated to 0 / 26. Verified in a browser, not just in the
manifest. Embed suite 40/40.