Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
140 changes: 140 additions & 0 deletions tests/e2e/sidebar-tabs-narrow.spec.js
Original file line number Diff line number Diff line change
@@ -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');
});
});