From f63d75ed80d24d60b4407cb66eeeaf7d972b2160 Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:38:42 +0000 Subject: [PATCH] Plugin studio: an Explorer tree for the rail, imitating VS Code 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. --- internal/check-filter-width-stable.mjs | 4 +- internal/check-fits-the-screen.mjs | 2 +- internal/check-fixed-anchoring.mjs | 4 +- internal/smoke-studio.mjs | 10 +- internal/verify-shell-scroll.mjs | 4 +- library/layouts/VerticalTabsLayout.vue | 90 ++--- .../plugin-studio/plugin-studio.vue | 113 +++--- .../plugin-studio/src/StudioTree.vue | 373 ++++++++++++++++++ public/doxa-maps/manifest.json | 4 +- .../doxa-maps/plugin-studio/plugin-studio.js | 35 +- .../plugin-studio/plugin-studio/index.html | 2 +- .../plugin-studio/studio/index.html | 2 +- 12 files changed, 484 insertions(+), 159 deletions(-) create mode 100644 plugin-designs/plugin-studio/src/StudioTree.vue 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. --> -