diff --git a/AGENT-BRIEF.md b/AGENT-BRIEF.md index 23bfdf4aa..49fc85c60 100644 --- a/AGENT-BRIEF.md +++ b/AGENT-BRIEF.md @@ -125,6 +125,31 @@ owe a complete audit. ★ **Read the image when the deliverable is visual.** Numbers have certified visually broken output in this repo more than once — a green generator is not a look-at-it. +## A pipe hides a failure + +A pipeline's exit status is its LAST command's. So this publishes a STALE bundle — new +source, old `.js` — and says nothing: + +```sh +BUNDLE=x bun x vite build 2>&1 | tail -2 && bun run tree # tail succeeded, so && proceeds +``` + +Measured on this machine, all three states: + +| Form | On a failed build | +|---|---| +| `build 2>&1 \| tail -2 && next` | **reports success** and runs `next` | +| `set -o pipefail` before it | stops, correctly | +| `build >/dev/null 2>&1 && next` | stops, correctly — no pipe, so the status is the build's | + +Every gate stays green afterwards, because `check:sri` proves the leaf and the bundle agree +with **each other**, not with the source. The same shape bit a `git merge` in this repo: +`if ! git merge … | tail` tested the pipe, so a conflicted merge read as clean. + +**Rule:** never put a build or a merge on the left of a pipe you then branch on. Redirect +instead of piping, or `set -o pipefail` first. If you want short output AND a reliable +status, capture to a file and `tail` the file afterwards. + ## Landing on main: the shape of the lock `main` is shared and serialized by `main-capture`. The ordering is load-bearing and BOTH diff --git a/documentation/readme-images/cap1-single-element.png b/documentation/readme-images/cap1-single-element.png index f0eeba79a..b31947baa 100644 Binary files a/documentation/readme-images/cap1-single-element.png and b/documentation/readme-images/cap1-single-element.png differ diff --git a/documentation/readme-images/cap2-adoption.png b/documentation/readme-images/cap2-adoption.png index 839ab645c..a9f8d5456 100644 Binary files a/documentation/readme-images/cap2-adoption.png and b/documentation/readme-images/cap2-adoption.png differ diff --git a/documentation/readme-images/cap2-engagement.png b/documentation/readme-images/cap2-engagement.png index 6fa8ee181..8301533a0 100644 Binary files a/documentation/readme-images/cap2-engagement.png and b/documentation/readme-images/cap2-engagement.png differ diff --git a/documentation/readme-images/cap2-prayer.png b/documentation/readme-images/cap2-prayer.png index 17a23f406..2a5227c94 100644 Binary files a/documentation/readme-images/cap2-prayer.png and b/documentation/readme-images/cap2-prayer.png differ diff --git a/documentation/readme-images/cap3-monolithic-research.png b/documentation/readme-images/cap3-monolithic-research.png index baf7ea68e..6e321342c 100644 Binary files a/documentation/readme-images/cap3-monolithic-research.png and b/documentation/readme-images/cap3-monolithic-research.png differ diff --git a/documentation/readme-images/cap4-upg100-list.png b/documentation/readme-images/cap4-upg100-list.png index ebd0b0f77..2c7b2c424 100644 Binary files a/documentation/readme-images/cap4-upg100-list.png and b/documentation/readme-images/cap4-upg100-list.png differ diff --git a/documentation/readme-images/cap4-upg100-mfe.png b/documentation/readme-images/cap4-upg100-mfe.png index 621dff181..71aca1a46 100644 Binary files a/documentation/readme-images/cap4-upg100-mfe.png and b/documentation/readme-images/cap4-upg100-mfe.png differ diff --git a/documentation/readme-images/cap5-doxa-maps-spa.png b/documentation/readme-images/cap5-doxa-maps-spa.png index 76fba14e0..d14073848 100644 Binary files a/documentation/readme-images/cap5-doxa-maps-spa.png and b/documentation/readme-images/cap5-doxa-maps-spa.png differ diff --git a/documentation/readme-images/cap5-plugin-studio.png b/documentation/readme-images/cap5-plugin-studio.png index 01b9f55aa..1f15fb1d0 100644 Binary files a/documentation/readme-images/cap5-plugin-studio.png and b/documentation/readme-images/cap5-plugin-studio.png differ diff --git a/documentation/readme-images/deploy1-iframe.png b/documentation/readme-images/deploy1-iframe.png index 59439dfe9..0cd39a19d 100644 Binary files a/documentation/readme-images/deploy1-iframe.png and b/documentation/readme-images/deploy1-iframe.png differ diff --git a/documentation/readme-images/shot-bundles.json b/documentation/readme-images/shot-bundles.json new file mode 100644 index 000000000..13f7d11eb --- /dev/null +++ b/documentation/readme-images/shot-bundles.json @@ -0,0 +1,42 @@ +{ + "cap1-single-element": { + "bundle": "all-people-groups", + "sha256": "d48c17dc369294301d17a4d29fc517cc7c2c2b18561b6d416ae02a9fbfb60c33" + }, + "cap2-adoption": { + "bundle": "doxa-simple-map", + "sha256": "a94db2c47d598dbc59daec6903afaac93862add9c0b7a532891b9c3c0608ce02" + }, + "cap2-engagement": { + "bundle": "doxa-simple-map", + "sha256": "a94db2c47d598dbc59daec6903afaac93862add9c0b7a532891b9c3c0608ce02" + }, + "cap2-prayer": { + "bundle": "doxa-simple-map", + "sha256": "a94db2c47d598dbc59daec6903afaac93862add9c0b7a532891b9c3c0608ce02" + }, + "cap3-monolithic-research": { + "bundle": "doxa-research-map", + "sha256": "eb305958dffd4a067de6f00155c3f396452adfc6d20e842cc9ae4f921e54f44a" + }, + "cap4-upg100-list": { + "bundle": "my-upg-100-list", + "sha256": "ff98986714934370a76742e3aa492a15ce67b959888f03092a453595103313d2" + }, + "cap4-upg100-mfe": { + "bundle": "my-upg-100-list", + "sha256": "ff98986714934370a76742e3aa492a15ce67b959888f03092a453595103313d2" + }, + "cap4-upg100-poster-wall": { + "bundle": "my-upg-100-list", + "sha256": "ff98986714934370a76742e3aa492a15ce67b959888f03092a453595103313d2" + }, + "cap5-doxa-maps-spa": { + "bundle": "doxa-maps-spa", + "sha256": "c7b1aad1e3bd4bbe936858f2159c566c1ef2e24fc9636a3446b1e10723d86502" + }, + "cap5-plugin-studio": { + "bundle": "plugin-studio", + "sha256": "c3d32d94d2df407fc35cea91faf0d46670abf52a63d37000486760361a588a5c" + } +} diff --git a/internal/check-filter-width-stable.mjs b/internal/check-filter-width-stable.mjs index fb518c521..3947eb6f7 100644 --- a/internal/check-filter-width-stable.mjs +++ b/internal/check-filter-width-stable.mjs @@ -68,9 +68,9 @@ async function measure(page, label, activateTab) { if (activateTab) { // Heavy tabs lazy-mount on activation: before the click nothing exists and every query returns // 0, which reads as "no filters here" rather than "not mounted yet". - const idx = await page.evaluate(`(${WALK})('.vtl-tab').findIndex(t=>${activateTab}.test(t.textContent||''))`) + const idx = await page.evaluate(`(${WALK})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)').findIndex(t=>${activateTab}.test(t.textContent||''))`) if (idx < 0) return { label, error: 'no tab matched ' + activateTab } - await page.evaluate(`(${WALK})('.vtl-tab')[${idx}].click()`) + await page.evaluate(`(${WALK})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[${idx}].click()`) await page.waitForTimeout(9000) } diff --git a/internal/check-fits-the-screen.mjs b/internal/check-fits-the-screen.mjs index b1fe327d6..0c97f6e6c 100644 --- a/internal/check-fits-the-screen.mjs +++ b/internal/check-fits-the-screen.mjs @@ -628,7 +628,7 @@ async function runShellBestEffort() { // already proved (dashboard-plus-grid / "Big Picture" / "Dashboard"), rather than assuming a // fixed label or index — resilient to the three-section restructure landing mid-flight. const tab = await page.evaluate(`(() => { - const cands = __walk('.vtl-tab'); + const cands = __walk('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)'); const hit = cands.find(t => /dashboard.plus.grid|big picture|dashboard/i.test((t.textContent||''))); if (!hit) return null; hit.click(); diff --git a/internal/check-fixed-anchoring.mjs b/internal/check-fixed-anchoring.mjs index 3de745121..dc3e6f4f8 100644 --- a/internal/check-fixed-anchoring.mjs +++ b/internal/check-fixed-anchoring.mjs @@ -75,9 +75,9 @@ async function hostRun(browser, label, url, activate) { // Heavy tabs are lazy-mounted on activation, so nothing exists until a tab is clicked. A flat // query before this point returns 0 triggers and reads as "no dropdowns here" — which is how // a host can silently drop out of coverage while the gate still reports success. - const idx = await page.evaluate(`(${DEEP})('.vtl-tab').findIndex(t=>${activate}.test(t.textContent||''))`) + const idx = await page.evaluate(`(${DEEP})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)').findIndex(t=>${activate}.test(t.textContent||''))`) if (idx < 0) return { label, error: 'no tab matched ' + activate } - await page.evaluate(`(${DEEP})('.vtl-tab')[${idx}].click()`) + await page.evaluate(`(${DEEP})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[${idx}].click()`) await page.waitForTimeout(9000) } const n = await page.evaluate(`(${DEEP})('.filter-dropdown__trigger').length`) diff --git a/internal/check-shot-freshness.mjs b/internal/check-shot-freshness.mjs index 9648bf6ae..3fb100eab 100644 --- a/internal/check-shot-freshness.mjs +++ b/internal/check-shot-freshness.mjs @@ -25,6 +25,11 @@ * Shots whose path is not a /doxa-maps/ leaf (the synthetic iframe host) are REPORTED as skipped * rather than dropped — an unchecked shot must be visible, not invisible. * + * CONTENT FIRST, CLOCKS ONLY AS A FALLBACK. readme-shots writes shot-bundles.json: for each shot, + * the sha256 of the bundle it was taken from. A matching hash means the picture shows exactly the + * bundle built now; a different one means it is stale. No date is involved, so a clone, a rebase + * or a retake that came out byte-identical cannot fool it. Shots with no record yet fall back to: + * * WHICH CLOCK: GIT, NOT THE FILESYSTEM, for any file that is committed and unmodified. * This compared mtimes, and a clone (or a new worktree) writes files in whatever order git checks * them out — so every fresh checkout failed, pristine main included (11 shots "2s behind" their @@ -33,11 +38,12 @@ * tarball) falls back to its mtime, because then the working copy IS the newest version. * * NEGATIVE-TESTED: appending a byte to public/doxa-maps/doxa-simple-map/doxa-simple-map.js makes - * this FAIL naming the three cap2-* shots (a dirty file is dated by mtime); reverting it returns it - * to PASS. A bare `touch` no longer fails it — a touch changes no content, so nothing is stale. + * this FAIL naming exactly the three cap2-* shots (their recorded hash no longer matches); + * reverting it returns it to PASS. A bare `touch` does not fail it: it changes no content. */ import { readFileSync, existsSync, statSync } from 'node:fs' import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { resolve, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -51,6 +57,9 @@ if (!existsSync(SHOTS_SRC) || !existsSync(OUT)) { process.exit(0) } +const SHOT_BUNDLES = join(OUT, 'shot-bundles.json') +const recorded = existsSync(SHOT_BUNDLES) ? JSON.parse(readFileSync(SHOT_BUNDLES, 'utf8')) : {} + // Dirty = modified or untracked under the two trees compared. null = git unavailable -> mtimes only. const git = (...a) => execFileSync('git', ['-C', ROOT, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) let dirty = null @@ -86,6 +95,18 @@ for (const { name, path } of shots) { if (!existsSync(bundle)) { skipped.push(`${name} (bundle ${m[1]} not built)`); continue } checked++ + // FIRST CHOICE: content, not clocks. readme-shots records the sha256 of the bundle each picture + // was taken from. Same hash = the picture shows exactly this bundle, however old either file's + // date. A different hash = the bundle changed since, so the picture is stale. This also covers + // a retake that came out byte-identical, which no clock can see: git dates an unchanged PNG by + // its old commit, and the gate called a correct, just-taken picture stale. + const rec = recorded[name] + if (rec && rec.bundle === m[1]) { + const now = createHash('sha256').update(readFileSync(bundle)).digest('hex') + if (now !== rec.sha256) stale.push(`${name}.png was shot from a different ${m[1]} bundle than the one built now`) + continue + } + // FALLBACK, for a shot with no record yet: clocks. const b = stamp(bundle), p = stamp(png) if (b.ms > p.ms) { stale.push(`${name}.png is OLDER than the bundle it shows (${m[1]}) — ` + @@ -109,4 +130,4 @@ if (missing.length || stale.length) { console.error(' A stale screenshot looks exactly like a current one — that is why this is a gate.') process.exit(1) } -console.log('SHOT-FRESHNESS: PASS — every screenshot is newer than the bundle it shows.') +console.log('SHOT-FRESHNESS: PASS — every screenshot shows the bundle that is built now.') diff --git a/internal/readme-shots.mjs b/internal/readme-shots.mjs index 1af3e9869..d0e5af3c7 100644 --- a/internal/readme-shots.mjs +++ b/internal/readme-shots.mjs @@ -12,7 +12,9 @@ */ import { chromium } from 'playwright' import { createServer } from 'node:http' -import { readFile, stat, mkdir } from 'node:fs/promises' +import { readFile, stat, mkdir, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' import { join, extname, resolve, dirname } from 'node:path' import { fileURLToPath } from 'node:url' @@ -86,6 +88,10 @@ const browser = await chromium.launch({ args: [ // ★ A SHOT THAT FAILS MUST NOT SILENTLY PRODUCE A README IMAGE. Collected and asserted at the end. const failures = [] +// documentation/readme-images/shot-bundles.json: shot name -> the sha256 of the bundle it was shot +// from. Merged, so an ONLY= run updates just the shots it took. +const SHOT_BUNDLES = resolve(HERE, '..', 'documentation', 'readme-images', 'shot-bundles.json') +const shotBundles = existsSync(SHOT_BUNDLES) ? JSON.parse(readFileSync(SHOT_BUNDLES, 'utf8')) : {} const ONLY = process.env.ONLY ? process.env.ONLY.split(',') : null // mapbox canvas selector to wait on (leaf pages mount a custom element with a canvas) @@ -183,6 +189,13 @@ async function shot(name, path, { if (clip) opts.clip = clip await page.screenshot(opts) console.log(' OK', name + '.png', `<- ${path} (${ready} elements)`) + // Record WHICH BUNDLE this picture is of, by content hash, so check:shots can tell a current + // picture from a stale one without trusting any clock (see SHOT_BUNDLES below). + const lm = path.match(/^\/doxa-maps\/([^/]+)\//) + const bundleJs = lm && join(ROOT, 'doxa-maps', lm[1], `${lm[1]}.js`) + if (bundleJs && existsSync(bundleJs)) { + shotBundles[name] = { bundle: lm[1], sha256: createHash('sha256').update(readFileSync(bundleJs)).digest('hex') } + } } catch (e) { console.log(' ERR', name, e.message.split('\n')[0]) failures.push(name + ': ' + e.message.split('\n')[0]) @@ -280,15 +293,24 @@ await shot('cap5-plugin-studio', '/doxa-maps/plugin-studio/plugin-studio/index.h before: async (page) => { // The rail lives inside the element's shadow root, so a light-DOM selector finds nothing — // click through an evaluated deep walk instead (the same seen-Set walk the readiness guard uses). + // The rail is an Explorer tree (f63d75e): its plugin rows are .st-row[role=treeitem], and a + // .st-sec row is a section heading, not a plugin. The older .vtl-tab rail is kept as a + // fallback. Without a tree row, the walk would reach the NESTED gallery's own .vtl-tab buttons + // instead, and click a tab inside the plugin rather than a plugin in the rail. await page.evaluate(() => { - const seen = new Set(); let first = null - ;(function w(n) { - if (!n || seen.has(n) || first) return - seen.add(n) - if (n.tagName === 'BUTTON' && /vtl-tab\b/.test((n.className || '').toString())) { first = n; return } - if (n.shadowRoot) w(n.shadowRoot) - for (const c of (n.children || [])) w(c) - })(document.body) + const find = (test) => { + const seen = new Set(); let hit = null + ;(function w(n) { + if (!n || seen.has(n) || hit) return + seen.add(n) + if (n.nodeType === 1 && test(n)) { hit = n; return } + if (n.shadowRoot) w(n.shadowRoot) + for (const c of (n.children || [])) w(c) + })(document.body) + return hit + } + const first = find((n) => n.matches('.st-row[role="treeitem"]:not(.st-sec)')) + || find((n) => n.tagName === 'BUTTON' && /vtl-tab\b/.test((n.className || '').toString())) if (first) first.click() }) await page.waitForTimeout(3000) // let the selected element mount before the main wait @@ -352,6 +374,7 @@ if (process.env.DXLIFE_URL) { await browser.close() server.close() +await writeFile(SHOT_BUNDLES, JSON.stringify(Object.fromEntries(Object.entries(shotBundles).sort()), null, 1) + '\n') // ★ EXIT NON-ZERO ON ANY FAILED SHOT. Previously this printed DONE unconditionally, so a run // where every map came back blank still reported success and the stale PNGs stayed in documentation/. diff --git a/internal/smoke-studio.mjs b/internal/smoke-studio.mjs index 34ce56a62..5cd101395 100644 --- a/internal/smoke-studio.mjs +++ b/internal/smoke-studio.mjs @@ -109,7 +109,7 @@ try { await page.evaluate(DEEP) const state = await page.evaluate(() => ({ present: !!document.querySelector('plugin-studio'), - tabs: window.__q('.vtl-tab').length, + tabs: window.__q('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)').length, })) // ── assertion 2: the element mounts AND its tag is defined ─────────────────── @@ -148,6 +148,10 @@ if (state.tabs === 0) { // and renders nothing scored identically to one rendering 1,397 nodes. That is the precise // "mounts is not works" trap this file's own header warns about, encoded into the check anyway. // The fix is to walk INTO the mounted element (through its shadow root) and require real depth. +// ★ "TABS" INCLUDES THE STUDIO'S TREE ROWS (2026-09-24). plugin-studio's rail is an Explorer tree +// (src/StudioTree.vue), not VerticalTabsLayout tabs, so every selector here matches BOTH shapes: +// '.vtl-tab' (nested shells like doxa-maps-spa) and the tree's treeitems minus section headers (a +// section header only folds; it opens no plugin, so sampling one would report a false "no content"). const SAMPLE_TABS = Number(process.env.SMOKE_TABS || 4) const MIN_DEPTH = Number(process.env.SMOKE_MIN_DEPTH || 5) @@ -161,7 +165,7 @@ for (let i = 0; i < Math.min(SAMPLE_TABS, state.tabs); i++) { const results = [] for (const i of idxs) { await page.evaluate(DEEP) - await page.evaluate((n) => { const t = window.__q('.vtl-tab')[n]; if (t) t.click() }, i) + await page.evaluate((n) => { const t = window.__q('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[n]; if (t) t.click() }, i) // POLL until the pane has real content rather than sleeping a fixed amount. A fixed wait forces an // ugly trade: long enough for the slowest tab makes the gate too slow to run on every edit, and // short enough to be fast marks a slow-mounting tab BROKEN. That false positive is the worse @@ -183,7 +187,7 @@ for (const i of idxs) { } await page.evaluate(DEEP) results.push(await page.evaluate((n) => { - const tab = window.__q('.vtl-tab')[n] + const tab = window.__q('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[n] const label = (tab && tab.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24) const p = window.__q('.ps-pane').find(x => !x.hasAttribute('hidden')) if (!p) return { label, none: true } diff --git a/internal/verify-shell-scroll.mjs b/internal/verify-shell-scroll.mjs index ed30067b3..861aa9c9a 100644 --- a/internal/verify-shell-scroll.mjs +++ b/internal/verify-shell-scroll.mjs @@ -96,7 +96,7 @@ const run = async () => { // query returns 0 — which reads as "no bug" if you stop here. This is the step both previous // investigations skipped. const tab = await page.evaluate(`(() => { ${WALK} - for (const t of __walk('.vtl-tab')) { + for (const t of __walk('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')) { if ((t.textContent || '').toLowerCase().includes('dashboard-plus-grid')) { t.click(); return t.textContent.trim() } } return null @@ -194,7 +194,7 @@ const run = async () => { await page2.goto(BASE + SHELL, { waitUntil: 'load' }) await page2.waitForTimeout(6000) const mapTab = await page2.evaluate(`(() => { ${WALK} - for (const t of __walk('.vtl-tab')) { + for (const t of __walk('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')) { if ((t.textContent || '').toLowerCase().includes('doxa-research-map')) { t.click(); return t.textContent.trim() } } return null diff --git a/library/layouts/VerticalTabsLayout.vue b/library/layouts/VerticalTabsLayout.vue index 7effa6e31..3de9ae823 100644 --- a/library/layouts/VerticalTabsLayout.vue +++ b/library/layouts/VerticalTabsLayout.vue @@ -24,14 +24,6 @@ (and optional `open:false`) renders a COLLAPSIBLE section (click the heading — chevron rotates); a plain group is an always-open labelled section (the "Subscriptions" style). A folder heading also shows the group's `badge` + `count` when given. - Two optional row fields let a rail show a TREE whose nodes repeat elsewhere: - depth : indent level (0/omitted = flush) — draws the row nested under the row above it - selects : the `key` of the canonical row this one stands for. An ALIAS row has its own unique - `key` (for v-for) but clicking it selects `selects`, it highlights whenever that key is - active, and it is skipped by keyboard nav so arrow keys visit each canonical row once. - branch : an id that makes this row FOLDABLE — a chevron beside it shows/hides the rows below. - Closed by default; picking the row also opens it. - ancestors : the `branch` ids above this row; it shows only while ALL of them are open. Selection is controlled via v-model:active (the active item's `key`). The host owns pane content and mount logic — this component only paints the SHELL (top bar + rail + a content frame) and exposes slots so each app keeps its unique chrome + its own mount machinery: @@ -39,6 +31,9 @@ #meta — right side of the top bar (e.g. a count, a language switcher) #toolbar — a full-width strip under the top bar, above the content (e.g. an action bar) #default — the content pane(s); the host renders its own panes and toggles them on `active` + #rail — (optional) replaces the rail body below the collapse toggle, for a host whose + navigation is not a list of tabs. Slot props: collapsed (desktop slim state), expand(), + pick(key). plugin-studio uses it for its Explorer tree. ★ NESTED-ELEMENT HOSTING (opt-in, LAZY). A row/item may instead ask THIS layout to host a nested custom element for its tab — so a shell can compose other element-plugins as its tabs. Add to a @@ -128,7 +123,8 @@ an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. --> -