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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ The concrete implication for an agent: don't reach for a framework, a general-pu

When a design decision has more than one reasonable answer, resolve it in this order: correct operation, minimal surface area (to-the-point comments, efficient algorithms, no excess features), readable (plain language, clear names), explicit (the user does something deliberate to kick off a behavior — nothing fires as a side effect), convenient, performant. Higher wins. Don't trade a higher priority for a lower one to make a later item nicer — for example, don't add a persisted session to make something more convenient at the cost of making it less explicit, and don't reach for a shared abstraction at the cost of a larger, harder-to-audit surface area.

Effort scales with reversibility. The action a user takes most often gets the cheapest gesture, and a less reversible one always costs more — a different gesture, a separate control, or a confirmation — never the same gesture as the reversible neighbor it sits beside. Deleting already works this way: trashing a single entry is reversible and so happens silently, trashing a group asks first because it carries every entry beneath it, and emptying the bin is permanent and so is confirmed. The rule generalizes that, so a new control's weight is decided by its consequence rather than by whatever fits the layout.

## Approach

Every internal dependency is owned, not borrowed. `packages/argon2`, `packages/chacha20`, and `packages/kdbx` are consumed by relative import to each other's compiled output in `build/packages/` — never through a `dependencies` entry in any `package.json`, and never published. `grep -r '"dependencies"' --include=package.json .` should always come back empty for internal code; if a change makes it not empty, that change is wrong.
Expand Down
188 changes: 188 additions & 0 deletions e2e/group-rail.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/** Real-browser coverage for the group rail (issue #63). Both assertions here
* need a layout engine, which jsdom does not have: that the rail's default
* width really does show 25 characters of a sub-group name with no
* intervention, and that dragging the handle really does resize it. */
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { fileURLToPath } from 'node:url';
import puppeteer, { type Browser, type ElementHandle, type Frame, type Page } from 'puppeteer-core';
import { resolveChromePath } from './support/chrome.ts';
import { type DistServer, startDistServer } from './support/dist-server.ts';
import { type KdbxFixture, writeKdbxFixture } from './support/fixture.ts';
import { resolveLaunchOptions } from './support/launch-options.ts';

const distDir = fileURLToPath(new URL('../dist', import.meta.url));

let server: DistServer;
let browser: Browser;
let page: Page;
let app: Frame;
let fixture: KdbxFixture;

before(async () => {
server = await startDistServer(distDir);
browser = await puppeteer.launch({
executablePath: resolveChromePath(),
...resolveLaunchOptions(),
args: ['--no-sandbox'],
});
page = await browser.newPage();
// Comfortably wider than the 700px drawer breakpoint, so the rail is the
// resizable side rail rather than the mobile drawer.
await page.setViewport({ width: 1280, height: 900 });
fixture = await writeKdbxFixture();

app = await openApp(page);
});

/** Upload the fixture to local.html and unlock the app it embeds, returning
* the app's frame. */
async function openApp(target: Page): Promise<Frame> {
await target.goto(`${server.origin}/local.html`, { waitUntil: 'networkidle0' });
const fileInput = (await target.waitForSelector(
'#file-input',
)) as ElementHandle<HTMLInputElement>;
await fileInput.uploadFile(fixture.path);
const frameElement = await target.waitForSelector('#app-frame');
assert.ok(frameElement, 'the app is embedded in an iframe');
const frame = (await frameElement.contentFrame()) as Frame;
const passwordInput = await frame.waitForSelector('#master-password');
assert.ok(passwordInput, 'the embedded app shows its unlock screen');
await passwordInput.type(fixture.password);
await frame.click('#unlock-btn');
await frame.waitForSelector('#group-tree .group-btn');
return frame;
}

after(async () => {
await browser.close();
await server.close();
});

test('the rail shows 25 characters of a sub-group name without any intervention', async (t) => {
const measured = await app.$$eval(
'#group-tree .group-btn',
(buttons, name) => {
const button = buttons.find((b) => b.textContent?.endsWith(name));
if (!button) return null;
// scrollWidth is clamped to clientWidth, so it can only ever report
// "overflowing" or "not" — never by how much. Measuring the text itself
// against the content box gives a margin that can be watched over time.
const text = document.createRange();
text.selectNodeContents(button);
const style = getComputedStyle(button);
const padding = Number.parseFloat(style.paddingLeft) + Number.parseFloat(style.paddingRight);
return {
needed: text.getBoundingClientRect().width,
available: button.clientWidth - padding,
};
},
fixture.groupName,
);

assert.ok(measured, `the rail lists "${fixture.groupName}"`);
// Reported on every run: the monospace fallback differs between developer
// machines and CI, so a shrinking margin here is the early warning that the
// default width is drifting towards truncation.
t.diagnostic(
`25-character group name needs ${measured.needed.toFixed(1)}px of the ${measured.available.toFixed(1)}px content box (${(measured.available - measured.needed).toFixed(1)}px spare)`,
);
assert.ok(
measured.needed <= measured.available,
`"${fixture.groupName}" does not fit the default rail: needs ${measured.needed.toFixed(1)}px, has ${measured.available.toFixed(1)}px`,
);
});

