diff --git a/.changeset/header-divider.md b/.changeset/header-divider.md new file mode 100644 index 000000000..2ff36cb9e --- /dev/null +++ b/.changeset/header-divider.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +Drag the line between the session tabs and the header toolbar to resize the toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then their percentages as it narrows; double-click, Home or Escape resets it. Arrow keys resize it from the keyboard, and the width is remembered on the device. diff --git a/config/test-suites.ts b/config/test-suites.ts index ae866007e..864d10270 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/tab-rail-search.browser.test.ts', 'test/tab-focus.browser.test.ts', 'test/session-sidebar-ux.browser.test.ts', + 'test/header-divider.browser.test.ts', 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', 'test/opencode-resize.test.ts', diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index b40ab49bd..764afc94f 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -178,6 +178,12 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w Hiding System Stats or Plan Usage still hides them in every style. +The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar +(it wraps onto a second line and the usage readings shed their labels, then their provider names, +then their percentages), left to widen it. Arrow keys resize it, and double-click or Home resets it. +It stops at the widest reading, the width is kept per device, and it is not shown on phones, in the +sidebar layout, in the vertical rail or in a solo window. + New header controls never appear on phones. Phone layout is deliberately minimal and is covered in [Mobile Guide](Mobile-Guide). diff --git a/scripts/build.mjs b/scripts/build.mjs index 65db3881e..d949fcad4 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -120,6 +120,7 @@ run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --m run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite'); +run('minify header-divider.js', 'npx esbuild dist/web/public/header-divider.js --minify --outfile=dist/web/public/header-divider.js --allow-overwrite'); run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite'); run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite'); run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite'); @@ -149,6 +150,7 @@ console.log('\n[build] content-hash cache busting'); 'tab-layout-browser.js', 'app.js', 'tab-rail-resize.js', + 'header-divider.js', 'terminal-ui.js', 'respawn-ui.js', 'ralph-panel.js', diff --git a/src/web/public/app.js b/src/web/public/app.js index 033838544..b955c847b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3680,7 +3680,9 @@ class CodemanApp { seg('mo', pct(usage?.monthly), false), ].filter(Boolean); if (!windows.length) return ''; - const label = labelled ? `${provider}` : ''; + // data-short is what the narrowest header densities show instead of the name (header-divider.js). + const short = { Claude: 'CL', Codex: 'CX', Copilot: 'CP' }[provider] ?? provider.slice(0, 2).toUpperCase(); + const label = labelled ? `${provider}` : ''; return `${label}${windows.join('·')}`; }; const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter( diff --git a/src/web/public/header-divider.js b/src/web/public/header-divider.js new file mode 100644 index 000000000..33fc56035 --- /dev/null +++ b/src/web/public/header-divider.js @@ -0,0 +1,208 @@ +/** + * @fileoverview A draggable divider on the left edge of the header's right-hand toolbar. + * + * Dragging it left gives the toolbar (stats, plan usage, buttons) more room; dragging it right narrows the + * toolbar, which then wraps onto a second line instead of squeezing the session tabs out. The width is a + * device-local convenience kept in localStorage; double-click or Home resets it. The handle is absolutely + * positioned inside #headerRight, so it takes no layout room and is simply hidden where the tabs are not in + * the header (sidebar, vertical rail, phones). + * + * @loadorder after app.js; self-contained, no CodemanApp dependency. + */ +(function () { + const STORAGE_KEY = 'codeman-header-right-width'; + const MIN_WIDTH = 160; + const MIN_TABS_ROOM = 240; + const KEY_STEP = 16; + + /** Keep a requested toolbar width between the minimum and what leaves the tabs some room. */ + function clampWidth(width, headerWidth, brandWidth) { + const max = Math.max(MIN_WIDTH, headerWidth - brandWidth - MIN_TABS_ROOM); + return Math.round(Math.min(max, Math.max(MIN_WIDTH, width))); + } + + /** + * How much of the usage readings to show for a toolbar this wide. The narrower the toolbar, the more of + * the labels go: window labels first, then provider names (shortened), then the percentages (the rings + * stay, and the chip's tooltip keeps the numbers). + */ + function densityFor(width) { + if (width >= 560) return ''; + if (width >= 400) return 'tight'; + if (width >= 280) return 'dense'; + return 'tiny'; + } + + function readStored() { + try { + const value = Number(localStorage.getItem(STORAGE_KEY)); + return Number.isFinite(value) && value > 0 ? value : null; + } catch { + return null; + } + } + + function writeStored(width) { + try { + if (width === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, String(width)); + } catch { + /* storage blocked: the width lasts until the page reloads */ + } + } + + function init() { + const header = document.querySelector('.header'); + const right = document.getElementById('headerRight'); + if (!header || !right || right.querySelector('.header-divider')) return; + const brand = header.querySelector('.header-brand'); + const root = document.documentElement; + + const handle = document.createElement('div'); + handle.className = 'header-divider'; + handle.setAttribute('role', 'separator'); + handle.setAttribute('aria-orientation', 'vertical'); + handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)'); + handle.setAttribute('aria-valuemin', String(MIN_WIDTH)); + handle.tabIndex = 0; + right.insertBefore(handle, right.firstChild); + + // The width the user asked for. The applied width can be smaller (a narrow window) or larger (a reading + // that needs more room), and the request is what comes back when the window grows again. + let wanted = readStored(); + + const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 }); + const current = () => right.getBoundingClientRect().width; + + /** The narrowest the toolbar can be without a reading poking out past its left edge (the divider). */ + function contentMinimum() { + const style = getComputedStyle(right); + const frame = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth); + let widest = 0; + for (const child of right.children) { + if (child === handle || child.offsetParent === null) continue; + widest = Math.max(widest, child.getBoundingClientRect().width, child.scrollWidth); + } + return Math.ceil(widest + frame); + } + + function setDensity(width) { + const density = densityFor(width); + if (density) root.setAttribute('data-header-density', density); + else root.removeAttribute('data-header-density'); + } + + /** Apply `wanted`, settled on a width no reading overflows at the density that width gets. */ + function settle() { + if (wanted === null) { + root.style.removeProperty('--header-right-width'); + root.removeAttribute('data-header-right-width'); + root.removeAttribute('data-header-density'); + handle.removeAttribute('aria-valuenow'); + handle.removeAttribute('aria-valuemax'); + return; + } + const { header: h, brand: b } = bounds(); + let clamped = clampWidth(wanted, h, b); + root.setAttribute('data-header-right-width', 'set'); + // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum. + for (let i = 0; i < 3; i++) { + root.style.setProperty('--header-right-width', clamped + 'px'); + setDensity(clamped); + const floor = contentMinimum(); + if (clamped >= floor) break; + clamped = floor; + } + handle.setAttribute('aria-valuenow', String(clamped)); + handle.setAttribute('aria-valuemax', String(clampWidth(Infinity, h, b))); + } + + /** Several changes in one frame (a chip refill, a resize) settle once. */ + let settleFrame = 0; + function scheduleSettle() { + if (wanted === null || settleFrame) return; + settleFrame = requestAnimationFrame(() => { + settleFrame = 0; + settle(); + }); + } + + /** The tab strip and the subagent lines measure the header, so they re-measure after a user resize. */ + function relayout() { + window.app?.updateTabOverflowMode?.(); + window.app?.updateConnectionLines?.(); + } + + function apply(width) { + wanted = width === null ? null : Math.round(width); + writeStored(wanted); + settle(); + relayout(); + } + + // Readings arrive after init (the plan-usage chip is filled in asynchronously, a Codex or Copilot row + // can start reporting later, a header button can be switched on): re-settle when any child changes size. + const resizeObserver = new ResizeObserver(scheduleSettle); + const watch = () => { + for (const child of right.children) if (child !== handle) resizeObserver.observe(child); + }; + watch(); + new MutationObserver(() => { + watch(); + scheduleSettle(); + }).observe(right, { childList: true }); + + handle.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + event.preventDefault(); + handle.setPointerCapture?.(event.pointerId); + document.body.classList.add('header-divider-dragging'); + const padRight = parseFloat(getComputedStyle(header).paddingRight) || 0; + let moveFrame = 0; + let lastX = event.clientX; + const onMove = (e) => { + lastX = e.clientX; + if (moveFrame) return; + moveFrame = requestAnimationFrame(() => { + moveFrame = 0; + wanted = Math.round(header.getBoundingClientRect().right - lastX - padRight); + settle(); + }); + }; + const finish = () => { + handle.removeEventListener('pointermove', onMove); + handle.removeEventListener('pointerup', finish); + handle.removeEventListener('pointercancel', finish); + handle.removeEventListener('lostpointercapture', finish); + if (moveFrame) cancelAnimationFrame(moveFrame); + moveFrame = 0; + document.body.classList.remove('header-divider-dragging'); + if (wanted !== null) apply(wanted); + }; + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', finish); + handle.addEventListener('pointercancel', finish); + handle.addEventListener('lostpointercapture', finish); + }); + + handle.addEventListener('dblclick', () => apply(null)); + + handle.addEventListener('keydown', (event) => { + if (event.key === 'Home' || event.key === 'Escape') { + apply(null); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + apply((wanted ?? current()) + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP)); + } else { + return; + } + event.preventDefault(); + }); + + window.addEventListener('resize', scheduleSettle); + settle(); + } + + window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); + else init(); +})(); diff --git a/src/web/public/index.html b/src/web/public/index.html index f3a2b2a27..90a262da1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -4345,6 +4345,7 @@

