From a8c66648514331ed6a7c1b514c2c4ca9a4c9a992 Mon Sep 17 00:00:00 2001
From: Allisson Azevedo
Date: Thu, 12 Nov 2026 21:47:27 -0300
Subject: [PATCH] feat(web): the item detail's bar and icon buttons, the
editor's fixed bar and the generator in a dialog (D254)
Edit is the detail's primary button beside "More actions"; Copy and Open on a field that is not
secret are icon buttons with an accessible name and a tooltip, and History is also a link at the
detail's foot. The editor's Cancel and Save sit in a bar fixed at the bottom of the pane. The
generator opens from an icon button in the password field, in a modal dialog, a bottom sheet on a
phone, with the same settings, entropy and "Use this password". Icons `dices` and `external-link`
copied from Lucide 1.61.0.
Co-Authored-By: Claude Opus 5.5
---
apps/web/e2e/accessibility.spec.ts | 2 +
apps/web/e2e/axe.spec.ts | 2 +-
apps/web/e2e/backup-merge.spec.ts | 1 -
apps/web/e2e/duplicate.spec.ts | 2 -
apps/web/e2e/form-history.spec.ts | 1 +
apps/web/e2e/item-detail.spec.ts | 19 +++++--
apps/web/e2e/keyboard.spec.ts | 1 -
apps/web/e2e/layout.spec.ts | 1 +
apps/web/e2e/logs.spec.ts | 1 -
apps/web/e2e/screen-reader.spec.ts | 2 +-
apps/web/e2e/sync.spec.ts | 3 --
apps/web/e2e/write-navigation.spec.ts | 1 -
apps/web/src/ui/Generator.tsx | 41 ++++++++------
apps/web/src/ui/ItemDetail.tsx | 52 ++++++++++++++----
apps/web/src/ui/ItemEditor.tsx | 78 ++++++++++++++++-----------
apps/web/src/ui/Secret.tsx | 6 ++-
apps/web/src/ui/VaultShell.tsx | 7 ++-
apps/web/src/ui/components.tsx | 63 ++++++++++++++--------
apps/web/src/ui/icons/index.tsx | 17 ++++++
docs/roadmap.md | 2 +-
20 files changed, 205 insertions(+), 97 deletions(-)
diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts
index e166c67..a1ff99b 100644
--- a/apps/web/e2e/accessibility.spec.ts
+++ b/apps/web/e2e/accessibility.spec.ts
@@ -20,6 +20,7 @@ test('names the generator, describes a field by its error, and reads a mask and
// The generator is a named group.
await page.getByRole('button', { name: 'Generate', exact: true }).click();
await expect(page.getByRole('group', { name: 'Password generator' })).toBeVisible();
+ await page.keyboard.press('Escape');
// A refused value: the field's error is its description.
await page.getByTestId('item-title').fill('ACME');
@@ -57,6 +58,7 @@ test('reflows at 320 px with no horizontal scroll: start, list, editor and Setti
await page.getByRole('button', { name: 'Login', exact: true }).click();
await page.getByRole('button', { name: 'Generate', exact: true }).click();
expect(await fits()).toBe(true);
+ await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: 'Settings' }).first().click();
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
diff --git a/apps/web/e2e/axe.spec.ts b/apps/web/e2e/axe.spec.ts
index e6006b4..67c217f 100644
--- a/apps/web/e2e/axe.spec.ts
+++ b/apps/web/e2e/axe.spec.ts
@@ -66,6 +66,7 @@ for (const [layout, width] of WIDTHS) {
await button(page, 'Login').click();
await button(page, 'Generate').click();
await audit(page, 'the item editor and the generator');
+ await page.keyboard.press('Escape');
await button(page, 'Save').click();
await expect(page.getByText('Enter a title.')).toBeVisible();
await audit(page, 'the item editor with an error');
@@ -79,7 +80,6 @@ for (const [layout, width] of WIDTHS) {
await button(page, 'Cancel').click();
await audit(page, 'the discard confirmation');
await button(page, 'Discard the changes').click();
- await button(page, 'More actions').click();
await button(page, 'History').click();
await audit(page, 'an item history');
diff --git a/apps/web/e2e/backup-merge.spec.ts b/apps/web/e2e/backup-merge.spec.ts
index 7b852eb..d716ec1 100644
--- a/apps/web/e2e/backup-merge.spec.ts
+++ b/apps/web/e2e/backup-merge.spec.ts
@@ -131,7 +131,6 @@ test('merges a backup made on another installation, keeping the losing edit in h
await button(a, 'ACME').click();
await button(a, 'Show').click();
await expect(a.getByTestId('secret-Password')).toHaveText(ON_B);
- await button(a, 'More actions').click();
await button(a, 'History').click();
const versions = a.locator('article').getByRole('listitem');
await expect(versions).toHaveCount(2);
diff --git a/apps/web/e2e/duplicate.spec.ts b/apps/web/e2e/duplicate.spec.ts
index 01f7e7f..d36a4c1 100644
--- a/apps/web/e2e/duplicate.spec.ts
+++ b/apps/web/e2e/duplicate.spec.ts
@@ -42,7 +42,6 @@ for (const [layout, width] of WIDTHS) {
await expect(page.getByRole('heading', { name: 'Harbor', level: 1 })).toBeVisible();
// The history says old values stay, and keeps them masked.
- await button(page, 'More actions').click();
await button(page, 'History').click();
await expect(page.getByRole('heading', { name: 'History of Harbor' })).toBeVisible();
await expect(page.getByText(/Earlier values stay in this history/)).toBeVisible();
@@ -65,7 +64,6 @@ for (const [layout, width] of WIDTHS) {
await expect(page.getByText('Work', { exact: true }).last()).toBeVisible();
await button(page, 'Show').click();
await expect(page.getByTestId('secret-Password')).toHaveText(OLD);
- await button(page, 'More actions').click();
await button(page, 'History').click();
await expect(page.getByText('No earlier versions.')).toBeVisible();
await button(page, 'Back to the item').click();
diff --git a/apps/web/e2e/form-history.spec.ts b/apps/web/e2e/form-history.spec.ts
index b959f22..2cf6460 100644
--- a/apps/web/e2e/form-history.spec.ts
+++ b/apps/web/e2e/form-history.spec.ts
@@ -118,6 +118,7 @@ test('keeps vault content out of the form history and the spellchecker (D174, D1
}
await button(page, 'Generate').first().click();
await expectNoneKept(page);
+ await page.keyboard.press('Escape');
await button(page, 'Cancel').click();
// The added fields are a change: leaving asks first (D234).
await button(page, 'Discard the changes').click();
diff --git a/apps/web/e2e/item-detail.spec.ts b/apps/web/e2e/item-detail.spec.ts
index f46ea79..0345710 100644
--- a/apps/web/e2e/item-detail.spec.ts
+++ b/apps/web/e2e/item-detail.spec.ts
@@ -3,8 +3,8 @@ import { type Page, expect, test } from '@playwright/test';
import { createVault } from './create';
// The item detail (D247): a header with the avatar and chips, fields in titled cards, icon and text
-// on a secret's buttons, Duplicate, History and Move to trash under "More actions", and a shown
-// seed phrase as a numbered grid.
+// on a secret's buttons and icon buttons on the others (D254), Duplicate, History and Move to trash
+// under "More actions", and a shown seed phrase as a numbered grid.
test.setTimeout(120_000);
@@ -57,16 +57,27 @@ test('the detail shows chips, titled cards and the actions menu (D247)', async (
// A secret's buttons carry an icon beside their visible text.
await expect(button(page, 'Show').locator('svg')).toHaveCount(1);
await expect(button(page, 'Copy: Password').locator('svg')).toHaveCount(1);
+ // Other fields: Copy and Open are icon buttons, named and with a tooltip (D254).
+ const copyUser = button(page, 'Copy: Username');
+ await expect(copyUser).toHaveAttribute('title', 'Copy');
+ await expect(copyUser).toHaveText('');
+ await expect(page.getByRole('link', { name: 'Open: Website', exact: true })).toHaveAttribute(
+ 'href',
+ 'https://bank.example/login',
+ );
// Edit is visible; the rest waits under More actions.
await expect(button(page, 'Edit')).toBeVisible();
await expect(button(page, 'Duplicate')).toHaveCount(0);
await button(page, 'More actions').click();
+ const menu = page.locator(':popover-open');
for (const name of ['Duplicate', 'History', 'Move to trash']) {
- await expect(button(page, name)).toBeVisible();
+ await expect(menu.getByRole('button', { name, exact: true })).toBeVisible();
}
await page.keyboard.press('Escape');
- await expect(button(page, 'History')).toHaveCount(0);
+ await expect(button(page, 'Duplicate')).toHaveCount(0);
+ // History is also a link at the detail's foot (D254).
+ await expect(button(page, 'History')).toBeVisible();
});
test('a shown seed phrase is a numbered grid of its words (D247)', async ({ page }) => {
diff --git a/apps/web/e2e/keyboard.spec.ts b/apps/web/e2e/keyboard.spec.ts
index 1254d79..74b40f1 100644
--- a/apps/web/e2e/keyboard.spec.ts
+++ b/apps/web/e2e/keyboard.spec.ts
@@ -109,7 +109,6 @@ for (const [layout, width] of WIDTHS) {
await expect(page.getByTestId('discard-confirm')).toBeVisible();
await press(page, button(page, 'Discard the changes'));
await expect(page.getByRole('heading', { name: 'Harbor', level: 1 })).toBeVisible();
- await press(page, button(page, 'More actions'));
await press(page, button(page, 'History'));
await expect(page.getByRole('heading', { name: 'History' }).first()).toBeVisible();
diff --git a/apps/web/e2e/layout.spec.ts b/apps/web/e2e/layout.spec.ts
index 110ae9d..18df7dd 100644
--- a/apps/web/e2e/layout.spec.ts
+++ b/apps/web/e2e/layout.spec.ts
@@ -353,6 +353,7 @@ test.describe('on a coarse pointer', () => {
await page.getByRole('button', { name: 'Generate', exact: true }).click();
await expect(page.getByTestId('generator-entropy')).toBeVisible();
expect(await smallTargets(page), `${layout}: the editor and its generator`).toEqual([]);
+ await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).first().click();
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
diff --git a/apps/web/e2e/logs.spec.ts b/apps/web/e2e/logs.spec.ts
index 8960c36..5a5acee 100644
--- a/apps/web/e2e/logs.spec.ts
+++ b/apps/web/e2e/logs.spec.ts
@@ -57,7 +57,6 @@ test('logs nothing, and no secret, through a full use (CRYPTO-INV-005, 006)', as
await page.getByRole('button', { name: 'Edit', exact: true }).click();
await page.getByTestId('input-Password').fill(ROTATED);
await page.getByRole('button', { name: 'Save', exact: true }).click();
- await page.getByRole('button', { name: 'More actions', exact: true }).click();
await page.getByRole('button', { name: 'History', exact: true }).click();
await page.getByRole('button', { name: 'Show', exact: true }).first().click();
await expect(page.getByText(SECRET)).toBeVisible();
diff --git a/apps/web/e2e/screen-reader.spec.ts b/apps/web/e2e/screen-reader.spec.ts
index 7854356..ffc54c5 100644
--- a/apps/web/e2e/screen-reader.spec.ts
+++ b/apps/web/e2e/screen-reader.spec.ts
@@ -132,8 +132,8 @@ for (const [layout, width] of WIDTHS) {
- button "Show"
- button "Generate"
- alert: Fix the fields marked above.
- - button "Save"
- button "Cancel"
+ - button "Save"
`);
// An item: the secret is read as "Hidden", and a copy is announced with its clipboard note.
diff --git a/apps/web/e2e/sync.spec.ts b/apps/web/e2e/sync.spec.ts
index 86e4b94..be4a0e2 100644
--- a/apps/web/e2e/sync.spec.ts
+++ b/apps/web/e2e/sync.spec.ts
@@ -440,7 +440,6 @@ test('concurrent edits converge in one sync: the newer wins on both, the other i
for (const page of [a, b]) {
await expect(button(page, 'ACME on A')).toHaveCount(0);
await button(page, 'ACME on B').click();
- await button(page, 'More actions').click();
await button(page, 'History').click();
// A's edit lost on both, and both keep it, above the first state.
const versions = page.locator('article').getByRole('listitem');
@@ -478,7 +477,6 @@ test('the history cut drops an edit of A in one sync: only A tells its user, and
await closed(a, b);
for (const page of [a, b]) {
await button(page, title).click();
- await button(page, 'More actions').click();
await button(page, 'History').click();
const versions = page.locator('article').getByRole('listitem');
await expect(versions).toHaveCount(20);
@@ -516,7 +514,6 @@ test('an edit older than a trash never brings the item back: in the trash on bot
await expect(page.getByText('The trash is empty.')).toBeVisible();
await button(page, 'All items').click();
await button(page, 'ACME').click();
- await button(page, 'More actions').click();
await button(page, 'History').click();
const versions = page.locator('article').getByRole('listitem');
await expect(versions.filter({ hasText: 'ACME on A' })).toHaveCount(1);
diff --git a/apps/web/e2e/write-navigation.spec.ts b/apps/web/e2e/write-navigation.spec.ts
index d5c4c19..1fbd865 100644
--- a/apps/web/e2e/write-navigation.spec.ts
+++ b/apps/web/e2e/write-navigation.spec.ts
@@ -77,7 +77,6 @@ test('another item chosen while a version is restored stays shown (D210)', async
await page.getByTestId('item-title').fill('ACME 2');
await button(page, 'Save').click();
await expect(heading(page, 'ACME 2', 1)).toBeVisible();
- await button(page, 'More actions').click();
await button(page, 'History').click();
await together(button(page, 'Restore this version'), button(page, 'Beta'));
await expect(button(page, 'ACME')).toBeVisible();
diff --git a/apps/web/src/ui/Generator.tsx b/apps/web/src/ui/Generator.tsx
index 1abf0a3..f33d392 100644
--- a/apps/web/src/ui/Generator.tsx
+++ b/apps/web/src/ui/Generator.tsx
@@ -34,7 +34,13 @@ function entropy(s: GeneratorSettings): number | null {
* The password generator of the item editor (overview §13.6). The generated value goes straight
* into the field being edited; only the settings are kept.
*/
-export function Generator({ onUse }: { readonly onUse: (value: string) => void }) {
+export function Generator({
+ onUse,
+ onCancel,
+}: {
+ readonly onUse: (value: string) => void;
+ readonly onCancel: () => void;
+}) {
const m = useMessages();
const [settings, setSettings] = useState(loadGeneratorSettings);
// Counts typed as text, so a value can be typed through an invalid one.
@@ -46,8 +52,8 @@ export function Generator({ onUse }: { readonly onUse: (value: string) => void }
};
const bits = entropy(settings);
return (
-
) : null}
-
+ {/* Fixed at the bottom of the pane, so Save never scrolls away (D254). */}
+
{leaving ? (
void;
+ /** A button after Show, as the generator's (D254). */
+ readonly action?: ReactNode;
}) {
const m = useMessages();
const id = useId();
@@ -211,6 +214,7 @@ export function SecretInput({
setShown(!shown);
}}
/>
+ {action}
void;
}) {
const m = useMessages();
+ // On a phone the whole screen scrolls, so the editor's bar sticks to it (D254).
+ const scrolls = useLayout() !== 'mobile';
const screen = useSyncExternalStore(nav.subscribe, nav.current);
if (!isDetail(screen)) {
return (
@@ -59,7 +61,10 @@ function DetailPane({
? `new-${String(nav.opening(screen))}`
: `${screen.name}-${hex(screen.id)}`;
return (
-
+
{screen.name === 'item' ? (
) : screen.name === 'history' ? (
diff --git a/apps/web/src/ui/components.tsx b/apps/web/src/ui/components.tsx
index f6b263d..9ec1e03 100644
--- a/apps/web/src/ui/components.tsx
+++ b/apps/web/src/ui/components.tsx
@@ -111,28 +111,14 @@ export function CopyCheck({ shown }: { readonly shown: boolean }) {
}
/**
- * A destructive or security confirmation in the native modal dialog (D231, D233): the focus stays
- * inside, Escape cancels and the rest of the page is inert. Shown while mounted; its text is the
- * caller's, unchanged from the inline confirmation it replaces. On a phone the same dialog is a
- * bottom sheet, its buttons full width with the action first (D250).
+ * The native modal dialog (D231, D233): the focus stays inside, Escape calls `onCancel` and the rest
+ * of the page is inert. Shown while mounted. On a phone it is a bottom sheet (D250).
*/
-export function ConfirmDialog({
- confirm,
- cancel,
- onConfirm,
+export function Modal({
onCancel,
- busy = false,
- tone = 'danger',
children,
...props
-}: Omit, 'onCancel'> & {
- readonly confirm: string;
- readonly cancel: string;
- readonly onConfirm: () => void;
- readonly onCancel: () => void;
- readonly busy?: boolean;
- readonly tone?: 'danger' | 'primary';
-}) {
+}: Omit, 'onCancel'> & { readonly onCancel: () => void }) {
const ref = useRef(null);
const sheet = useLayout() === 'mobile';
useEffect(() => {
@@ -145,7 +131,6 @@ export function ConfirmDialog({
return (
+ );
+}
+
+/**
+ * A destructive or security confirmation in a `Modal`; its text is the caller's, unchanged from the
+ * inline confirmation it replaces. On a phone its buttons are full width with the action first.
+ */
+export function ConfirmDialog({
+ confirm,
+ cancel,
+ onConfirm,
+ onCancel,
+ busy = false,
+ tone = 'danger',
+ children,
+ ...props
+}: Omit, 'onCancel'> & {
+ readonly confirm: string;
+ readonly cancel: string;
+ readonly onConfirm: () => void;
+ readonly onCancel: () => void;
+ readonly busy?: boolean;
+ readonly tone?: 'danger' | 'primary';
+}) {
+ const sheet = useLayout() === 'mobile';
+ return (
+ {
+ // Not once the work started.
if (!busy) onCancel();
}}
{...props}
@@ -181,7 +202,7 @@ export function ConfirmDialog({
)}
-
+
);
}
diff --git a/apps/web/src/ui/icons/index.tsx b/apps/web/src/ui/icons/index.tsx
index f8c64ff..278a43b 100644
--- a/apps/web/src/ui/icons/index.tsx
+++ b/apps/web/src/ui/icons/index.tsx
@@ -38,6 +38,16 @@ const PATHS = {
>
),
+ dices: (
+ <>
+
+
+
+
+
+
+ >
+ ),
download: (
<>
@@ -52,6 +62,13 @@ const PATHS = {
>
),
+ 'external-link': (
+ <>
+
+
+
+ >
+ ),
eye: (
<>
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 5413e48..6ffd717 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -435,7 +435,7 @@ and the password generator. Then `docs/record-content.md` vectors, `packages/vau
- [x] Capture every v0.7.1 screen and approve an HTML prototype of the layout and presentation changes (`docs/prototypes/phase-7b-layout.html`, D254)
- [x] Notices: the Preview badge, the storage warning on one line with Details, the backup reminder at the top of the list, a neutral lock badge (D254)
- [x] Lists: folders and types as pills opening a bottom sheet on a phone, the toast above the bottom navigation (D254)
-- [ ] Item detail and editor: the header bar, divided field rows, icon buttons on non-secret fields, the fixed Cancel and Save bar, the generator in a dialog or bottom sheet (D254)
+- [x] Item detail and editor: the header bar, divided field rows, icon buttons on non-secret fields, the fixed Cancel and Save bar, the generator in a dialog or bottom sheet (D254)
- [ ] Show and Hide on every password field, masked again on submit, on a screen change and on lock (D254)
- [ ] Settings inside the vault frame on tablet and desktop; Devices with the Pair and Re-admit cards (D254)
- [ ] Wizards on a screen of their own: "Next:", Cancel's place, the fixed action on a phone, the file drop area with Continue (D254)