test('dragging the handle resizes the rail', async () => {
const railWidth = (): Promise<number> =>
app.$eval('#sidebar', (el) => el.getBoundingClientRect().width);

const handle = await app.$('#sidebar-resize');
assert.ok(handle, 'the rail has a resize handle');
const box = await handle.boundingBox();
assert.ok(box, 'the handle is laid out');

const startWidth = await railWidth();
const y = box.y + 20;
await page.mouse.move(box.x + box.width / 2, y);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 80, y, { steps: 8 });
await page.mouse.up();

const endWidth = await railWidth();
assert.ok(
endWidth > startWidth,
`dragging right widened the rail (${startWidth}px -> ${endWidth}px)`,
);
});

test('at phone width the rail is a drawer: no resize handle, and ⋯ still reaches rename', async () => {
const phone = await browser.newPage();
await phone.setViewport({ width: 375, height: 812 });
const phoneApp = await openApp(phone);

assert.equal(
await phoneApp.$eval('#sidebar-resize', (el) => getComputedStyle(el).display),
'none',
'the drawer has no edge to drag, so the handle is not rendered',
);

await phoneApp.click('[data-action="toggle-sidebar"]');
await phoneApp.waitForSelector('#sidebar.sidebar-open');
// The drawer slides in over 0.2s; clicking mid-flight misses the button.
await phoneApp.waitForFunction(() => {
const drawer = document.querySelector('#sidebar');
return drawer !== null && getComputedStyle(drawer).transform === 'matrix(1, 0, 0, 1, 0, 0)';
});

// Every drawer row exposes its ⋯, because tapping a group to make it active
// would close the drawer and cost a second visit.
const menuButton = await phoneApp.evaluateHandle((name) => {
const rows = Array.from(document.querySelectorAll('#group-tree .group-row'));
const row = rows.find((r) => r.querySelector('.group-btn')?.textContent?.endsWith(name));
return row?.querySelector('.group-menu-btn') ?? null;
}, fixture.groupName);
const menuElement = menuButton.asElement() as ElementHandle<HTMLElement> | null;
assert.ok(menuElement, 'the sub-group row has a ⋯ button in the drawer');
assert.notEqual(
await menuElement.evaluate((el) => getComputedStyle(el).visibility),
'hidden',
'⋯ is visible without first selecting the row',
);

await menuElement.click();
const labels = await phoneApp.$$eval('.group-menu-item', (items) =>
items.map((i) => i.textContent),
);
assert.deepEqual(labels, ['Rename', 'Move'], 'the menu opens with rename and move');

assert.ok(await phoneApp.$('#sidebar.sidebar-open'), 'opening the menu left the drawer open');
await phone.close();
});