Read My Mind

+ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 65755b90e..de886aae4 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2249,6 +2249,111 @@ html[data-tab-orientation='vertical'].tab-rail-compact margin-left: auto; padding-left: 0.75rem; border-left: 1px solid var(--glass-border); + position: relative; +} + +/* Draggable edge of the toolbar (header-divider.js). Absolute, so it takes no layout room and the + existing border-left stays the visible line. */ +.header-divider { + position: absolute; + top: 0; + bottom: 0; + left: -6px; + width: 12px; + cursor: col-resize; + touch-action: none; + z-index: 2; +} + +.header-divider:hover, +.header-divider:focus-visible { + background: color-mix(in srgb, var(--accent) 24%, transparent); + outline: none; +} + +.header-divider:focus-visible { + box-shadow: inset 0 0 0 2px var(--accent); +} + +/* Everything below applies only where the tabs share the header with the toolbar, i.e. where the handle is + visible to undo it: not in the sidebar layout, the vertical rail or a solo window, and not on a phone. */ + +/* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */ +@media (min-width: 769px) { + html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + .header-right { + flex: 0 0 var(--header-right-width); + width: var(--header-right-width); + flex-wrap: wrap; + justify-content: flex-end; + align-content: center; + row-gap: 0.25rem; + min-height: var(--header-height); + } + + /* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js): + tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings. + The prefix outweighs the per-style rules further down, so every header-stats style follows it. */ + html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + :is(.header-system-stats .stat-label, .header-plan-usage .pu-label) { + display: none; + } + + html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar']) + body:not(.solo-mode) + :is(.header-system-stats, .header-plan-usage) { + gap: 8px; + padding: 0 8px; + } + + html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + .header-plan-usage + .pu-provider { + font-size: 0; + } + + html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + .header-plan-usage + .pu-provider::after { + content: attr(data-short); + font-size: 0.6rem; + } + + /* Rings-only is a Compact-style reading: the other styles have no rings, so they keep their values. */ + html[data-header-stats='compact'][data-header-density='tiny']:not([data-tab-orientation='vertical']):not( + [data-session-list='sidebar'] + ) + body:not(.solo-mode) + :is(.header-system-stats .stat-value, .header-plan-usage .pu-val) { + display: none; + } +} + +body.header-divider-dragging, +body.header-divider-dragging * { + cursor: col-resize !important; + user-select: none !important; +} + +/* Only where the tabs share the header with the toolbar. */ +html[data-tab-orientation='vertical'] .header-divider, +html[data-session-list='sidebar'] .header-divider, +body.solo-mode .header-divider { + display: none; +} + +@media (max-width: 768px) { + .header-divider { + display: none; + } } /* Connection Indicator */ diff --git a/test/header-divider.browser.test.ts b/test/header-divider.browser.test.ts new file mode 100644 index 000000000..7f12f2980 --- /dev/null +++ b/test/header-divider.browser.test.ts @@ -0,0 +1,256 @@ +/** + * Real Chromium, against the real header markup from index.html and the real styles.css: dragging the divider + * resizes the toolbar and wraps it onto a second line, the readings shed detail as it narrows, nothing pokes + * out past the divider (even for content that arrives later), and none of it applies where the handle is hidden. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const dir = resolve(import.meta.dirname, '../src/web/public'); +const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8'); +const controller = readFileSync(resolve(dir, 'header-divider.js'), 'utf8'); +const header = /
[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0]; + +const CHIP = + 'Claude' + + '5h49%' + + '7d72%' + + 'Copilot' + + 'mo81%'; + +const ORIGIN = 'http://app.test/'; + +/** The default layout attributes, with any `name="value"` in `extra` replacing the matching default. */ +function rootAttrs(extra = '') { + const attrs = new Map([ + ['data-header-stats', 'compact'], + ['data-tab-orientation', 'horizontal'], + ['data-session-list', 'header'], + ]); + for (const [, name, value] of extra.matchAll(/([\w-]+)="([^"]*)"/g)) attrs.set(name, value); + return [...attrs].map(([name, value]) => `${name}="${value}"`).join(' '); +} +type Attrs = { root?: string; body?: string }; + +describe('header divider (real Chromium)', () => { + let browser: Browser; + let p: Page; + beforeAll(async () => { + browser = await chromium.launch(); + }); + afterAll(async () => { + await browser.close(); + }); + + /** Open the real header; `stored` is the width kept from an earlier visit, `chip` fills the plan-usage chip. */ + async function open(options: { root?: string; body?: string; stored?: number; chip?: boolean; width?: number } = {}) { + p = await browser.newPage({ viewport: { width: options.width ?? 1400, height: 600 } }); + await p.route(ORIGIN, (route) => + route.fulfill({ + contentType: 'text/html', + body: `${header}`, + }) + ); + await p.goto(ORIGIN); + if (options.stored) + await p.evaluate((w) => localStorage.setItem('codeman-header-right-width', String(w)), options.stored); + if (options.chip) { + await p.evaluate((html) => { + const chip = document.getElementById('planUsageChip')!; + chip.classList.remove('header-plan-usage--hidden'); + chip.innerHTML = html; + }, CHIP); + } + await p.addScriptTag({ content: controller }); + } + const box = (sel: string) => p.locator(sel).evaluate((el) => el.getBoundingClientRect().toJSON()); + const settled = () => p.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + const dragTo = async (toolbarWidth: number) => { + const handle = await box('.header-divider'); + const head = await box('.header'); + await p.mouse.move(handle.left + 6, handle.top + 10); + await p.mouse.down(); + await p.mouse.move(head.right - 12 - toolbarWidth, handle.top + 10, { steps: 6 }); + await p.mouse.up(); + await settled(); + }; + const readingsInside = async () => { + const edge = (await box('#headerRight')).left + 1; + const lefts = await p + .locator('#headerRight > :not(.header-divider)') + .evaluateAll((els) => + els.filter((el) => (el as HTMLElement).offsetParent !== null).map((el) => el.getBoundingClientRect().left) + ); + return { edge, lefts }; + }; + const display = (sel: string) => + p + .locator(sel) + .first() + .evaluate((el) => getComputedStyle(el).display); + + it('narrows the toolbar and wraps it when dragged right, and resets on double-click', async () => { + await open({ chip: true }); + const before = await box('#headerRight'); + await dragTo(330); + const after = await box('#headerRight'); + expect(after.width).toBeLessThan(before.width - 100); + expect(after.height).toBeGreaterThan(before.height + 10); + expect((await box('.session-tabs-host')).width).toBeGreaterThan(before.width / 2); + expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).not.toBeNull(); + await p.locator('.header-divider').dblclick(); + expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); + expect(await p.evaluate(() => localStorage.getItem('codeman-header-right-width'))).toBeNull(); + await p.close(); + }); + + it('resizes from the keyboard and tells the tab strip to re-measure', async () => { + await open({ chip: true }); + await p.evaluate(() => { + (window as any).relayouts = 0; + (window as any).app = { + updateTabOverflowMode: () => (window as any).relayouts++, + updateConnectionLines: () => {}, + }; + }); + const before = await box('#headerRight'); + await p.locator('.header-divider').focus(); + await p.keyboard.press('ArrowRight'); + await p.keyboard.press('ArrowRight'); + expect((await box('#headerRight')).width).toBeLessThan(before.width - 20); + expect(await p.evaluate(() => (window as any).relayouts)).toBeGreaterThanOrEqual(2); + await p.keyboard.press('Home'); + expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2); + await p.close(); + }); + + it('stops the divider at the widest reading so nothing pokes out past it', async () => { + await open({ chip: true }); + await dragTo(20); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('keeps a reading inside the divider when it arrives after the width was applied', async () => { + await open({ stored: 160 }); + await settled(); + await p.evaluate((html) => { + const chip = document.getElementById('planUsageChip')!; + chip.classList.remove('header-plan-usage--hidden'); + chip.innerHTML = html; + }, CHIP); + await settled(); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + await p.close(); + }); + + it('keeps a wide reading that is switched on later inside the divider', async () => { + await open({ stored: 160 }); + await settled(); + await p.evaluate(() => { + const wide = document.createElement('div'); + wide.id = 'lateReading'; + wide.style.cssText = 'width:320px;flex:none;height:24px'; + document.getElementById('headerRight')!.appendChild(wide); + }); + await settled(); + const { edge, lefts } = await readingsInside(); + for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge); + expect((await box('#headerRight')).width).toBeGreaterThanOrEqual(320); + await p.close(); + }); + + it('sheds labels, then provider names, then percentages as the toolbar narrows', async () => { + await open({ chip: true }); + expect(await display('.pu-label')).not.toBe('none'); + const seen: string[] = []; + for (const width of [450, 330, 200]) { + await dragTo(width); + seen.push((await p.evaluate(() => document.documentElement.dataset.headerDensity)) ?? ''); + } + expect(seen).toEqual(['tight', 'dense', 'tiny']); + expect(await display('.pu-label')).toBe('none'); + expect(await display('.pu-val')).toBe('none'); + expect( + await p + .locator('.pu-provider') + .first() + .evaluate((el) => getComputedStyle(el, '::after').content) + ).toBe('"CL"'); + await p.close(); + }); + + for (const style of ['tiles', 'classic']) { + it(`follows the density steps under the ${style} style, keeping values at the narrowest step`, async () => { + await open({ chip: true, root: `data-header-stats="${style}"` }); + await dragTo(330); + expect( + await p + .locator('.pu-provider') + .first() + .evaluate((el) => getComputedStyle(el).fontSize) + ).toBe('0px'); + await dragTo(200); + expect(await display('.pu-val')).not.toBe('none'); + await p.close(); + }); + } + + for (const [name, root, body] of [ + ['the sidebar layout', 'data-session-list="sidebar"', ''], + ['the vertical rail', 'data-tab-orientation="vertical"', ''], + ['a solo window', '', 'solo-mode'], + ] as const) { + it(`leaves a stored narrow width alone in ${name}, where the handle is hidden`, async () => { + await open({ chip: true, stored: 180, root, body }); + // the stored width is read at init, which already ran inside open() before storage was set + await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '180')); + await p.reload(); + await p.addScriptTag({ content: controller }); + await settled(); + expect(await p.locator('.header-divider').isVisible()).toBe(false); + expect(await display('.stat-label')).not.toBe('none'); + expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('180px'); + await p.close(); + }); + } + + it('does not pin the toolbar to a stored width on a narrow viewport', async () => { + await open({ chip: true, width: 700 }); + await p.evaluate(() => localStorage.setItem('codeman-header-right-width', '300')); + await p.reload(); + await p.addScriptTag({ content: controller }); + await settled(); + expect(await p.locator('.header-right').evaluate((el) => getComputedStyle(el).flexBasis)).not.toBe('300px'); + await p.close(); + }); + + it('gets the chosen width back when the window shrinks and grows again', async () => { + await open({ chip: true }); + await dragTo(700); + const chosen = (await box('#headerRight')).width; + await p.setViewportSize({ width: 1000, height: 600 }); + await settled(); + expect((await box('#headerRight')).width).toBeLessThan(chosen); + await p.setViewportSize({ width: 1400, height: 600 }); + await settled(); + expect(Math.abs((await box('#headerRight')).width - chosen)).toBeLessThan(2); + await p.close(); + }); + + it('clears the dragging state when the pointer capture is lost', async () => { + await open({ chip: true }); + const handle = await box('.header-divider'); + await p.mouse.move(handle.left + 6, handle.top + 10); + await p.mouse.down(); + expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(true); + await p.locator('.header-divider').evaluate((el) => el.dispatchEvent(new PointerEvent('lostpointercapture'))); + expect(await p.evaluate(() => document.body.classList.contains('header-divider-dragging'))).toBe(false); + await p.mouse.up(); + await p.close(); + }); +}); diff --git a/test/header-divider.test.ts b/test/header-divider.test.ts new file mode 100644 index 000000000..72a988e0c --- /dev/null +++ b/test/header-divider.test.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview The draggable divider at the left edge of the header toolbar: the width clamp keeps the tabs + * some room, the controller is wired into the page and build, and the stored width is device-local. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; + +const publicDir = resolve(import.meta.dirname, '../src/web/public'); +const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8'); +const buildJs = readFileSync(resolve(import.meta.dirname, '../scripts/build.mjs'), 'utf8'); + +function load() { + const window: Record = {}; + const context = vm.createContext({ + window, + document: { readyState: 'loading', addEventListener: () => {}, querySelector: () => null }, + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, + }); + vm.runInContext(read('header-divider.js'), context, { filename: 'header-divider.js' }); + return window.CodemanHeaderDivider as { + clampWidth: (w: number, header: number, brand: number) => number; + densityFor: (w: number) => string; + MIN_WIDTH: number; + MIN_TABS_ROOM: number; + }; +} + +describe('header divider', () => { + it('clamps the toolbar between its minimum and what leaves the tabs room', () => { + const { clampWidth, MIN_WIDTH, MIN_TABS_ROOM } = load(); + expect(clampWidth(10, 1600, 120)).toBe(MIN_WIDTH); + expect(clampWidth(5000, 1600, 120)).toBe(1600 - 120 - MIN_TABS_ROOM); + expect(clampWidth(420.4, 1600, 120)).toBe(420); + }); + + it('shows less of the usage readings the narrower the toolbar gets', () => { + const { densityFor } = load(); + expect([700, 560, 559, 400, 399, 280, 279, 160].map(densityFor)).toEqual([ + '', + '', + 'tight', + 'tight', + 'dense', + 'dense', + 'tiny', + 'tiny', + ]); + }); + + it('never goes below the minimum even in a header too narrow for the tabs room', () => { + const { clampWidth, MIN_WIDTH } = load(); + expect(clampWidth(400, 300, 100)).toBe(MIN_WIDTH); + }); + + it('is wired into the page and the build, and hides where the tabs are not in the header', () => { + expect(read('index.html')).toContain(''); + expect(buildJs).toContain("run('minify header-divider.js'"); + expect(buildJs).toMatch(/HASHABLE[\s\S]{0,1500}'header-divider\.js'/); + const css = read('styles.css'); + expect(css).toContain('.header-divider'); + expect(css).toMatch(/data-session-list='sidebar'\] \.header-divider/); + expect(css).toMatch(/data-tab-orientation='vertical'\] \.header-divider/); + }); + + it('keeps the width on the device only and offers a keyboard reset', () => { + const js = read('header-divider.js'); + expect(js).toContain('localStorage'); + expect(js).not.toContain('/api/settings'); + expect(js).toMatch(/key === 'Home'/); + expect(js).toContain("addEventListener('dblclick'"); + }); +});