diff --git a/CHANGELOG.md b/CHANGELOG.md index 9630b674..ba2b6854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,17 @@ auto-generated per-PR notes; this file is the curated, human-readable history. uses in production. Verified no raw NUL bytes remain anywhere under `src/` or `tests/`; no code change was needed. +### Changed +- **Sidebar tab headers go text-only once the sidebar is dragged to 220px or + narrower** (#552) — both the upper Databases/Dashboards role switcher and + the lower Library/History switcher hide their icons and `· N` counts at + that width, keeping the four labels readable, aligned and clickable with no + clipping or overlap even at the sidebar's 180px drag minimum. A pure CSS + container-query gate (`@container sidebar (max-width: 220px)`, named/scoped + on `.sidebar`'s new `container-type: inline-size`) restores the full + presentation the instant the sidebar widens past the threshold, with no JS + state to reconcile. + ## [0.7.2] - 2026-07-29 ### Fixed diff --git a/src/styles.css b/src/styles.css index 6319f161..b5ecc332 100644 --- a/src/styles.css +++ b/src/styles.css @@ -866,6 +866,13 @@ h1, h2, h3, h4, h5, h6 { display: flex; flex-direction: column; background: var(--bg-side); min-height: 0; flex-shrink: 0; + /* #552: a container-query axis (not a viewport media query — the sidebar's + width is a dragged pixel value on `sidebar.style.width`, invisible to + `@media`) so the tab headers — and any future sidebar content, e.g. the + Dashboard tree rows — can react to the actual dragged width. Named + `sidebar`/220px is the shared axis; see `@container sidebar` below for + the first consumer. */ + container-type: inline-size; container-name: sidebar; } .col-resize, .row-resize { position: relative; flex-shrink: 0; z-index: 1; @@ -999,6 +1006,18 @@ h1, h2, h3, h4, h5, h6 { .saved-row:hover .sv-act { display: inline-flex; } .sv-act:hover { color: var(--fg); background: var(--bg-hover); } .side-count { color: var(--fg-faint); font-weight: var(--fw-regular); } +/* #552: at a narrow sidebar (bottom quarter of the 180–420px drag range — + `src/ui/splitters.ts`'s `clamp(ev.clientX, 180, 420)`), both tab rows + (`.upper-role-tabs`'s Databases/Dashboard and this row's Library/History) + drop to text-only labels: the icon, and `.side-count`'s "· N" (the count AND + its separator dot share one text node, so hiding the element hides both). + Widening the sidebar past 220px restores the icon/count with no JS state — + see `.sidebar`'s container-type/-name declaration above, alongside + `.query-host`/`.dashboard-host`. */ +@container sidebar (max-width: 220px) { + .side-tab svg { display: none; } + .side-tab .side-count { display: none; } +} .history-row { position: relative; padding: 8px 10px; cursor: pointer; user-select: none; diff --git a/tests/e2e/sidebar-tabs-narrow.spec.js b/tests/e2e/sidebar-tabs-narrow.spec.js new file mode 100644 index 00000000..0cc5c44d --- /dev/null +++ b/tests/e2e/sidebar-tabs-narrow.spec.js @@ -0,0 +1,140 @@ +import { test, expect } from '@playwright/test'; + +// #552 — compact text-only tab headers at a narrow sidebar. happy-dom cannot +// see CSS layout (the `@container sidebar (max-width: 220px)` gate lives in +// `src/styles.css`), so this is the only place the breakpoint is provable: +// dragging the real `.col-resize` handle the way the app does (mousedown on +// the handle, then a real `mousemove`/`mouseup` — `src/ui/splitters.ts`'s +// `dragValue('col', ev)` reads `ev.clientX` directly, unclamped by any rect), +// and reading the resulting `getComputedStyle` on both tab rows. +// +// Reuses `dashboard-tree.html` (#426's fixture): it already mounts the real +// `mountAppShell` with both tab rows live — the upper role tabs (Databases ·2 +// / Dashboards ·3, from its stub schema + seeded workspace) and the lower +// Library/History switcher (its seed leaves one Library query, `q-lib`). + +const open = async (page) => { + await page.setViewportSize({ width: 1280, height: 800 }); + await page.goto('/tests/e2e/dashboard-tree.html'); + await page.waitForFunction(() => window.__ready === true); +}; + +/** Drag `.col-resize` to `targetX` the way a real user does: press on the + * handle, move the pointer, release. `dragValue`'s 'col' branch reads only + * `ev.clientX`, so the resulting sidebar width IS `targetX`. */ +const dragSidebarTo = async (page, targetX) => { + const handle = page.locator('.col-resize'); + const box = await handle.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(targetX, box.y + box.height / 2, { steps: 5 }); + await page.mouse.up(); +}; + +const tabsGeometry = () => { + const rows = [...document.querySelectorAll('.side-tabs')]; + return rows.map((row) => ({ + row: row.className, + width: row.getBoundingClientRect().width, + rowScrollWidth: row.scrollWidth, + rowClientWidth: row.clientWidth, + tabs: [...row.querySelectorAll('.side-tab')].map((tab) => { + const label = tab.querySelector('span'); + const icon = tab.querySelector('svg'); + const count = tab.querySelector('.side-count'); + return { + text: label.textContent, + iconDisplay: icon ? getComputedStyle(icon).display : 'none', + countDisplay: count ? getComputedStyle(count).display : 'none', + labelClipped: label.scrollWidth > label.clientWidth, + tabRect: tab.getBoundingClientRect(), + labelRect: label.getBoundingClientRect(), + }; + }), + })); +}; + +test.describe('sidebar tab headers at narrow widths (#552)', () => { + test('the wide (default) sidebar keeps icons and counts on both tab rows', async ({ page }) => { + await open(page); + const [upper, lower] = await page.evaluate(tabsGeometry); + for (const row of [upper, lower]) { + expect(row.tabs.length).toBeGreaterThan(0); + for (const tab of row.tabs) { + expect(tab.iconDisplay).not.toBe('none'); + } + } + // Databases carries a count from the stub schema; Library carries one from + // the seed's sole unowned query — both are the "not yet compacted" signal. + expect(upper.tabs.find((t) => t.text === 'Databases').countDisplay).not.toBe('none'); + expect(lower.tabs.find((t) => t.text === 'Library').countDisplay).not.toBe('none'); + }); + + test('dragging the sidebar to <=220px switches both rows to text-only labels, with no overflow, clipping or overlap', async ({ page }) => { + await open(page); + await dragSidebarTo(page, 200); + await expect.poll(() => page.locator('.sidebar').evaluate((el) => el.getBoundingClientRect().width)).toBeLessThanOrEqual(220); + + const [upper, lower] = await page.evaluate(tabsGeometry); + for (const row of [upper, lower]) { + // No horizontal overflow within the tab row itself. + expect(row.rowScrollWidth).toBeLessThanOrEqual(row.rowClientWidth + 1); + let prevRight = -Infinity; + for (const tab of row.tabs) { + expect(tab.iconDisplay).toBe('none'); + expect(tab.countDisplay).toBe('none'); + expect(tab.labelClipped).toBe(false); + // The label fits inside its own tab's box (no bleed past the button). + expect(tab.labelRect.left).toBeGreaterThanOrEqual(tab.tabRect.left - 0.5); + expect(tab.labelRect.right).toBeLessThanOrEqual(tab.tabRect.right + 0.5); + // Tabs are laid out left-to-right with no horizontal overlap. + expect(tab.tabRect.left).toBeGreaterThanOrEqual(prevRight - 0.5); + prevRight = tab.tabRect.right; + } + } + // The four labels stay readable and clickable. + await expect(page.locator('.upper-role-tabs .side-tab', { hasText: 'Databases' })).toBeVisible(); + await expect(page.locator('.upper-role-tabs .side-tab', { hasText: 'Dashboards' })).toBeVisible(); + await expect(page.locator('.saved-pane > .side-tabs .side-tab', { hasText: 'Library' })).toBeVisible(); + await expect(page.locator('.saved-pane > .side-tabs .side-tab', { hasText: 'History' })).toBeVisible(); + await page.locator('.saved-pane > .side-tabs .side-tab', { hasText: 'History' }).click(); + await expect(page.locator('.saved-pane > .side-tabs .side-tab', { hasText: 'History' })).toHaveClass(/active/); + }); + + test('the 180px minimum sidebar still shows compact, unclipped, non-overlapping labels', async ({ page }) => { + await open(page); + await dragSidebarTo(page, 100); // below the 180px floor — dragValue clamps it there + await expect.poll(() => page.locator('.sidebar').evaluate((el) => el.getBoundingClientRect().width)).toBeCloseTo(180, 0); + + const [upper, lower] = await page.evaluate(tabsGeometry); + for (const row of [upper, lower]) { + expect(row.rowScrollWidth).toBeLessThanOrEqual(row.rowClientWidth + 1); + let prevRight = -Infinity; + for (const tab of row.tabs) { + expect(tab.iconDisplay).toBe('none'); + expect(tab.countDisplay).toBe('none'); + expect(tab.labelClipped).toBe(false); + expect(tab.tabRect.left).toBeGreaterThanOrEqual(prevRight - 0.5); + prevRight = tab.tabRect.right; + } + } + }); + + test('widening the sidebar back past 220px restores the full tab presentation', async ({ page }) => { + await open(page); + await dragSidebarTo(page, 200); + await expect.poll(() => page.locator('.sidebar').evaluate((el) => el.getBoundingClientRect().width)).toBeLessThanOrEqual(220); + + await dragSidebarTo(page, 300); + await expect.poll(() => page.locator('.sidebar').evaluate((el) => el.getBoundingClientRect().width)).toBeGreaterThan(220); + + const [upper, lower] = await page.evaluate(tabsGeometry); + for (const row of [upper, lower]) { + for (const tab of row.tabs) { + expect(tab.iconDisplay).not.toBe('none'); + } + } + expect(upper.tabs.find((t) => t.text === 'Databases').countDisplay).not.toBe('none'); + expect(lower.tabs.find((t) => t.text === 'Library').countDisplay).not.toBe('none'); + }); +});