test('a rail widened on desktop does not follow the user into the phone drawer', async () => {
const handle = await app.$('#sidebar-resize');
assert.ok(handle, 'the rail has a resize handle');
const box = await handle.boundingBox();
assert.ok(box, 'the handle is laid out');

const y = box.y + 20;
await page.mouse.move(box.x + box.width / 2, y);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 + 400, y, { steps: 8 });
await page.mouse.up();

const railWidth = (): Promise<number> =>
app.$eval('#sidebar', (el) => el.getBoundingClientRect().width);
const wide = await railWidth();
assert.ok(wide > 400, `the rail is dragged wide first (${wide}px)`);

await page.setViewport({ width: 375, height: 812 });
const drawer = await railWidth();
assert.ok(
drawer <= 375,
`the drawer keeps its own width at phone size (${drawer}px inside a 375px viewport)`,
);

await page.setViewport({ width: 1280, height: 900 });
});
14 changes: 12 additions & 2 deletions e2e/support/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import { writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { appendChild, Credentials, createEntry, Kdbx } from '../../packages/kdbx/src/index.ts';
import {
appendChild,
Credentials,
createEntry,
createGroup,
Kdbx,
} from '../../packages/kdbx/src/index.ts';

// Fast KDF settings (matches pages/tests/*.test.ts) — a throwaway fixture,
// no reason to pay real Argon2id cost.
Expand All @@ -13,11 +19,14 @@ export interface KdbxFixture {
path: string;
password: string;
entryTitle: string;
groupName: string;
}

export async function writeKdbxFixture(): Promise<KdbxFixture> {
const password = 'e2e-test-password';
const entryTitle = 'Example Entry';
// Exactly 25 characters, the floor issue #63 sets for the group rail.
const groupName = 'Financial Institutions XY';

const credentials = new Credentials({ password });
const kdbx = await Kdbx.create(credentials, {
Expand All @@ -32,6 +41,7 @@ export async function writeKdbxFixture(): Promise<KdbxFixture> {
kdbx.getRootGroup(),
createEntry({ title: entryTitle, username: 'octocat', password: 'hunter2' }),
);
appendChild(kdbx.getRootGroup(), createGroup(groupName));
const bytes = await kdbx.save();

const path = join(
Expand All @@ -40,5 +50,5 @@ export async function writeKdbxFixture(): Promise<KdbxFixture> {
);
await writeFile(path, bytes);

return { path, password, entryTitle };
return { path, password, entryTitle, groupName };
}
70 changes: 64 additions & 6 deletions pages/0x67/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
--warning-bg: #fbf0e7;
--warning-border: #ead7c2;
--success: #0e7c5a;
--sidebar-width: 210px;
/* 25 chars of a first-level sub-group (#63) at .group-btn's size, plus icon, padding, nesting, and the ⋯ slot. */
--sidebar-width: 280px;
}

body {
Expand Down Expand Up @@ -470,6 +471,21 @@ through to notice unsaved edits, unlike every other dirty-aware prompt. */
overflow: hidden;
}

/* A flex sibling of the rail, not an overlay on it (#63): the tree scrolls, and
an overlaid handle would sit on top of its scrollbar. */
.sidebar-resize {
flex: 0 0 6px;
margin: 0;
border: none;
cursor: col-resize;
touch-action: none; /* a drag here resizes (#63); scrolling must not claim it */
}

.sidebar-resize:hover,
.sidebar-resize:focus-visible {
background: var(--accent-dim);
}

.sidebar-header {
display: flex;
align-items: center;
Expand All @@ -479,6 +495,11 @@ through to notice unsaved edits, unlike every other dirty-aware prompt. */
flex-shrink: 0;
}

.sidebar-header-actions {
display: flex;
gap: 0.15rem;
}

.sidebar-label {
font-size: 0.75rem;
font-weight: 600;
Expand Down Expand Up @@ -508,14 +529,41 @@ through to notice unsaved edits, unlike every other dirty-aware prompt. */
gap: 0.1rem;
}

.group-actions {
display: flex;
/* Reserved on every row, shown only on the active one (#63), so selecting a
group doesn't reflow its name. */
.group-menu-btn {
flex-shrink: 0;
}

.group-action-btn {
padding: 0.2rem 0.3rem;
font-size: 0.8rem;
visibility: hidden;
}

.group-row-active .group-menu-btn {
visibility: visible;
}

/* Inline, not a floating popup (#63): the rail and the tree both clip overflow,
so an absolutely positioned menu would be cut off. */
.group-menu {
display: flex;
gap: 0.25rem;
padding: 0.15rem 0 0.35rem 1rem;
}

.group-menu-item {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
cursor: pointer;
font-family: inherit;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
white-space: nowrap;
}

.group-menu-item:hover {
background: var(--surface-2);
}

.group-btn {
Expand Down Expand Up @@ -823,6 +871,16 @@ through to notice unsaved edits, unlike every other dirty-aware prompt. */
transition: transform 0.2s ease;
}

.sidebar-resize {
display: none;
}

/* Tapping a group closes the drawer (#63), so gating ⋯ on the active row
would put rename and move two drawer visits away; show it on every row. */
.group-menu-btn {
visibility: visible;
}

.sidebar.sidebar-open {
transform: translateX(0);
}
Expand Down
8 changes: 6 additions & 2 deletions pages/0x67/page.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,14 @@ <h1 class="db-filename">New database</h1>
<aside id="sidebar" class="sidebar">
<div class="sidebar-header">
<span class="sidebar-label">Groups</span>
<button type="button" class="icon-btn" data-action="add-group" title="New group">+</button>
<div class="sidebar-header-actions">
<button type="button" class="icon-btn" data-action="add-group" title="New group">➕</button>
<button type="button" class="icon-btn" id="delete-group-btn" data-action="delete-group" title="Delete group" aria-label="Delete group">🗑</button>
</div>
</div>
<nav id="group-tree" class="group-tree"></nav>
</aside>
<hr id="sidebar-resize" class="sidebar-resize" tabindex="0" aria-orientation="vertical" aria-label="Resize groups panel" aria-valuenow="280">
<main class="entry-panel">
<div class="panel-header">
<span id="panel-title" class="panel-title"></span>
Expand All @@ -143,7 +147,7 @@ <h1 class="db-filename">New database</h1>
<option value="modified:asc">Modified (oldest)</option>
</select>
</div>
<button type="button" class="icon-btn" data-action="add-entry" title="New entry">+</button>
<button type="button" class="icon-btn" data-action="add-entry" title="New entry"></button>
</div>
</div>
<div id="entry-list" class="entry-list"></div>
Expand Down
Loading