From 32a5172ca0a1602f0ccec05d8d2b460d17bea0ab Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 29 Jul 2026 21:02:29 +0200 Subject: [PATCH 1/3] fix(#568): stop the Dashboard tree revealing two rows' actions at once, and put the count back inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .dash-tree-row:focus-within .dash-tree-act revealed a row's pencil/trash on ANY focus, so a merely-clicked (selected) row kept its actions visible forever alongside whatever row the pointer was hovering. Swapped to :has(:focus-visible), which real keyboard Tab/Arrow still grants but a pointer click does not, so exactly one row's actions show at a time while keyboard reachability is unchanged. Separately, the `· N` count was a flex sibling of a flex:1 label, so it (and everything after it) got pushed into a right-aligned cluster instead of sitting inline after the name as dashboard-tree-model.ts already documented as the intent and the Library tab already does. Label + count now share one flex group so only that group grows. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MbJW6B535ebEZjZRgaBZy8 --- CHANGELOG.md | 12 +++++++++++ src/styles.css | 36 +++++++++++++++++++++++++++----- src/ui/dashboard-tree.ts | 7 ++++++- tests/e2e/dashboard-tree.spec.js | 26 +++++++++++++++++++++++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0881d439..cfaf6032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,18 @@ auto-generated per-PR notes; this file is the curated, human-readable history. adapter refactor — no user-visible behavior changes. ### Fixed +- **The Dashboard tree no longer reveals two rows' pencil/trash actions at + once, and its `· N` count now sits inline after the label** (#568). The + hover/focus reveal rule (`.dash-tree-row:focus-within .dash-tree-act`) + matched on any focus, so a row a user had merely clicked to select kept its + actions visible indefinitely, alongside whichever other row the pointer was + hovering — swapped to `:has(:focus-visible)`, which only a real keyboard + Tab/Arrow grants, preserving reveal-on-keyboard-focus without pinning a + clicked row open. Separately, `Sales revenue · 2` read as a right-aligned + column rather than immediately after the name (`Library · 66`'s placement, + as `dashboard-tree-model.ts` already documented as the intent): the label + and count are now wrapped in one flex group so only that group grows, + matching the narrow-sidebar test's own claim. - **A detached Data pane's variable dropdown no longer shows a frozen snapshot of recent values** (#478 follow-up). #555's caller-neutral `VariableBarApp` adapter copied `varRecent` into `state` as a plain data diff --git a/src/styles.css b/src/styles.css index 7b507d06..a0a68f03 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1720,7 +1720,13 @@ body.detached-tab .graph-overlay-panel { .dash-tree-row { position: relative; } /* The group rows (Variables / Panels) are structure, not content. */ .dash-tree-group { color: var(--fg-faint); font-weight: var(--fw-medium); } -.dash-tree-count { margin-left: 2px; font-size: var(--text-label); } +/* `flex-shrink: 0` + `white-space: nowrap`: inside `.dash-tree-label-group` + below, an unbounded `.label` (its flex-basis is its full, untruncated text + width) would otherwise absorb almost none of the shrink budget itself, + leaving this tiny "· N" to take the rest — squeezing it down to a couple of + pixels and wrapping "· 2" onto two lines. The count stays fixed-width; the + label does all the shrinking, same as it always ellipsizes. */ +.dash-tree-count { margin-left: 2px; font-size: var(--text-label); flex-shrink: 0; white-space: nowrap; } /* CURRENT RESOURCE vs KEYBOARD FOCUS must read differently (#426): the current Dashboard/member is a persistent tonal surface with an accent edge, while keyboard focus is the transient ring. A row can legitimately be both. */ @@ -1810,15 +1816,25 @@ body.detached-tab .graph-overlay-panel { opacity: 0; pointer-events: none; } /* Revealed on hover like the Library row actions, but ALWAYS in the keyboard - focus order: `:focus-within` makes the visually concealed cluster visible - while the row or one of its controls owns focus, so no operation is - keyboard-unreachable. + focus order: `:has(:focus-visible)` makes the visually concealed cluster + visible while the row or one of its controls holds KEYBOARD focus, so no + operation is keyboard-unreachable. Deliberately not `:focus-within`, which + matches on any focus regardless of modality — a row a user merely CLICKED + to select keeps DOM focus, so `:focus-within` kept its actions revealed + forever, alongside whichever row the pointer was hovering, showing two + pencil/trash pairs at once. Chromium (and other engines) withhold + `:focus-visible` after a pointer interaction but grant it for real keyboard + navigation — Tab/Arrow keys — even when the resulting focus is set + programmatically from a keydown handler (see the Tab-walk and + keyboard-delete tests in `tests/e2e/dashboard-tree.spec.js`), so this still + reveals the cluster for a sighted keyboard user and only for one row at a + time. `[aria-expanded="true"]` holds it visible for its own dialog/confirmation's whole lifetime — both are body-mounted, so by the time one closes the pointer and focus have left the row, and an invisible trigger must not become unreachable before focus can return to it. */ .dash-tree-row:hover .dash-tree-act, -.dash-tree-row:focus-within .dash-tree-act, +.dash-tree-row:has(:focus-visible) .dash-tree-act, .dash-tree-act[aria-expanded="true"] { opacity: 1; pointer-events: auto; } /* #447's orphan-variable trash stays ALWAYS visible: it is the only way to remove stored SQL nothing references any more, and a hover-only affordance @@ -1914,6 +1930,16 @@ body.detached-tab .graph-overlay-panel { } .tree-row .meta.loading { font-style: italic; opacity: .6; } +/* Dashboard tree count reads immediately after the label — matching Library's + `Library · 66` placement — instead of inheriting the schema tree's + flex:1 label above, which pushes trailing content to the row's far right + edge. Only the GROUP grows; the label inside it shrinks/ellipsizes and the + count stays a fixed-width neighbor. Placed after `.tree-row .label` above + so this wins the `flex` override at equal selector specificity, without + touching the schema (Databases) tree's own `.tree-row .label` behavior. */ +.dash-tree-label-group { display: flex; align-items: center; min-width: 0; flex: 1; } +.dash-tree-label-group .label { flex: 0 1 auto; min-width: 0; } + /* Column name/type independent drag targets (#186). Only these two child spans are drag sources on a column row — the row-level `:active grabbing` above no longer applies to columns (overridden just below, scoped to `.mono` diff --git a/src/ui/dashboard-tree.ts b/src/ui/dashboard-tree.ts index d7b2c04c..fe92d873 100644 --- a/src/ui/dashboard-tree.ts +++ b/src/ui/dashboard-tree.ts @@ -674,6 +674,11 @@ function buildRow( const count = row.count === null ? null : h('span', { class: 'side-count dash-tree-count' }, '· ' + row.count); + // The label and its count grow as ONE group, so the count reads right after + // the name (`Sales revenue · 2`) like the Library tab's `Library · 66` — + // rather than as a sibling of a flex:1 label, which pushes it into the + // right-aligned cluster with `meta`/actions. + const labelGroup = h('span', { class: 'dash-tree-label-group' }, label, count); // #447: the WORD, not only a colour — an unused (orphaned) variable says so in // text, right after its name. const status = row.invalid === 'variable-unused' @@ -737,7 +742,7 @@ function buildRow( syncRovingTabindex(rowEl.parentElement, row.key); pressRow(app, row, event.shiftKey); }, - }, chevron, h('span', { class: 'icon' }, rowIcon(row)), label, count, status, + }, chevron, h('span', { class: 'icon' }, rowIcon(row)), labelGroup, status, h('span', { class: 'meta' }, row.meta), marker, // #494: the trailing DIRECT controls, in the model's own order — edit // before delete, destructive rightmost. No tree ROW carries a `⋯` any more. diff --git a/tests/e2e/dashboard-tree.spec.js b/tests/e2e/dashboard-tree.spec.js index 55ee127b..10ef31ec 100644 --- a/tests/e2e/dashboard-tree.spec.js +++ b/tests/e2e/dashboard-tree.spec.js @@ -921,6 +921,23 @@ test.describe('direct row actions (#494)', () => { await expect(row.getByRole('button', { name: 'Delete dashboard Ops latency' })).toBeFocused(); }); + // The bug this guards: `:focus-within` reveals a row's actions on ANY + // focus, including the focus a plain CLICK leaves behind on the row it + // selected — so that row's pencil/trash stayed revealed indefinitely, + // alongside whichever OTHER row the pointer moved on to hover next. Only + // real keyboard focus (`:focus-visible`) should pin a row's actions open + // once the pointer has left it. + test('clicking a row to select it does not leave its actions revealed once the pointer hovers another row', async ({ page }) => { + await open(page); + await roleTab(page, 'Dashboards').click(); + const clicked = treeRow(page, 'workspace:ops'); + const hovered = treeRow(page, 'workspace:long'); + await clicked.click(); + await hovered.hover(); + await expect(clicked.locator('.dash-tree-act').first()).toHaveCSS('opacity', '0'); + await expect(hovered.locator('.dash-tree-act').first()).toHaveCSS('opacity', '1'); + }); + test('the dialog announces itself as a modal named by its heading', async ({ page }) => { await open(page); await roleTab(page, 'Dashboards').click(); @@ -1125,6 +1142,15 @@ test.describe('Dashboard tree counts at the narrow sidebar (#553)', () => { for (const key of ['workspace:sales', 'workspace:sales:group:variables', 'workspace:sales:group:panels']) { await expect(treeRow(page, key).locator('.dash-tree-count')).toBeVisible(); } + // "Inline after the label" is a geometric claim, not just a text/visibility + // one: the count must sit right next to the label's own box, not pushed + // into the row's right-aligned trailing cluster (meta/marker/actions). + for (const key of ['workspace:sales', 'workspace:sales:group:variables', 'workspace:sales:group:panels']) { + const row = treeRow(page, key); + const labelBox = await row.locator('.label').boundingBox(); + const countBox = await row.locator('.dash-tree-count').boundingBox(); + expect(countBox.x - (labelBox.x + labelBox.width)).toBeLessThan(8); + } }); test('dragging to <=220px hides every dot/count, but the count stays in the accessible name and actions stay reachable', async ({ page }) => { From 37ad7dedd07a631451a256800eb1cfe443ef1047 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 29 Jul 2026 21:12:55 +0200 Subject: [PATCH 2/3] fix(#568): also reveal a row's own actions on its composite tab stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :has()'s argument is an implicit descendant selector, so .dash-tree-row:has(:focus-visible) alone missed the row's OWN roving-tabindex focus (what a real ArrowDown/ArrowUp or the first Tab into the tree lands on, before a chevron or action button is reached) — verified live: arrowing onto a row left its pencil/trash at opacity 0 until you tabbed further onto a child control. Added :focus-visible (self) alongside :has(:focus-visible) (descendant), and a regression test that arrows onto a row and asserts its actions are revealed without a descendant control being focused. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MbJW6B535ebEZjZRgaBZy8 --- CHANGELOG.md | 9 ++++++--- src/styles.css | 33 +++++++++++++++++++------------- tests/e2e/dashboard-tree.spec.js | 17 ++++++++++++++++ 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfaf6032..73c2c3f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,12 @@ auto-generated per-PR notes; this file is the curated, human-readable history. hover/focus reveal rule (`.dash-tree-row:focus-within .dash-tree-act`) matched on any focus, so a row a user had merely clicked to select kept its actions visible indefinitely, alongside whichever other row the pointer was - hovering — swapped to `:has(:focus-visible)`, which only a real keyboard - Tab/Arrow grants, preserving reveal-on-keyboard-focus without pinning a - clicked row open. Separately, `Sales revenue · 2` read as a right-aligned + hovering — swapped to `:focus-visible`/`:has(:focus-visible)` (the row's own + composite tab stop and its controls, respectively — `:has()`'s implicit + descendant selector alone misses the row itself), which only a real + keyboard Tab/Arrow grants, preserving reveal-on-keyboard-focus without + pinning a clicked row open. Separately, `Sales revenue · 2` read as a + right-aligned column rather than immediately after the name (`Library · 66`'s placement, as `dashboard-tree-model.ts` already documented as the intent): the label and count are now wrapped in one flex group so only that group grows, diff --git a/src/styles.css b/src/styles.css index a0a68f03..286449b5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1816,24 +1816,31 @@ body.detached-tab .graph-overlay-panel { opacity: 0; pointer-events: none; } /* Revealed on hover like the Library row actions, but ALWAYS in the keyboard - focus order: `:has(:focus-visible)` makes the visually concealed cluster - visible while the row or one of its controls holds KEYBOARD focus, so no - operation is keyboard-unreachable. Deliberately not `:focus-within`, which - matches on any focus regardless of modality — a row a user merely CLICKED - to select keeps DOM focus, so `:focus-within` kept its actions revealed - forever, alongside whichever row the pointer was hovering, showing two - pencil/trash pairs at once. Chromium (and other engines) withhold - `:focus-visible` after a pointer interaction but grant it for real keyboard - navigation — Tab/Arrow keys — even when the resulting focus is set - programmatically from a keydown handler (see the Tab-walk and - keyboard-delete tests in `tests/e2e/dashboard-tree.spec.js`), so this still - reveals the cluster for a sighted keyboard user and only for one row at a - time. + focus order: `:focus-visible`/`:has(:focus-visible)` make the visually + concealed cluster visible while the row itself OR one of its controls holds + KEYBOARD focus, so no operation is keyboard-unreachable. Both are needed — + `:has()`'s argument is an implicit DESCENDANT selector, so `:has(:focus- + visible)` alone matches only when a control inside the row (the chevron or + an action button) is focused, not when the row's own composite tab stop + is (the roving-tabindex owner a real ArrowDown/ArrowUp or the first Tab + into the tree lands on) — that gap left the cluster invisible at exactly + the moment #472 wants a sighted keyboard user to see it waiting. + Deliberately not `:focus-within`, which matches on any focus regardless of + modality — a row a user merely CLICKED to select keeps DOM focus, so + `:focus-within` kept its actions revealed forever, alongside whichever row + the pointer was hovering, showing two pencil/trash pairs at once. Chromium + (and other engines) withhold `:focus-visible` after a pointer interaction + but grant it for real keyboard navigation — Tab/Arrow keys — even when the + resulting focus is set programmatically from a keydown handler (see the + Tab-walk and keyboard-delete tests in `tests/e2e/dashboard-tree.spec.js`), + so this still reveals the cluster for a sighted keyboard user and only for + one row at a time. `[aria-expanded="true"]` holds it visible for its own dialog/confirmation's whole lifetime — both are body-mounted, so by the time one closes the pointer and focus have left the row, and an invisible trigger must not become unreachable before focus can return to it. */ .dash-tree-row:hover .dash-tree-act, +.dash-tree-row:focus-visible .dash-tree-act, .dash-tree-row:has(:focus-visible) .dash-tree-act, .dash-tree-act[aria-expanded="true"] { opacity: 1; pointer-events: auto; } /* #447's orphan-variable trash stays ALWAYS visible: it is the only way to diff --git a/tests/e2e/dashboard-tree.spec.js b/tests/e2e/dashboard-tree.spec.js index 10ef31ec..0bc6061a 100644 --- a/tests/e2e/dashboard-tree.spec.js +++ b/tests/e2e/dashboard-tree.spec.js @@ -938,6 +938,23 @@ test.describe('direct row actions (#494)', () => { await expect(hovered.locator('.dash-tree-act').first()).toHaveCSS('opacity', '1'); }); + // `:has()`'s argument is an implicit DESCENDANT selector, so a reveal rule + // written as only `.dash-tree-row:has(:focus-visible)` would miss the row's + // OWN composite tab stop — the roving-tabindex owner a real ArrowDown/ + // ArrowUp or the first Tab into the tree lands ON, before a chevron or + // action button has been reached. That left the cluster invisible at + // exactly the moment a sighted keyboard user first arrives at a row. + test('arrowing onto a row reveals its own actions, not just a descendant control\'s', async ({ page }) => { + await open(page); + await roleTab(page, 'Dashboards').click(); + const row = treeRow(page, 'workspace:sales'); + await row.focus(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowUp'); + await expect(row).toBeFocused(); + await expect(row.locator('.dash-tree-act').first()).toHaveCSS('opacity', '1'); + }); + test('the dialog announces itself as a modal named by its heading', async ({ page }) => { await open(page); await roleTab(page, 'Dashboards').click(); From 0a78588233650a7e5ea89853dd380b627bcdbc18 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Wed, 29 Jul 2026 21:33:23 +0200 Subject: [PATCH 3/3] fix(#568): restore focus-reveal for directly-focused action buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatGPT's review of this PR (verified independently) caught a real CI regression: full-suite e2e on chromium was failing 199/200 on tests/e2e/tile-open-workbench.spec.js's "Panels-row plus" test, which programmatically .focus()es the action button directly and expects it to reveal at opacity 1 — a pre-existing contract the sibling .dash-tile-actions family's "Focus alone reveals it" test also relies on. My earlier fix only verified the single dashboard-tree.spec.js file locally, not the full e2e suite CI actually runs, so this slipped through. Root cause: :focus-visible/:has(:focus-visible) require real keyboard modality, but a bare programmatic .focus() with no preceding keyboard event doesn't grant it. Added .dash-tree-act:focus (plain, not :focus-visible) to the reveal rule: it only reveals the ONE button that itself holds focus, never a sibling or the whole row, so it cannot resurrect the original stale-row-selection bug. Also hardened the count-adjacency e2e assertion (added a lower bound; moved the upper bound off the old layout's exact 8px gap, which risked a sub-pixel-rounding false pass), split the arrow-navigation regression test to explicitly isolate the pointer-to-keyboard modality transition, added a test for the :has() descendant-focus branch specifically, and fixed a comment in the drag handler left stale by the label/count DOM restructuring. Full e2e suite (all spec files) now passes on chromium, firefox, and webkit (392/392, 8 skipped as before for unsupported Firefox isMobile emulation) — previously only the single dashboard-tree.spec.js file had been run. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MbJW6B535ebEZjZRgaBZy8 --- CHANGELOG.md | 7 +++++- src/styles.css | 25 +++++++++++++------ src/ui/dashboard-tree.ts | 2 +- tests/e2e/dashboard-tree.spec.js | 42 ++++++++++++++++++++++++++++---- 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c2c3f9..ae7999ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,12 @@ auto-generated per-PR notes; this file is the curated, human-readable history. composite tab stop and its controls, respectively — `:has()`'s implicit descendant selector alone misses the row itself), which only a real keyboard Tab/Arrow grants, preserving reveal-on-keyboard-focus without - pinning a clicked row open. Separately, `Sales revenue · 2` read as a + pinning a clicked row open. A directly-focused action button (`.dash-tree- + act:focus`, no `:focus-visible`) reveals only itself regardless of + modality, so a programmatic focus-return or a test's direct `.focus()` — + the pre-existing contract `tests/e2e/tile-open-workbench.spec.js`'s + "Panels-row plus" test already relied on — still works. Separately, + `Sales revenue · 2` read as a right-aligned column rather than immediately after the name (`Library · 66`'s placement, as `dashboard-tree-model.ts` already documented as the intent): the label diff --git a/src/styles.css b/src/styles.css index 286449b5..4685b913 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1828,13 +1828,23 @@ body.detached-tab .graph-overlay-panel { Deliberately not `:focus-within`, which matches on any focus regardless of modality — a row a user merely CLICKED to select keeps DOM focus, so `:focus-within` kept its actions revealed forever, alongside whichever row - the pointer was hovering, showing two pencil/trash pairs at once. Chromium - (and other engines) withhold `:focus-visible` after a pointer interaction - but grant it for real keyboard navigation — Tab/Arrow keys — even when the - resulting focus is set programmatically from a keydown handler (see the - Tab-walk and keyboard-delete tests in `tests/e2e/dashboard-tree.spec.js`), - so this still reveals the cluster for a sighted keyboard user and only for - one row at a time. + the pointer was hovering, showing two pencil/trash pairs at once. Ordinary + pointer focus on the ROW no longer pins it open this way — though a + different row's genuine hover and this row's genuine keyboard focus can + still be visible at once, which is correct: those are two independent, + live states, not the stale-selection bug. Chromium (and other engines) + withhold `:focus-visible` after a pointer interaction but grant it for real + keyboard navigation — Tab/Arrow keys — even when the resulting focus is set + programmatically from a keydown handler (see the Tab-walk and + keyboard-delete tests in `tests/e2e/dashboard-tree.spec.js`). + `.dash-tree-act:focus` (plain, not `:focus-visible`) is separate and + narrower: the button ITSELF holding any focus — Tab, a programmatic + focus-return once its own dialog closes, or a test's direct `.focus()` — + reveals only that one control, never a sibling or the whole row, so it + cannot resurrect the row-level bug above. This preserves the pre-existing + contract `tests/e2e/tile-open-workbench.spec.js`'s "Panels-row plus" test + and its sibling `.dash-tile-actions` ("Focus alone reveals it — a keyboard + user never hovers") already rely on. `[aria-expanded="true"]` holds it visible for its own dialog/confirmation's whole lifetime — both are body-mounted, so by the time one closes the pointer and focus have left the row, and an invisible trigger must not @@ -1842,6 +1852,7 @@ body.detached-tab .graph-overlay-panel { .dash-tree-row:hover .dash-tree-act, .dash-tree-row:focus-visible .dash-tree-act, .dash-tree-row:has(:focus-visible) .dash-tree-act, +.dash-tree-act:focus, .dash-tree-act[aria-expanded="true"] { opacity: 1; pointer-events: auto; } /* #447's orphan-variable trash stays ALWAYS visible: it is the only way to remove stored SQL nothing references any more, and a hover-only affordance diff --git a/src/ui/dashboard-tree.ts b/src/ui/dashboard-tree.ts index fe92d873..451498e2 100644 --- a/src/ui/dashboard-tree.ts +++ b/src/ui/dashboard-tree.ts @@ -576,7 +576,7 @@ function dropProps(app: DashboardTreeApp, row: DashboardTreeRow): Record { - // The row has eight child spans and drag events bubble, so a bare + // The row has several child spans and drag events bubble, so a bare // `dragleave` fires every time the pointer crosses onto one of them. const to = event.relatedTarget; const rowEl = event.currentTarget as HTMLElement; diff --git a/tests/e2e/dashboard-tree.spec.js b/tests/e2e/dashboard-tree.spec.js index 0bc6061a..a1f3d489 100644 --- a/tests/e2e/dashboard-tree.spec.js +++ b/tests/e2e/dashboard-tree.spec.js @@ -945,13 +945,37 @@ test.describe('direct row actions (#494)', () => { // action button has been reached. That left the cluster invisible at // exactly the moment a sighted keyboard user first arrives at a row. test('arrowing onto a row reveals its own actions, not just a descendant control\'s', async ({ page }) => { + await open(page); + await roleTab(page, 'Dashboards').click(); + const clicked = treeRow(page, 'workspace:sales'); + const next = treeRow(page, 'workspace:ops'); + // Establish POINTER modality first — a real click, the way a user actually + // lands keyboard focus on a row in practice — so the ArrowDown below is + // the thing that flips modality to keyboard, not a leftover from `open()`'s + // own setup. This isolates the historical cross-engine failure mode: a + // pointer→keyboard transition that lands on a DIFFERENT row entirely. + await clicked.click(); + expect(await clicked.evaluate((el) => el.matches(':focus-visible'))).toBe(false); + await page.keyboard.press('ArrowDown'); + await expect(next).toBeFocused(); + expect(await next.evaluate((el) => el.matches(':focus-visible'))).toBe(true); + await expect(next.locator('.dash-tree-act').first()).toHaveCSS('opacity', '1'); + }); + + // The DESCENDANT half of the reveal rule — `.dash-tree-row:has(:focus- + // visible)` — is exercised separately from the row's own composite tab stop + // above: Tab onto the chevron (a control, not the row, and not an action + // button, so `.dash-tree-act:focus` cannot be why this passes) and confirm + // the cluster stays visible with the pointer elsewhere. + test('tabbing onto the chevron reveals the row\'s actions via the descendant :has() branch', async ({ page }) => { await open(page); await roleTab(page, 'Dashboards').click(); const row = treeRow(page, 'workspace:sales'); await row.focus(); - await page.keyboard.press('ArrowDown'); - await page.keyboard.press('ArrowUp'); - await expect(row).toBeFocused(); + await page.keyboard.press('Tab'); + const chevron = row.locator('.dash-tree-chev'); + await expect(chevron).toBeFocused(); + expect(await chevron.evaluate((el) => el.matches(':focus-visible'))).toBe(true); await expect(row.locator('.dash-tree-act').first()).toHaveCSS('opacity', '1'); }); @@ -1161,12 +1185,20 @@ test.describe('Dashboard tree counts at the narrow sidebar (#553)', () => { } // "Inline after the label" is a geometric claim, not just a text/visibility // one: the count must sit right next to the label's own box, not pushed - // into the row's right-aligned trailing cluster (meta/marker/actions). + // into the row's right-aligned trailing cluster (meta/marker/actions). The + // intended gap is the count's own 2px margin; the OLD (buggy) layout's gap + // was exactly `.tree-row`'s 6px flex `gap` PLUS that 2px margin = 8px, so + // the upper bound sits well clear of 8 rather than grazing it (a boundary + // right on the old value risks a sub-pixel rounding false pass), and a + // lower bound catches the count overlapping the label instead of merely + // failing to be far away from it. for (const key of ['workspace:sales', 'workspace:sales:group:variables', 'workspace:sales:group:panels']) { const row = treeRow(page, key); const labelBox = await row.locator('.label').boundingBox(); const countBox = await row.locator('.dash-tree-count').boundingBox(); - expect(countBox.x - (labelBox.x + labelBox.width)).toBeLessThan(8); + const gap = countBox.x - (labelBox.x + labelBox.width); + expect(gap).toBeGreaterThanOrEqual(-0.5); + expect(gap).toBeLessThan(5); } });