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/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. --> -