mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(header): resizable toolbar divider that collapses the usage readings as it narrows
This commit is contained in:
1 parent
002de32a08
commit
fa2d75c951
9 files changed
+467
-1
No files matched your search
@@ -0,0 +1,130 @@
|
||||
/** Real Chromium: dragging the header divider resizes the toolbar and wraps it onto a second line. */
|
||||
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 page = (root = 'data-tab-orientation="horizontal" data-session-list="header"') => `<!doctype html>
|
||||
<html ${root}><head><style>${styles}</style></head><body>
|
||||
<div class="app"><header class="header">
|
||||
<div class="header-brand"><span class="logo">Codeman</span></div>
|
||||
<div class="session-tabs-host"><div class="session-tabs" id="sessionTabs"></div></div>
|
||||
<div class="header-right" id="headerRight">
|
||||
<div class="header-plan-usage" style="width:230px;flex:none">usage</div>
|
||||
<div class="header-system-stats" style="width:200px;flex:none">stats</div>
|
||||
<button class="btn-icon-header">a</button><button class="btn-icon-header">b</button>
|
||||
</div>
|
||||
</header></div>
|
||||
</body></html>`;
|
||||
|
||||
describe('header divider (real Chromium)', () => {
|
||||
let browser: Browser;
|
||||
let p: Page;
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
async function open(html: string) {
|
||||
p = await browser.newPage({ viewport: { width: 1400, height: 600 } });
|
||||
await p.setContent(html);
|
||||
await p.addScriptTag({ content: controller });
|
||||
}
|
||||
const box = (sel: string) => p.locator(sel).evaluate((el) => el.getBoundingClientRect().toJSON());
|
||||
|
||||
it('narrows the toolbar and wraps it onto a second line when dragged right, and resets on double-click', async () => {
|
||||
await open(page());
|
||||
const before = await box('#headerRight');
|
||||
const handle = await box('.header-divider');
|
||||
const y = handle.top + handle.height / 2;
|
||||
await p.mouse.move(handle.left + 6, y);
|
||||
await p.mouse.down();
|
||||
await p.mouse.move(handle.left + 6 + 330, y, { steps: 6 });
|
||||
await p.mouse.up();
|
||||
const after = await box('#headerRight');
|
||||
expect(after.width).toBeLessThan(before.width - 250);
|
||||
expect(after.height).toBeGreaterThan(before.height + 10);
|
||||
// setContent gives an opaque origin: storage is blocked, and the drag must still work.
|
||||
expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).not.toBe(
|
||||
''
|
||||
);
|
||||
const tabs = await box('.session-tabs-host');
|
||||
expect(tabs.width).toBeGreaterThan(before.width);
|
||||
|
||||
await p.locator('.header-divider').dblclick();
|
||||
const reset = await box('#headerRight');
|
||||
expect(Math.abs(reset.width - before.width)).toBeLessThan(2);
|
||||
expect(await p.evaluate(() => document.documentElement.style.getPropertyValue('--header-right-width'))).toBe('');
|
||||
await p.close();
|
||||
});
|
||||
|
||||
it('drops usage labels, then provider names, then percentages as the toolbar narrows', async () => {
|
||||
const chip =
|
||||
'<div class="header-plan-usage" id="planUsageChip"><span class="pu-row"><span data-short="CL" class="pu-provider">Claude</span>' +
|
||||
'<span class="pu-windows"><span class="pu-win"><span class="pu-ring"></span><span class="pu-label">5h</span><span class="pu-val">49%</span></span></span></span></div>';
|
||||
await open(page('data-header-stats="compact" data-tab-orientation="horizontal" data-session-list="header"'));
|
||||
await p.evaluate((html) => document.getElementById('headerRight')!.insertAdjacentHTML('beforeend', html), chip);
|
||||
const visible = (sel: string) => p.locator(sel).isVisible();
|
||||
expect(await visible('.pu-label')).toBe(true);
|
||||
const sizes: Array<[number, string]> = [
|
||||
[450, 'tight'],
|
||||
[330, 'dense'],
|
||||
[200, 'tiny'],
|
||||
];
|
||||
for (const [width, density] of sizes) {
|
||||
const handle = await box('.header-divider');
|
||||
const header = await box('.header');
|
||||
await p.mouse.move(handle.left + 6, handle.top + handle.height / 2);
|
||||
await p.mouse.down();
|
||||
await p.mouse.move(header.right - width - 12, handle.top + handle.height / 2, { steps: 5 });
|
||||
await p.mouse.up();
|
||||
expect(await p.evaluate(() => document.documentElement.dataset.headerDensity)).toBe(density);
|
||||
}
|
||||
expect(await visible('.pu-label')).toBe(false);
|
||||
expect(await visible('.pu-val')).toBe(false);
|
||||
expect(await p.locator('.pu-provider').evaluate((el) => getComputedStyle(el, '::after').content)).toBe('"CL"');
|
||||
await p.close();
|
||||
});
|
||||
|
||||
it('stops the divider at the widest reading so nothing pokes out past it', async () => {
|
||||
await open(page());
|
||||
const handle = await box('.header-divider');
|
||||
const header = await box('.header');
|
||||
await p.mouse.move(handle.left + 6, handle.top + handle.height / 2);
|
||||
await p.mouse.down();
|
||||
await p.mouse.move(header.right - 20, handle.top + handle.height / 2, { steps: 8 });
|
||||
await p.mouse.up();
|
||||
const right = await box('#headerRight');
|
||||
const edge = right.left + 1;
|
||||
const lefts = await p
|
||||
.locator('#headerRight > :not(.header-divider)')
|
||||
.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().left));
|
||||
for (const left of lefts) expect(left).toBeGreaterThanOrEqual(edge);
|
||||
await p.close();
|
||||
});
|
||||
|
||||
it('resizes from the keyboard', async () => {
|
||||
await open(page());
|
||||
const before = await box('#headerRight');
|
||||
await p.locator('.header-divider').focus();
|
||||
await p.keyboard.press('ArrowRight');
|
||||
await p.keyboard.press('ArrowRight');
|
||||
const after = await box('#headerRight');
|
||||
expect(after.width).toBeLessThan(before.width - 20);
|
||||
await p.keyboard.press('Home');
|
||||
expect(Math.abs((await box('#headerRight')).width - before.width)).toBeLessThan(2);
|
||||
await p.close();
|
||||
});
|
||||
|
||||
it('is hidden when the tabs live in the sidebar', async () => {
|
||||
await open(page('data-tab-orientation="horizontal" data-session-list="sidebar"'));
|
||||
expect(await p.locator('.header-divider').isVisible()).toBe(false);
|
||||
await p.close();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
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('<script defer src="header-divider.js"></script>');
|
||||
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'");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user