diff --git a/apps/web/e2e/about.spec.ts b/apps/web/e2e/about.spec.ts
index af50bd8..afe815d 100644
--- a/apps/web/e2e/about.spec.ts
+++ b/apps/web/e2e/about.spec.ts
@@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs';
import { type Page, expect, test } from '@playwright/test';
-import { createVault, startScreen } from './create';
+import { createVault, openSettingsPage, startScreen } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9).
test.setTimeout(120_000);
@@ -49,7 +49,7 @@ test('shows the version and the hash of the running build, before unlock and in
await startScreen(page);
await createVault(page, PASSWORD);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'about');
await expectAbout(page, hash);
// Opening About before unlock hides the screen without losing what was typed.
diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts
index d39b1de..e166c67 100644
--- a/apps/web/e2e/accessibility.spec.ts
+++ b/apps/web/e2e/accessibility.spec.ts
@@ -62,7 +62,17 @@ test('reflows at 320 px with no horizontal scroll: start, list, editor and Setti
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
expect(await fits()).toBe(true);
// Longer labels in Brazilian Portuguese.
+ await page.getByTestId('settings-language').click();
await page.getByRole('combobox', { name: 'Language' }).selectOption('pt-BR');
+ await expect(page.getByRole('heading', { name: 'Idioma', level: 1 })).toBeVisible();
+ expect(await fits()).toBe(true);
+ // The densest pages, in Portuguese too.
+ await page.getByRole('button', { name: 'Voltar', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Configurações', level: 1 })).toBeVisible();
expect(await fits()).toBe(true);
+ for (const row of ['devices', 'backup']) {
+ await page.getByTestId(`settings-${row}`).click();
+ expect(await fits()).toBe(true);
+ await page.getByRole('button', { name: 'Voltar', exact: true }).click();
+ }
});
diff --git a/apps/web/e2e/appearance.spec.ts b/apps/web/e2e/appearance.spec.ts
index 170adc9..61cf7d8 100644
--- a/apps/web/e2e/appearance.spec.ts
+++ b/apps/web/e2e/appearance.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9), on every browser.
test.setTimeout(120_000);
@@ -10,7 +10,7 @@ for (const theme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme: theme });
await page.goto('/');
await createVault(page, 'quartz-lantern-velvet-harbor-9214');
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'appearance');
const swatches = page.locator('fieldset span[data-accent]');
await expect(swatches).toHaveCount(6);
const colors = await swatches.evaluateAll((spans) =>
diff --git a/apps/web/e2e/axe-devices.spec.ts b/apps/web/e2e/axe-devices.spec.ts
index ad86ac3..e26da6e 100644
--- a/apps/web/e2e/axe-devices.spec.ts
+++ b/apps/web/e2e/axe-devices.spec.ts
@@ -45,11 +45,12 @@ async function device(
return page;
}
-/** Goes back to Settings from wherever the vault is (on mobile, through Back). */
+/** Goes back to Settings › Devices from wherever the vault is (on mobile, through Back). */
async function settings(page: Page): Promise {
const open = page.getByRole('button', { name: 'Settings' }).first();
while (!(await open.isVisible())) await button(page, 'Back').click();
await open.click();
+ await page.getByTestId('settings-devices').click();
}
for (const [layout, width] of WIDTHS) {
diff --git a/apps/web/e2e/axe.spec.ts b/apps/web/e2e/axe.spec.ts
index c786e0f..90b0924 100644
--- a/apps/web/e2e/axe.spec.ts
+++ b/apps/web/e2e/axe.spec.ts
@@ -90,6 +90,15 @@ for (const [layout, width] of WIDTHS) {
await settings.click();
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
await audit(page, 'Settings');
+ // The densest pages (D252).
+ await page.getByTestId('settings-devices').click();
+ await expect(page.getByRole('heading', { name: 'Devices', level: 1 })).toBeVisible();
+ await audit(page, 'Settings, Devices');
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-backup').click();
+ await expect(page.getByRole('heading', { name: 'Backup', level: 1 })).toBeVisible();
+ await audit(page, 'Settings, Backup');
+ await button(page, 'Back').click();
// Locked: unlock, and the forgotten password.
await lock(page);
diff --git a/apps/web/e2e/backup-merge.spec.ts b/apps/web/e2e/backup-merge.spec.ts
index a7434c8..c84a032 100644
--- a/apps/web/e2e/backup-merge.spec.ts
+++ b/apps/web/e2e/backup-merge.spec.ts
@@ -4,7 +4,7 @@ import { type Browser, type BrowserContext, type Page, expect, test } from '@pla
import { FUTURE_CLOCK_BOUND_MS } from '@blindvault/vault';
-import { createVault, openVault, startScreen } from './create';
+import { createVault, openSettingsPage, openVault, startScreen } from './create';
import { PAIRING_URL } from '../playwright.config';
import { fillPassword } from './password';
@@ -42,13 +42,9 @@ async function installation(browser: Browser, logged: string[]): Promise {
return page;
}
-async function openSettings(page: Page) {
- await page.getByRole('button', { name: 'Settings' }).first().click();
-}
-
/** Exports from Settings with the local password and returns the file the browser received. */
async function exportBackup(page: Page, password: string): Promise {
- await openSettings(page);
+ await openSettingsPage(page, 'backup');
await page.getByTestId('backup-password').fill(password);
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 60_000 }),
@@ -56,6 +52,7 @@ async function exportBackup(page: Page, password: string): Promise {
]);
const bytes = await readFile(await download.path());
await button(page, 'Back').click();
+ await button(page, 'Back').click();
return bytes;
}
@@ -70,7 +67,7 @@ async function setPassword(page: Page, value: string) {
/** Settings › Merge a backup: the file, the Recovery Phrase, then the confirmation. */
async function openForMerge(page: Page, file: Buffer, words: readonly string[]) {
// After a merge, Settings still shows it, with no Settings button of its own.
- if (!(await page.getByTestId('merge-start').isVisible())) await openSettings(page);
+ if (!(await page.getByTestId('merge-start').isVisible())) await openSettingsPage(page, 'merge');
await page.getByTestId('merge-start').click();
await page.getByTestId('restore-file').setInputFiles({
name: 'blindvault-backup.bvault',
@@ -128,6 +125,7 @@ test('merges a backup made on another installation, keeping the losing edit in h
// B's edit won; A's own edit is in the history, above the first state.
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await button(a, 'ACME').click();
await button(a, 'Show').click();
await expect(a.getByTestId('secret-Password')).toHaveText(ON_B);
@@ -221,7 +219,7 @@ test('a merge of a backup opened by its export password recommends Check Recover
const logged: string[] = [];
const a = await installation(browser, logged);
await createVault(a, PASSWORD);
- await openSettings(a);
+ await openSettingsPage(a, 'backup');
await a.getByTestId('backup-protect').check();
await button(a, 'Generate a passphrase').click();
const passphrase = await a.getByTestId('export-password').inputValue();
@@ -235,6 +233,8 @@ test('a merge of a backup opened by its export password recommends Check Recover
await expect(a.getByTestId('after-restore')).toHaveCount(0);
// Its own backup merges with nothing to change; the export password opened it.
+ await button(a, 'Back').click();
+ await a.getByTestId('settings-merge').click();
await a.getByTestId('merge-start').click();
await a.getByTestId('restore-file').setInputFiles({
name: 'blindvault-backup.bvault',
@@ -273,7 +273,7 @@ test.describe('devices a merge brings', () => {
/** A pairs a new installation, same network only; A ends back in its vault. */
async function pair(browser: Browser, a: Page, logged: string[]) {
- await openSettings(a);
+ await openSettingsPage(a, 'devices');
await button(a, 'Pair a device…').click();
await a.getByTestId('pairing-same-network').check();
await fillPassword(a.getByTestId('invite-password'), PASSWORD);
@@ -300,6 +300,7 @@ test.describe('devices a merge brings', () => {
await expect(a.getByTestId('pairing-done')).toBeVisible({ timeout: 60_000 });
await button(a, 'Close').click();
await button(a, 'Back').click();
+ await button(a, 'Back').click();
}
test('a merge names a device this one never saw, then one retired elsewhere, as a sign of the Master Key held by someone else (§1.6, D182)', async ({
@@ -339,11 +340,12 @@ test.describe('devices a merge brings', () => {
await expect(result).not.toContainText('Devices now retired');
// A retires the paired device; its next backup tells C.
- await openSettings(a);
+ await openSettingsPage(a, 'devices');
await a.getByTestId('device-retire').click();
await a.getByRole('button', { name: /^Retire / }).click();
await expect(a.getByTestId('device')).toHaveCount(1);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
const retired = await exportBackup(a, PASSWORD);
await openForMerge(c, retired, words);
diff --git a/apps/web/e2e/backup.spec.ts b/apps/web/e2e/backup.spec.ts
index 8ae589f..7c3923b 100644
--- a/apps/web/e2e/backup.spec.ts
+++ b/apps/web/e2e/backup.spec.ts
@@ -4,7 +4,7 @@ import { RecordType } from '@blindvault/crypto';
import { type Page, expect, test } from '@playwright/test';
import { corruptRecords } from './corrupt';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9); an export derives twice.
test.setTimeout(180_000);
@@ -43,7 +43,7 @@ test('exports a backup, asking for the local password again, then one under a ge
await page.getByTestId('input-Password').fill(SECRET);
await button(page, 'Save').click();
await expect(page.getByRole('heading', { name: 'ACME', level: 1 })).toBeVisible();
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'backup');
await expect(
page.getByText(
'A backup is an encrypted file with a full copy of this vault. Your Recovery Phrase always opens it. Keep copies away from this device, so you can restore if you lose it.',
@@ -140,7 +140,7 @@ test('a record that fails verification makes no backup, and the export names it
await page.getByTestId('item-title').fill('ACME');
await button(page, 'Save').click();
await expect(page.getByRole('heading', { name: 'ACME', level: 1 })).toBeVisible();
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'backup');
// The export reads every record again from storage and verifies it (D181).
expect(await corruptRecords(page, RecordType.item)).toBeGreaterThan(0);
let downloaded = false;
diff --git a/apps/web/e2e/clock-behind.spec.ts b/apps/web/e2e/clock-behind.spec.ts
index 8314744..c54d4a3 100644
--- a/apps/web/e2e/clock-behind.spec.ts
+++ b/apps/web/e2e/clock-behind.spec.ts
@@ -2,7 +2,7 @@ import { type Page, expect, test } from '@playwright/test';
import { FUTURE_CLOCK_BOUND_MS } from '@blindvault/vault';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// The device clock goes back after the vault was written ahead (§9.2, D73, D149, D221): the vault is
// read-only until the clock catches up, every screen that writes says why, and writing resumes on
@@ -73,7 +73,7 @@ test('a clock gone back makes the vault read-only, each screen says why, and wri
await expect(button(page, 'Edit')).toHaveCount(0);
// Settings: no pairing, no re-admission and no new Recovery Phrase while read-only.
- await button(page, 'Settings').click();
+ await openSettingsPage(page, 'devices');
await expect(page.getByTestId('invite')).toContainText(
'The device clock went back, so the vault is read-only: pairing comes back once writing does.',
);
@@ -81,11 +81,14 @@ test('a clock gone back makes the vault read-only, each screen says why, and wri
await expect(page.getByTestId('readmit-device')).toContainText(
'The device clock went back, so the vault is read-only: re-admission comes back once writing does.',
);
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-recovery').click();
await expect(page.getByTestId('regenerate')).toContainText(
"This device's clock went back, so the vault is read-only. The Recovery Phrase can be regenerated from",
);
await expect(page.getByTestId('regenerate-start')).toHaveCount(0);
await button(page, 'Back').click();
+ await button(page, 'Back').click();
// A minute before writing resumes: within the bound, so only the date is shown.
await page.clock.setSystemTime(UNTIL - MINUTE);
diff --git a/apps/web/e2e/create.ts b/apps/web/e2e/create.ts
index 6b5eaad..b473c5f 100644
--- a/apps/web/e2e/create.ts
+++ b/apps/web/e2e/create.ts
@@ -84,3 +84,32 @@ export async function createVault(page: Page, password: string): Promise` test id. */
+export type SettingsPageName =
+ | 'appearance'
+ | 'language'
+ | 'locking'
+ | 'password'
+ | 'passkey'
+ | 'recovery'
+ | 'devices'
+ | 'backup'
+ | 'merge'
+ | 'import'
+ | 'storage'
+ | 'refound'
+ | 'erase'
+ | 'about';
+
+/** Opens Settings from the vault, then one of its pages. */
+export async function openSettingsPage(page: Page, name: SettingsPageName): Promise {
+ await page.getByRole('button', { name: 'Settings', exact: true }).first().click();
+ await page.getByTestId(`settings-${name}`).click();
+}
+
+/** From a page of Settings, back to the index and on to another page. */
+export async function switchSettingsPage(page: Page, name: SettingsPageName): Promise {
+ await page.getByRole('button', { name: 'Back', exact: true }).click();
+ await page.getByTestId(`settings-${name}`).click();
+}
diff --git a/apps/web/e2e/device-rename.spec.ts b/apps/web/e2e/device-rename.spec.ts
index 1b9acf1..bdedf51 100644
--- a/apps/web/e2e/device-rename.spec.ts
+++ b/apps/web/e2e/device-rename.spec.ts
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Creation and unlock each derive Argon2id at about 1 s (D9).
test.setTimeout(120_000);
@@ -12,7 +12,7 @@ test('renames this device from Settings, refusing an empty or too long name, and
}) => {
await page.goto('/');
await createVault(page, PASSWORD);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'devices');
const device = page.getByTestId('devices').getByTestId('device');
await device.getByTestId('device-rename').click();
@@ -33,7 +33,7 @@ test('renames this device from Settings, refusing an empty or too long name, and
await page.getByTestId('unlock-password').fill(PASSWORD);
await page.getByRole('button', { name: 'Unlock' }).click();
await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 });
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'devices');
await expect(
page.getByTestId('devices').getByTestId('device').locator('span.font-medium'),
).toHaveText('Work laptop (this device)');
diff --git a/apps/web/e2e/erase.spec.ts b/apps/web/e2e/erase.spec.ts
index 833c368..960d34f 100644
--- a/apps/web/e2e/erase.spec.ts
+++ b/apps/web/e2e/erase.spec.ts
@@ -1,6 +1,6 @@
import { type Page, expect, test } from '@playwright/test';
-import { createVault, startScreen } from './create';
+import { createVault, startScreen, openSettingsPage } from './create';
// Erase (§7, D41, D154, D183, D205): the warning, the Device record advice, the confirmation, then
// no vault. Creation derives Argon2id at about 1 s (D9).
@@ -45,7 +45,7 @@ test('erases the unlocked vault from Settings, saying it is the only device (D11
const words = await createVault(page, PASSWORD);
expect(await databases(page)).toHaveLength(1);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'erase');
await expect(page.getByText(INTRO)).toBeVisible();
await button(page, 'Erase this vault from this device…').click();
const warning = page.getByTestId('erase-warning');
@@ -54,6 +54,9 @@ test('erases the unlocked vault from Settings, saying it is the only device (D11
// The backup is one click away: the export password field takes the cursor.
await button(page, 'Make a backup now').click();
await expect(page.getByTestId('backup-password')).toBeFocused();
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-erase').click();
+ await button(page, 'Erase this vault from this device…').click();
await confirmErase(page);
await startScreen(page);
@@ -114,7 +117,7 @@ test('a second app window that takes over an erased vault shows the start screen
await second.goto('/');
await expect(second.getByText('BlindVault is open in another window.')).toBeVisible();
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'erase');
await button(page, 'Erase this vault from this device…').click();
await confirmErase(page);
await startScreen(page);
diff --git a/apps/web/e2e/feedback.spec.ts b/apps/web/e2e/feedback.spec.ts
index 2e6d103..f6722db 100644
--- a/apps/web/e2e/feedback.spec.ts
+++ b/apps/web/e2e/feedback.spec.ts
@@ -50,7 +50,8 @@ test('the empty vault shows its first steps, each opening where it is done (D250
await expect(steps.getByRole('img', { name: 'Done' })).toBeVisible();
await steps.getByRole('button', { name: 'Import', exact: true }).click();
- await expect(page.getByRole('heading', { name: 'Import', exact: true, level: 2 })).toBeFocused();
+ await expect(page.getByRole('heading', { name: 'Import', exact: true, level: 1 })).toBeFocused();
+ await button(page, 'Back').click();
await button(page, 'Back').click();
await steps.getByRole('button', { name: 'Add an item', exact: true }).click();
diff --git a/apps/web/e2e/form-history.spec.ts b/apps/web/e2e/form-history.spec.ts
index 02bafef..b959f22 100644
--- a/apps/web/e2e/form-history.spec.ts
+++ b/apps/web/e2e/form-history.spec.ts
@@ -1,6 +1,12 @@
import { type Page, expect, test } from '@playwright/test';
-import { answerConfirmation, openVault, readPhrase } from './create';
+import {
+ answerConfirmation,
+ openSettingsPage,
+ openVault,
+ readPhrase,
+ switchSettingsPage,
+} from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9), on every browser.
test.setTimeout(180_000);
@@ -121,9 +127,10 @@ test('keeps vault content out of the form history and the spellchecker (D174, D1
await page.getByTestId('search').fill('harbor');
await expectNoneKept(page);
await page.getByTestId('search').fill('');
- await button(page, 'Settings').click();
+ await openSettingsPage(page, 'password');
await expectNoneKept(page);
await expect(page.getByText(BROWSER_SAVE)).toBeVisible();
+ await switchSettingsPage(page, 'backup');
// The export password fields, with a generated passphrase shown as text (D181).
await page.getByTestId('backup-protect').check();
await button(page, 'Generate a passphrase').click();
diff --git a/apps/web/e2e/import.spec.ts b/apps/web/e2e/import.spec.ts
index 898cc9e..2ca2f9c 100644
--- a/apps/web/e2e/import.spec.ts
+++ b/apps/web/e2e/import.spec.ts
@@ -1,6 +1,6 @@
import { type Page, expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
import { storedPlaintext } from './stored';
// Import (overview §15, D123, D125, D146): preview, one commit into a landing folder, the delete
@@ -39,7 +39,7 @@ function exportFile(encrypted: boolean) {
}
async function preview(page: Page) {
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'import');
await page.getByTestId('import-start').click();
await page.getByTestId('import-source').selectOption('bitwarden');
await expect(page.getByText('holds every secret in plaintext')).toBeVisible();
@@ -76,6 +76,7 @@ test('imports a Bitwarden export into a landing folder in one commit, after a pr
);
await expect(page.getByTestId('import-result')).toContainText('Delete bitwarden_export.json now');
+ await button(page, 'Back').click();
await button(page, 'Back').click();
await page.getByRole('button', { name: FOLDER }).click();
await expect(button(page, 'Wifi')).toBeVisible();
diff --git a/apps/web/e2e/kept-aside.spec.ts b/apps/web/e2e/kept-aside.spec.ts
index 0b4b4d9..66db950 100644
--- a/apps/web/e2e/kept-aside.spec.ts
+++ b/apps/web/e2e/kept-aside.spec.ts
@@ -2,7 +2,7 @@ import { RecordType } from '@blindvault/crypto';
import { type Page, expect, test } from '@playwright/test';
import { corruptRecords } from './corrupt';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Records that fail verification at unlock are kept aside (D181): the vault opens, and each screen
// that would hand them on or rely on them says why instead (D45, D202, D207), as does the
@@ -38,7 +38,7 @@ test('records kept aside at unlock block pairing and re-admission, a damaged Rec
await button(page, 'Unlock').click();
await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 });
- await button(page, 'Settings').click();
+ await openSettingsPage(page, 'devices');
await expect(page.getByTestId('invite')).toContainText(
'This device holds a record it cannot read: pairing would hand it on. Re-found the vault or restore a backup.',
);
@@ -46,12 +46,16 @@ test('records kept aside at unlock block pairing and re-admission, a damaged Rec
await expect(page.getByTestId('readmit-device')).toContainText(
'This device holds a record it cannot read: re-admission would hand it on. Re-found the vault or restore a backup.',
);
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-recovery').click();
await expect(
page.getByText(
'The Recovery Envelope on this device is damaged, so the Recovery Phrase cannot be checked. Regenerate the Recovery Phrase to replace it.',
),
).toBeVisible();
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-refound').click();
const refound = page.getByTestId('refound');
await refound.getByTestId('refound-start').click();
await expect(refound.getByTestId('refound-corrupt')).toHaveText(
diff --git a/apps/web/e2e/late-code.spec.ts b/apps/web/e2e/late-code.spec.ts
index 6a68078..95c9b2f 100644
--- a/apps/web/e2e/late-code.spec.ts
+++ b/apps/web/e2e/late-code.spec.ts
@@ -1,6 +1,6 @@
import { type Request, expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9); this test derives five times.
test.setTimeout(180_000);
@@ -33,12 +33,14 @@ test('loads no script, worker or WebAssembly after the first unlock (D142)', asy
});
// The strength check and two derivations.
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'password');
await page.getByTestId('current-password').fill(PASSWORD);
await page.getByTestId('new-password').fill(NEXT);
await page.getByTestId('confirm-password').fill(NEXT);
await page.getByRole('button', { name: 'Change password' }).click();
await expect(page.getByText('Password changed.')).toBeVisible({ timeout: 30_000 });
+ await page.getByRole('button', { name: 'Back', exact: true }).click();
+ await page.getByTestId('settings-backup').click();
// An export under an export password: its strength check and two more derivations.
await page.getByTestId('backup-protect').check();
diff --git a/apps/web/e2e/layout.spec.ts b/apps/web/e2e/layout.spec.ts
index 3cc2149..69cbccc 100644
--- a/apps/web/e2e/layout.spec.ts
+++ b/apps/web/e2e/layout.spec.ts
@@ -97,9 +97,15 @@ for (const [layout, width] of WIDTHS) {
// Mobile has Settings in its bottom navigation, below the lists.
if (layout === 'mobile') await page.getByRole('button', { name: 'Back', exact: true }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).first().click();
- await expect(page.getByTestId('device-storage')).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
await expect(page.getByTestId('lock-state')).toHaveText('Unlocked');
await noOverflow(page, 'Settings');
+ // The densest pages (D252).
+ for (const row of ['devices', 'backup', 'storage']) {
+ await page.getByTestId(`settings-${row}`).click();
+ await noOverflow(page, `Settings, ${row}`);
+ await page.getByRole('button', { name: 'Back', exact: true }).click();
+ }
});
}
@@ -200,9 +206,12 @@ test("goes one screen back on the browser's back, through one entry that carries
// Settings on desktop goes back the same way.
await resize(page, 1280);
await page.getByRole('button', { name: 'Settings', exact: true }).first().click();
+ await page.getByTestId('settings-storage').click();
await expect(page.getByTestId('device-storage')).toBeVisible();
await page.goBack();
- await expect(page.getByTestId('device-storage')).toBeHidden();
+ await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
+ await page.goBack();
+ await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeHidden();
await expect(page.getByRole('button', { name: 'New item', exact: true })).toBeVisible();
expect(new URL(page.url()).pathname).toBe('/');
@@ -341,8 +350,13 @@ test.describe('on a coarse pointer', () => {
expect(await smallTargets(page), `${layout}: the editor and its generator`).toEqual([]);
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).first().click();
- await expect(page.getByTestId('device-storage')).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
expect(await smallTargets(page), `${layout}: Settings`).toEqual([]);
+ for (const row of ['devices', 'backup', 'storage']) {
+ await page.getByTestId(`settings-${row}`).click();
+ expect(await smallTargets(page), `${layout}: Settings, ${row}`).toEqual([]);
+ await page.getByRole('button', { name: 'Back', exact: true }).click();
+ }
await page.getByRole('button', { name: 'Back', exact: true }).click();
}
diff --git a/apps/web/e2e/list-row.spec.ts b/apps/web/e2e/list-row.spec.ts
index 2d20d85..099c0d3 100644
--- a/apps/web/e2e/list-row.spec.ts
+++ b/apps/web/e2e/list-row.spec.ts
@@ -1,6 +1,6 @@
import { type Page, expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// The list row (D246): avatar, a subtitle of non-secret fields, Copy for the password, and the
// phone's letter groups.
@@ -42,11 +42,12 @@ test('a row shows non-secret fields only and copies the password, cleared after
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/');
await createVault(page, PASSWORD);
- await button(page, 'Settings').first().click();
+ await openSettingsPage(page, 'locking');
await page.getByTestId('clipboardClearSeconds').fill('10');
await button(page, 'Save').click();
await expect(page.getByText('Saved.', { exact: true })).toBeVisible();
await button(page, 'Back').click();
+ await button(page, 'Back').click();
await newLogin(page, 'Harbor Credit Union');
await page.getByTestId('input-Username').fill('ana.lima');
diff --git a/apps/web/e2e/logs.spec.ts b/apps/web/e2e/logs.spec.ts
index 1fe8cdd..8960c36 100644
--- a/apps/web/e2e/logs.spec.ts
+++ b/apps/web/e2e/logs.spec.ts
@@ -1,6 +1,6 @@
import { type Page, expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9), on every browser.
test.setTimeout(120_000);
@@ -68,7 +68,7 @@ test('logs nothing, and no secret, through a full use (CRYPTO-INV-005, 006)', as
await expect(page.getByText('Wrong password.')).toBeVisible({ timeout: 30_000 });
await unlock(page, PASSWORD);
await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 });
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'password');
const change = page.getByRole('button', { name: 'Change password' });
await page.getByTestId('current-password').fill(WRONG);
await page.getByTestId('new-password').fill(NEXT);
diff --git a/apps/web/e2e/navigation.spec.ts b/apps/web/e2e/navigation.spec.ts
index f6f3ca3..b9ae598 100644
--- a/apps/web/e2e/navigation.spec.ts
+++ b/apps/web/e2e/navigation.spec.ts
@@ -110,3 +110,52 @@ test('filters the list by type, with counts beside the types and the folder pane
await pane.getByRole('button', { name: 'All items', exact: true }).click();
await expect(list).toHaveText(['Garden plan', 'Tax return']);
});
+
+test('Settings is an index of pages in groups; a page opens over it and Back returns, then leaves (D252)', async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 375, height: 800 });
+ await page.goto('/');
+ await createVault(page, PASSWORD);
+ await button(page, 'Settings').click();
+
+ await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
+ for (const group of ['Preferences', 'Security', 'Devices', 'Data', 'Danger zone']) {
+ await expect(page.getByRole('heading', { name: group, level: 2 })).toBeVisible();
+ }
+ for (const row of [
+ 'appearance',
+ 'language',
+ 'locking',
+ 'password',
+ 'passkey',
+ 'recovery',
+ 'devices',
+ 'backup',
+ 'merge',
+ 'import',
+ 'storage',
+ 'refound',
+ 'erase',
+ 'about',
+ ]) {
+ await expect(page.getByTestId(`settings-${row}`)).toBeVisible();
+ }
+ // A row shows its current value where one helps: a fresh vault has no backup, and locks after
+ // the default 5 minutes (DEFAULT_PREFERENCES).
+ await expect(page.getByTestId('settings-backup')).toContainText('Never');
+ await expect(page.getByTestId('settings-locking')).toContainText('5 min');
+
+ await page.getByTestId('settings-locking').click();
+ await expect(page.getByRole('heading', { name: 'Locking', level: 1 })).toBeFocused();
+ await expect(page.getByTestId('autoLockMinutes')).toBeVisible();
+ await expect(page.getByTestId('settings-locking')).toHaveCount(0);
+
+ await button(page, 'Back').click();
+ await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
+ await expect(page.getByTestId('settings-locking')).toBeVisible();
+
+ await button(page, 'Back').click();
+ await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toHaveCount(0);
+ await expect(button(page, 'New item')).toBeVisible();
+});
diff --git a/apps/web/e2e/onboarding.spec.ts b/apps/web/e2e/onboarding.spec.ts
index 1d424cc..c0ee1fb 100644
--- a/apps/web/e2e/onboarding.spec.ts
+++ b/apps/web/e2e/onboarding.spec.ts
@@ -97,6 +97,7 @@ for (const width of [390, 1280] as const) {
await expect(page.getByTestId('backup-password')).toBeFocused();
await expect(ready).toHaveCount(0);
await button(page, 'Back').click();
+ await button(page, 'Back').click();
await expect(ready).toBeVisible();
// Pairing opens with its password step already shown, the cursor in it (D234).
@@ -109,6 +110,7 @@ for (const width of [390, 1280] as const) {
),
).toBeVisible();
await button(page, 'Back').click();
+ await button(page, 'Back').click();
await expect(ready).toBeVisible();
// Closed: the vault, with the backup reminder, and never again after an unlock.
@@ -141,6 +143,7 @@ test('"Vault ready" marks the backup done once made, and still offers it (D234)'
button(page, 'Export').click(),
]);
await button(page, 'Back').click();
+ await button(page, 'Back').click();
await expect(backup).toHaveAttribute('data-done', 'true');
await expect(backup).toContainText('Done');
await expect(backup.getByRole('button')).toHaveText('Make a backup');
diff --git a/apps/web/e2e/pairing-qr.spec.ts b/apps/web/e2e/pairing-qr.spec.ts
index ec0d04a..95a4ee1 100644
--- a/apps/web/e2e/pairing-qr.spec.ts
+++ b/apps/web/e2e/pairing-qr.spec.ts
@@ -1,7 +1,7 @@
import { type Browser, type Page, expect, test } from '@playwright/test';
import { deniedCamera, expectCameraReleased, fakeQrCamera, noCamera, showQr } from './camera';
-import { createVault, openVault, startScreen } from './create';
+import { createVault, openVault, startScreen, openSettingsPage } from './create';
import { PAIRING_URL } from '../playwright.config';
import { fillPassword } from './password';
@@ -59,7 +59,7 @@ async function invite(a: Page): Promise {
await a.getByTestId('item-title').fill(TITLE);
await button(a, 'Save').click();
await expect(a.getByRole('heading', { name: TITLE })).toBeVisible();
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await button(a, 'Pair a device…').click();
await a.getByTestId('pairing-same-network').check();
await fillPassword(a.getByTestId('invite-password'), PASSWORD_A);
diff --git a/apps/web/e2e/pairing-stun.spec.ts b/apps/web/e2e/pairing-stun.spec.ts
index 027acb2..81309cd 100644
--- a/apps/web/e2e/pairing-stun.spec.ts
+++ b/apps/web/e2e/pairing-stun.spec.ts
@@ -1,6 +1,6 @@
import { type Browser, type Page, expect, test } from '@playwright/test';
-import { createVault, startScreen } from './create';
+import { createVault, startScreen, openSettingsPage } from './create';
import { PAIRING_URL } from '../playwright.config';
import { fillPassword } from './password';
@@ -64,7 +64,7 @@ test('a STUN list of its own: refused when invalid, saved, used by the pairing,
}) => {
const a = await device(browser);
await createVault(a, PASSWORD_A);
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await button(a, 'Pair a device…').click();
const hint = a.getByTestId('same-network-hint');
await expect(hint).toContainText('(stun.l.google.com, stun.cloudflare.com)');
@@ -144,7 +144,7 @@ test('an unreadable stored list: the same network only, locked on, and the pairi
const b = await device(browser);
await createVault(a, PASSWORD_A);
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await button(a, 'Pair a device…').click();
await a.getByTestId('pairing-same-network').check();
await fillPassword(a.getByTestId('invite-password'), PASSWORD_A);
diff --git a/apps/web/e2e/pairing.spec.ts b/apps/web/e2e/pairing.spec.ts
index 5a8bbad..0a1d966 100644
--- a/apps/web/e2e/pairing.spec.ts
+++ b/apps/web/e2e/pairing.spec.ts
@@ -2,7 +2,13 @@ import { RecordType } from '@blindvault/crypto';
import { type Browser, type Page, expect, test } from '@playwright/test';
import { corruptRecords } from './corrupt';
-import { createVault, openVault, startScreen } from './create';
+import {
+ createVault,
+ openSettingsPage,
+ openVault,
+ startScreen,
+ switchSettingsPage,
+} from './create';
import { PAIRING_URL } from '../playwright.config';
import { fillPassword } from './password';
@@ -54,7 +60,7 @@ async function invite(a: Page): Promise {
await button(a, 'Save').click();
await expect(a.getByRole('heading', { name: TITLE })).toBeVisible();
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await button(a, 'Pair a device…').click();
await a.getByTestId('pairing-same-network').check();
await fillPassword(a.getByTestId('invite-password'), PASSWORD_A);
@@ -123,7 +129,7 @@ test('pairs two devices: the same number on both, then B holds the vault (§8.4,
await expect(a.getByTestId('device-last-sync')).toContainText('Last synced with this device: ');
await expect(a.getByTestId('device-last-sync')).not.toContainText('never');
await expect(a.getByTestId('device-pending')).toHaveCount(0);
- await button(b, 'Settings').click();
+ await openSettingsPage(b, 'devices');
await expect(b.getByTestId('device')).toHaveCount(2);
await expect(b.getByTestId('device-last-sync')).not.toContainText('never');
@@ -134,12 +140,13 @@ test('pairs two devices: the same number on both, then B holds the vault (§8.4,
await expect(b.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 });
// Erasing B names its Device record, to retire from A, before and after (D205).
- await button(b, 'Settings').click();
+ await openSettingsPage(b, 'devices');
const own = b.getByTestId('device').filter({ hasText: '(this device)' });
const nameB = ((await own.locator('span.font-medium').textContent()) ?? '')
.replace(' (this device)', '')
.trim();
expect(nameB).not.toBe('');
+ await switchSettingsPage(b, 'erase');
const named = `Afterwards, retire this device's Device record, "${nameB}", from another device: until then they keep offering it as a sync peer.`;
await button(b, 'Erase this vault from this device…').click();
await expect(b.getByTestId('erase-warning').getByText(named, { exact: true })).toBeVisible();
@@ -162,6 +169,7 @@ test('a clock gone back far on a paired device offers replacing its vault by pai
const now = await a.evaluate(() => Date.now());
await a.clock.setSystemTime(now + 400 * 24 * 60 * 60 * 1000);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await button(a, 'New item').click();
await button(a, 'Login').click();
await a.getByTestId('item-title').fill('Ahead');
@@ -260,19 +268,23 @@ test('a hidden-app lock at 0 says it locks at once, in Settings, the export and
}) => {
const a = await device(browser);
await createVault(a, PASSWORD_A);
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'locking');
const immediate =
'At 0 the vault locks as soon as the app is hidden, including during a file picker or passkey prompt on some systems.';
const exportHidden =
'The vault locks as soon as the app is hidden: if the browser hides it while it saves the file, the vault locks, and the export must be repeated.';
await expect(a.getByText(immediate)).toHaveCount(0);
+ await switchSettingsPage(a, 'backup');
await expect(a.getByText(exportHidden)).toHaveCount(0);
+ await switchSettingsPage(a, 'locking');
await a.getByTestId('hiddenLockSeconds').fill('0');
await expect(a.getByText(immediate)).toBeVisible();
await button(a, 'Save').click();
await expect(a.getByText('Saved.')).toBeVisible();
+ await switchSettingsPage(a, 'backup');
await expect(a.getByText(exportHidden)).toBeVisible();
+ await switchSettingsPage(a, 'devices');
await button(a, 'Pair a device…').click();
await a.getByTestId('pairing-same-network').check();
await fillPassword(a.getByTestId('invite-password'), PASSWORD_A);
@@ -280,17 +292,20 @@ test('a hidden-app lock at 0 says it locks at once, in Settings, the export and
await expect(a.getByTestId('pairing-text')).not.toHaveValue('', { timeout: 30_000 });
await expect(
a.getByText(
- 'This vault locks as soon as BlindVault is hidden, which ends the pairing: copy and paste without leaving the app, or change the hidden-app lock below.',
+ 'This vault locks as soon as BlindVault is hidden, which ends the pairing: copy and paste without leaving the app, or change the hidden-app lock in Settings › Locking.',
),
).toBeVisible();
await a.getByTestId('pairing-cancel').click();
+ await switchSettingsPage(a, 'locking');
await a.getByTestId('hiddenLockSeconds').fill('30');
await expect(a.getByText(immediate)).toHaveCount(0);
await button(a, 'Save').click();
await expect(a.getByText('Saved.')).toBeVisible();
+ await switchSettingsPage(a, 'backup');
await expect(a.getByText(exportHidden)).toHaveCount(0);
- // Cancel left the password step open.
+ await switchSettingsPage(a, 'devices');
+ await button(a, 'Pair a device…').click();
await a.getByTestId('pairing-same-network').check();
await fillPassword(a.getByTestId('invite-password'), PASSWORD_A);
await button(a, 'Start pairing').click();
diff --git a/apps/web/e2e/passkey.spec.ts b/apps/web/e2e/passkey.spec.ts
index 9099636..47ee847 100644
--- a/apps/web/e2e/passkey.spec.ts
+++ b/apps/web/e2e/passkey.spec.ts
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
import { type CDPSession, type Page, expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9), on every browser.
test.setTimeout(120_000);
@@ -45,7 +45,7 @@ async function create(page: Page) {
}
async function setUp(page: Page, password = PASSWORD) {
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'passkey');
await expect(page.getByText(/needs only its screen lock, fingerprint or face/)).toBeVisible();
await page.getByTestId('passkey-password').fill(password);
await page.getByRole('button', { name: 'Set up passkey', exact: true }).click();
@@ -79,7 +79,7 @@ test('sets up a device-bound passkey and unlocks with it (§5.5, D75)', async ({
await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 });
// Removed: no longer offered.
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'passkey');
await page.getByRole('button', { name: 'Remove passkey', exact: true }).click();
await expect(
page.getByText('Passkey removed. Your passkey manager was asked to delete it.', {
@@ -197,6 +197,8 @@ test('with no way to ask the manager, removing, replacing or erasing says to del
// Replaced with its own backup while a passkey is set up.
await setUpAgain(page);
+ await page.getByRole('button', { name: 'Back', exact: true }).click();
+ await page.getByTestId('settings-backup').click();
await page.getByTestId('backup-password').fill(PASSWORD);
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 60_000 }),
@@ -227,8 +229,10 @@ test('with no way to ask the manager, removing, replacing or erasing says to del
).toBeVisible();
// Erased while a passkey is set up.
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'passkey');
await setUpAgain(page);
+ await page.getByRole('button', { name: 'Back', exact: true }).click();
+ await page.getByTestId('settings-erase').click();
await page
.getByRole('button', { name: 'Erase this vault from this device…', exact: true })
.click();
diff --git a/apps/web/e2e/phrase-regeneration.spec.ts b/apps/web/e2e/phrase-regeneration.spec.ts
index 4243713..df193df 100644
--- a/apps/web/e2e/phrase-regeneration.spec.ts
+++ b/apps/web/e2e/phrase-regeneration.spec.ts
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
import { type Page, expect, test } from '@playwright/test';
-import { answerConfirmation, createVault } from './create';
+import { answerConfirmation, createVault, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9); exports and the regeneration derive
// again.
@@ -66,10 +66,14 @@ test('regenerates the Recovery Phrase, keeping the vault, and asks for a new bac
await page.getByTestId('item-title').fill('ACME');
await button(page, 'Save').click();
await expect(page.getByRole('heading', { name: 'ACME', level: 1 })).toBeVisible();
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'recovery');
const oldId = await currentId(page);
expect(oldId).toMatch(/^[0-9a-f]{8}$/);
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-backup').click();
await exportBackup(page);
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-recovery').click();
await expect(page.getByTestId('recovery-no-backup')).toHaveCount(0);
await expect(
page.getByText(
@@ -118,7 +122,7 @@ test('regenerates the Recovery Phrase, keeping the vault, and asks for a new bac
).toBeVisible();
// The backup made before opens only with the previous phrase.
await expect(page.getByTestId('recovery-no-backup')).toHaveText(
- 'No backup made on this device opens with the current Recovery Phrase yet: make one in Backup, above.',
+ 'No backup made on this device opens with the current Recovery Phrase yet: make one in Settings › Backup.',
);
await check(page, words);
@@ -131,10 +135,13 @@ test('regenerates the Recovery Phrase, keeping the vault, and asks for a new bac
await expect(page.getByTestId('backup-password')).toBeFocused();
await exportBackup(page);
await expect(page.getByText(`It opens with the Recovery Phrase ${newId}`)).toBeVisible();
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-recovery').click();
await expect(page.getByTestId('recovery-no-backup')).toHaveCount(0);
// The vault contents did not change.
await button(page, 'Back').click();
+ await button(page, 'Back').click();
await expect(page.getByText('ACME').first()).toBeVisible();
const traces = [...old, ...words].flatMap((w, i, all) =>
i + 1 < all.length ? [`${w} ${all[i + 1] ?? ''}`] : [],
@@ -147,10 +154,12 @@ test('pauses the inactivity lock while the new phrase is shown, and resumes it o
}) => {
await page.goto('/');
await createVault(page, PASSWORD);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'locking');
await page.getByTestId('autoLockMinutes').fill('1');
await button(page, 'Save').first().click();
await expect(page.getByText('Saved.')).toBeVisible();
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-recovery').click();
const words = await newPhrase(page);
await page.clock.install();
// The inactivity timer restarted after the phrase screen opened runs on the fake clock.
diff --git a/apps/web/e2e/readmission.spec.ts b/apps/web/e2e/readmission.spec.ts
index 1f23d17..fe3856e 100644
--- a/apps/web/e2e/readmission.spec.ts
+++ b/apps/web/e2e/readmission.spec.ts
@@ -4,7 +4,7 @@ import { RecordType } from '@blindvault/crypto';
import { type Browser, type Page, expect, test } from '@playwright/test';
import { corruptRecords } from './corrupt';
-import { createVault, openVault, startScreen } from './create';
+import { createVault, openSettingsPage, openVault, startScreen } from './create';
import { PAIRING_URL } from '../playwright.config';
import { fillPassword } from './password';
@@ -56,7 +56,7 @@ async function addItem(page: Page, title: string): Promise {
/** Settings › Devices › Re-admission, same network only; `first` shows its text first. */
async function startReadmission(page: Page, first: boolean): Promise {
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'devices');
await button(page, 'Re-admit with another device of this vault…').click();
await page.getByTestId('readmit-device').getByTestId('pairing-same-network').check();
await page.getByTestId(first ? 'readmission-first' : 'readmission-paste').click();
@@ -89,7 +89,7 @@ async function restored(browser: Browser, logged: string[]) {
const a = await device(browser, logged);
const words = await createVault(a, PASSWORD_A);
await addItem(a, 'Harbor Bank');
- await a.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(a, 'backup');
await a.getByTestId('backup-password').fill(PASSWORD_A);
const [download] = await Promise.all([
a.waitForEvent('download', { timeout: 60_000 }),
@@ -97,6 +97,7 @@ async function restored(browser: Browser, logged: string[]) {
]);
const backup = await readFile(await download.path());
await button(a, 'Back').click();
+ await button(a, 'Back').click();
// B: a fresh restore of A's backup, unregistered, with an item of its own.
const b = await device(browser, logged);
@@ -143,6 +144,7 @@ test('re-admits a restored device: both know each other and hold every item (§9
await expect(page.getByTestId('device-last-sync')).not.toContainText('never');
await expect(page.getByTestId('devices-readmit')).toHaveCount(0);
await button(page, 'Back').click();
+ await button(page, 'Back').click();
await expect(page.getByRole('button', { name: /Harbor Bank/ })).toBeVisible();
await expect(page.getByRole('button', { name: /Copper Mail/ })).toBeVisible();
}
@@ -158,7 +160,7 @@ test('a pairing answered by a device of the vault is refused, and a restored dev
const { a, b } = await restored(browser, logged);
// A invites; B answers from its re-admission, as a device that already holds the vault.
- await a.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(a, 'devices');
await button(a, 'Pair a device…').click();
await a.getByTestId('pairing-same-network').check();
await fillPassword(a.getByTestId('invite-password'), PASSWORD_A);
@@ -248,6 +250,7 @@ test('numbers said to differ stop the re-admission on both, and nothing is merge
await expect(a.getByTestId('device')).toHaveCount(1);
await expect(b.getByTestId('devices-readmit')).toBeVisible();
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await expect(a.getByRole('button', { name: /Copper Mail/ })).toHaveCount(0);
});
@@ -275,5 +278,6 @@ test('a stored record of A that fails verification stops the re-admission on bot
'Pairing not completed: this device never got the confirmation that the other one kept the vault.',
);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await expect(a.getByRole('button', { name: /Copper Mail/ })).toHaveCount(0);
});
diff --git a/apps/web/e2e/recovery-phrase.spec.ts b/apps/web/e2e/recovery-phrase.spec.ts
index ffde93b..ad002fb 100644
--- a/apps/web/e2e/recovery-phrase.spec.ts
+++ b/apps/web/e2e/recovery-phrase.spec.ts
@@ -1,7 +1,7 @@
import { type Page, expect, test } from '@playwright/test';
import { formAlert } from './alerts';
-import { answerConfirmation, createVault, readPhrase } from './create';
+import { answerConfirmation, createVault, readPhrase, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9), on every browser.
test.setTimeout(120_000);
@@ -131,7 +131,7 @@ test('checks the Recovery Phrase while unlocked, keeping the typed words nowhere
page.on('console', (message) => logged.push(message.text()));
await page.goto('/');
const words = await createVault(page, PASSWORD);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'recovery');
await expect(page.getByText(/The current Recovery Phrase was created on/)).toBeVisible();
const field = page.getByTestId('check-phrase');
const check = async (typed: string) => {
diff --git a/apps/web/e2e/refounding.spec.ts b/apps/web/e2e/refounding.spec.ts
index 143e5be..11eadec 100644
--- a/apps/web/e2e/refounding.spec.ts
+++ b/apps/web/e2e/refounding.spec.ts
@@ -1,6 +1,6 @@
import { type Page, expect, test } from '@playwright/test';
-import { answerConfirmation, createVault } from './create';
+import { answerConfirmation, createVault, openSettingsPage } from './create';
// Creation, the proof of the user and the new password each derive Argon2id at about 1 s (D9).
test.setTimeout(240_000);
@@ -35,8 +35,10 @@ test('re-founds the vault: warnings, proof, new phrase and password, and only th
await newLogin(page, 'Discarded');
await button(page, 'More actions').click();
await button(page, 'Move to trash').click();
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'recovery');
const before = (await page.getByTestId('recovery-current').textContent()) ?? '';
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-refound').click();
// The warnings, and the confirmation they need (D46, D72).
const refound = page.getByTestId('refound');
@@ -102,17 +104,24 @@ test('re-founds the vault: warnings, proof, new phrase and password, and only th
timeout: 60_000,
},
);
+ // The new vault also shows the backup reminder, with its own Dismiss (D188).
+ await expect(page.getByTestId('backup-reminder')).toBeVisible();
+ await page.getByTestId('refounded').getByRole('button', { name: 'Dismiss' }).click();
+ await expect(page.getByTestId('refounded')).toHaveCount(0);
+ expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(1);
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-devices').click();
await expect(page.getByTestId('devices').getByTestId('device')).toHaveCount(1);
await expect(page.getByTestId('devices').getByTestId('device')).toContainText('(this device)');
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-recovery').click();
await expect(page.getByTestId('recovery-current')).not.toHaveText(before);
+ await button(page, 'Back').click();
+ await page.getByTestId('settings-backup').click();
await expect(page.getByTestId('last-backup')).toHaveText(
'No backup has been made on this device yet.',
);
- // The new vault also shows the backup reminder, with its own Dismiss (D188).
- await expect(page.getByTestId('backup-reminder')).toBeVisible();
- await page.getByTestId('refounded').getByRole('button', { name: 'Dismiss' }).click();
- await expect(page.getByTestId('refounded')).toHaveCount(0);
- expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(1);
+ await button(page, 'Back').click();
await button(page, 'Back').click();
await expect(button(page, 'ACME')).toBeVisible();
await expect(button(page, 'Discarded')).toHaveCount(0);
diff --git a/apps/web/e2e/replace-vault.spec.ts b/apps/web/e2e/replace-vault.spec.ts
index 504596b..0f261fd 100644
--- a/apps/web/e2e/replace-vault.spec.ts
+++ b/apps/web/e2e/replace-vault.spec.ts
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
import { type Browser, type BrowserContext, type Page, expect, test } from '@playwright/test';
-import { createVault, openVault } from './create';
+import { createVault, openVault, openSettingsPage } from './create';
// Creation, export and replacement each derive Argon2id at about 1 s (D9).
test.setTimeout(300_000);
@@ -43,7 +43,7 @@ async function newItem(page: Page, title: string) {
/** Exports from Settings with the local password and returns the file the browser received. */
async function exportBackup(page: Page): Promise {
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'backup');
await page.getByTestId('backup-password').fill(PASSWORD);
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 60_000 }),
@@ -51,6 +51,7 @@ async function exportBackup(page: Page): Promise {
]);
const bytes = await readFile(await download.path());
await button(page, 'Back').click();
+ await button(page, 'Back').click();
return bytes;
}
diff --git a/apps/web/e2e/restore.spec.ts b/apps/web/e2e/restore.spec.ts
index ba6ce5e..866e9da 100644
--- a/apps/web/e2e/restore.spec.ts
+++ b/apps/web/e2e/restore.spec.ts
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
import { type Page, expect, test } from '@playwright/test';
-import { createVault, openVault, startScreen } from './create';
+import { createVault, openSettingsPage, openVault, startScreen } from './create';
import { fillPassword } from './password';
// Creation, export and restore each calibrate or derive Argon2id at about 1 s (D9).
@@ -35,7 +35,7 @@ async function vaultWithItem(page: Page): Promise {
await page.getByTestId('input-Password').fill(SECRET);
await button(page, 'Save').click();
await expect(page.getByRole('heading', { name: 'ACME', level: 1 })).toBeVisible();
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'backup');
return words;
}
@@ -186,6 +186,7 @@ test('restores a backup by its export password, then recommends Check Recovery P
await button(page, 'Check Recovery Phrase').click();
await expect(page.getByTestId('check-phrase')).toBeFocused();
await button(page, 'Back').click();
+ await button(page, 'Back').click();
await expect(ready).toBeVisible();
// Closing it counts as both one-time notices: no banner repeats them.
await openVault(page);
diff --git a/apps/web/e2e/restored-device.spec.ts b/apps/web/e2e/restored-device.spec.ts
index af80d6a..d5da717 100644
--- a/apps/web/e2e/restored-device.spec.ts
+++ b/apps/web/e2e/restored-device.spec.ts
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
import { type Page, expect, test } from '@playwright/test';
-import { createVault, startScreen } from './create';
+import { createVault, openSettingsPage, startScreen, switchSettingsPage } from './create';
// Creation, export and restore each calibrate or derive Argon2id at about 1 s (D9).
test.setTimeout(240_000);
@@ -48,7 +48,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires
const logged = watchLogs(page);
await page.goto('/');
const words = await createVault(page, PASSWORD);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'devices');
// The creator: its own record only, so it is the only device (D119), with nothing to retire.
const devices = page.getByTestId('devices');
@@ -63,6 +63,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires
.trim();
expect(name).not.toBe('');
+ await switchSettingsPage(page, 'backup');
await page.getByTestId('backup-password').fill(PASSWORD);
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 60_000 }),
@@ -98,6 +99,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires
await expect(devices.getByTestId('device')).toContainText(name);
await expect(devices.getByTestId('device')).not.toContainText('(this device)');
// Erasing it now leaves no Device record of its own to retire (D205).
+ await switchSettingsPage(page, 'erase');
await button(page, 'Erase this vault from this device…').click();
const erase = page.getByTestId('erase-warning');
await expect(
@@ -105,6 +107,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires
).toBeVisible();
await erase.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(erase).toHaveCount(0);
+ await switchSettingsPage(page, 'devices');
// Retiring asks for a confirmation that says what it is not; cancel writes nothing.
await devices.getByTestId('device-retire').click();
@@ -134,7 +137,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires
await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 });
await expect(page.getByTestId('ready')).toHaveCount(0);
await expect(page.getByTestId('readmission')).toHaveCount(0);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'devices');
await expect(devices.getByTestId('devices-retired-count')).toBeVisible();
const secrets = [PASSWORD, NEW_PASSWORD, ...words];
diff --git a/apps/web/e2e/storage-state.spec.ts b/apps/web/e2e/storage-state.spec.ts
index f7465eb..1ad228a 100644
--- a/apps/web/e2e/storage-state.spec.ts
+++ b/apps/web/e2e/storage-state.spec.ts
@@ -3,7 +3,7 @@
import { type Page, expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Creating a vault calibrates Argon2id at about 1 s (D9).
test.setTimeout(120_000);
@@ -60,7 +60,7 @@ async function returnToVisible(page: Page): Promise {
}
async function openSettings(page: Page): Promise {
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'storage');
await expect(page.getByTestId('device-storage')).toBeVisible();
}
diff --git a/apps/web/e2e/sync.spec.ts b/apps/web/e2e/sync.spec.ts
index b48f4c7..4f3cdb3 100644
--- a/apps/web/e2e/sync.spec.ts
+++ b/apps/web/e2e/sync.spec.ts
@@ -2,7 +2,13 @@ import { RecordType } from '@blindvault/crypto';
import { type Browser, type Page, expect, test } from '@playwright/test';
import { corruptRecords } from './corrupt';
-import { answerConfirmation, createVault, startScreen } from './create';
+import {
+ answerConfirmation,
+ createVault,
+ openSettingsPage,
+ startScreen,
+ switchSettingsPage,
+} from './create';
import { PAIRING_URL } from '../playwright.config';
import { fillPassword } from './password';
@@ -56,7 +62,7 @@ async function paired(browser: Browser) {
const a = await device(browser);
const b = await device(browser);
await createVault(a, PASSWORD_A);
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await button(a, 'Pair a device…').click();
await a.getByTestId('pairing-same-network').check();
await fillPassword(a.getByTestId('invite-password'), PASSWORD_A);
@@ -82,7 +88,7 @@ async function paired(browser: Browser) {
await expect(b.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 60_000 });
await expect(a.getByTestId('pairing-done')).toBeVisible({ timeout: 60_000 });
await button(a, 'Close').click();
- await button(b, 'Settings').click();
+ await openSettingsPage(b, 'devices');
return { a, b };
}
@@ -91,9 +97,11 @@ test('two paired devices connect from their sync codes, each with the other pick
}) => {
const { a, b } = await paired(browser);
// B locks at once when hidden; A keeps the default delay (D93, D105).
+ await switchSettingsPage(b, 'locking');
await b.getByTestId('hiddenLockSeconds').fill('0');
await button(b, 'Save').click();
await expect(b.getByText('Saved.')).toBeVisible();
+ await switchSettingsPage(b, 'devices');
// Each picks the other device; A shows its code first, B reads it.
await a.getByTestId('device-sync').click();
@@ -117,7 +125,7 @@ test('two paired devices connect from their sync codes, each with the other pick
await syncB.getByTestId('sync-read').click();
await expect(
syncB.getByText(
- 'This vault locks as soon as BlindVault is hidden, which ends the sync: copy and paste without leaving the app, or change the hidden-app lock below.',
+ 'This vault locks as soon as BlindVault is hidden, which ends the sync: copy and paste without leaving the app, or change the hidden-app lock in Settings › Locking.',
),
).toBeVisible();
await syncB.getByTestId('pairing-paste').fill(offerText);
@@ -216,6 +224,7 @@ test('A retires B, then tells it: B merges its retired record and shows the D41
await expect(b.getByTestId('readmit-device')).toContainText(
'This device was retired: it cannot be re-admitted. Replace it by pairing to sync.',
);
+ await switchSettingsPage(b, 'refound');
// Re-founding on B says it cannot sync first, in place of the advice to sync (D47).
await b.getByTestId('refound-start').click();
const warnings = b.getByTestId('refound-warnings');
@@ -293,7 +302,10 @@ async function closed(a: Page, b: Page) {
await b.getByTestId('sync-end').click();
await expect(a.getByTestId('sync-failed')).toHaveAttribute('data-reason', 'ended');
await a.getByTestId('sync-again').click();
- for (const page of [a, b]) await button(page, 'Back').click();
+ for (const page of [a, b]) {
+ await button(page, 'Back').click();
+ await button(page, 'Back').click();
+ }
}
async function newLogin(page: Page, title: string) {
@@ -315,13 +327,15 @@ test('an item created on A reaches B through a sync, and B shows the merge (D222
}) => {
const { a, b } = await paired(browser);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await newLogin(a, 'ACME');
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await synced(a, b);
await expect(b.getByTestId('merge-result')).toContainText('Merged: 1 added, 0 replaced.');
await b.getByTestId('sync-end').click();
await button(b, 'Back').click();
+ await button(b, 'Back').click();
// B still shows "Vault ready" from its pairing (D198).
await button(b, 'Open the vault').click();
await expect(button(b, 'ACME')).toBeVisible();
@@ -332,8 +346,9 @@ test('a record of A that fails verification stops the sync before it is sent: A
}) => {
const { a, b } = await paired(browser);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await newLogin(a, 'ACME');
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
// B lacks the item, so A reads it again from storage and verifies it before sending (D181).
expect(await corruptRecords(a, RecordType.item)).toBeGreaterThan(0);
@@ -352,6 +367,7 @@ test('a record of A that fails verification stops the sync before it is sent: A
await b.getByTestId('sync-again').click();
await button(b, 'Back').click();
+ await button(b, 'Back').click();
await button(b, 'Open the vault').click();
await expect(button(b, 'ACME')).toHaveCount(0);
});
@@ -404,8 +420,9 @@ test('concurrent edits converge in one sync: the newer wins on both, the other i
}) => {
const { a, b } = await paired(browser);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await newLogin(a, 'ACME');
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await synced(a, b);
await closed(a, b);
await button(b, 'Open the vault').click();
@@ -413,7 +430,7 @@ test('concurrent edits converge in one sync: the newer wins on both, the other i
// A edits first, then B, so B's is the newer write.
await rename(a, 'ACME', 'ACME on A');
await rename(b, 'ACME', 'ACME on B');
- for (const page of [a, b]) await button(page, 'Settings').click();
+ for (const page of [a, b]) await openSettingsPage(page, 'devices');
await synced(a, b);
await expect(a.getByTestId('merge-result')).toContainText('Merged: 0 added, 1 replaced.');
await expect(b.getByTestId('merge-result')).toContainText('Merged: 0 added, 0 replaced.');
@@ -437,8 +454,9 @@ test('the history cut drops an edit of A in one sync: only A tells its user, and
}) => {
const { a, b } = await paired(browser);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await newLogin(a, 'ACME');
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await synced(a, b);
await closed(a, b);
await button(b, 'Open the vault').click();
@@ -450,7 +468,7 @@ test('the history cut drops an edit of A in one sync: only A tells its user, and
await rename(b, title, `B ${String(i)}`);
title = `B ${String(i)}`;
}
- for (const page of [a, b]) await button(page, 'Settings').click();
+ for (const page of [a, b]) await openSettingsPage(page, 'devices');
await synced(a, b);
const notice = 'An older edit made on this device no longer fits the history of: ACME on A.';
await expect(a.getByText(notice)).toBeVisible();
@@ -472,8 +490,9 @@ test('an edit older than a trash never brings the item back: in the trash on bot
}) => {
const { a, b } = await paired(browser);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await newLogin(a, 'ACME');
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await synced(a, b);
await closed(a, b);
await button(b, 'Open the vault').click();
@@ -483,7 +502,7 @@ test('an edit older than a trash never brings the item back: in the trash on bot
await button(b, 'ACME').click();
await button(b, 'More actions').click();
await button(b, 'Move to trash').click();
- for (const page of [a, b]) await button(page, 'Settings').click();
+ for (const page of [a, b]) await openSettingsPage(page, 'devices');
await synced(a, b);
await closed(a, b);
@@ -508,8 +527,9 @@ test('an item trashed on A is in the trash of both with the same purge date, off
}) => {
const { a, b } = await paired(browser);
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await newLogin(a, 'ACME');
- await button(a, 'Settings').click();
+ await openSettingsPage(a, 'devices');
await synced(a, b);
await closed(a, b);
await button(b, 'Open the vault').click();
@@ -517,8 +537,8 @@ test('an item trashed on A is in the trash of both with the same purge date, off
await button(a, 'ACME').click();
await button(a, 'More actions').click();
await button(a, 'Move to trash').click();
- await button(a, 'Settings').click();
- await button(b, 'Settings').click();
+ await openSettingsPage(a, 'devices');
+ await openSettingsPage(b, 'devices');
await synced(a, b);
await closed(a, b);
@@ -564,16 +584,22 @@ test('a phrase regenerated on A reaches B in one sync, which tells its user and
browser,
}) => {
const { a, b } = await paired(browser);
+ await switchSettingsPage(b, 'backup');
await b.getByTestId('backup-password').fill(PASSWORD_B);
await Promise.all([b.waitForEvent('download', { timeout: 60_000 }), button(b, 'Export').click()]);
+ await switchSettingsPage(b, 'recovery');
await expect(b.getByTestId('recovery-no-backup')).toHaveCount(0);
+ await switchSettingsPage(a, 'recovery');
const id = await regenerate(a, PASSWORD_A);
+ await switchSettingsPage(a, 'devices');
+ await switchSettingsPage(b, 'devices');
await synced(a, b);
await expect(b.getByTestId('merge-result')).toContainText(
`The Recovery Phrase was regenerated on another device: the new one, ${id},`,
);
await expect(a.getByTestId('merge-result')).not.toContainText('Recovery Phrase');
+ await switchSettingsPage(b, 'recovery');
expect(await currentId(b)).toBe(id);
// B's backup was made for the previous phrase.
await expect(b.getByTestId('recovery-no-backup')).toBeVisible();
@@ -583,20 +609,27 @@ test('of two regenerations, the later is kept on both and A, whose phrase lost,
browser,
}) => {
const { a, b } = await paired(browser);
+ await switchSettingsPage(a, 'recovery');
+ await switchSettingsPage(b, 'recovery');
const lost = await regenerate(a, PASSWORD_A);
const kept = await regenerate(b, PASSWORD_B);
expect(kept).not.toBe(lost);
+ await switchSettingsPage(a, 'devices');
+ await switchSettingsPage(b, 'devices');
await synced(a, b);
+ await expect(b.getByTestId('merge-result')).not.toContainText('Recovery Phrase');
const warning = a.getByTestId('phrase-superseded');
await expect(warning).toContainText(
`The Recovery Phrase you wrote down for this device, ${lost},`,
);
await expect(warning).toContainText('keep it until you have a new backup');
- await expect(b.getByTestId('merge-result')).not.toContainText('Recovery Phrase');
- for (const page of [a, b]) expect(await currentId(page)).toBe(kept);
await warning.getByRole('button', { name: 'Make a backup now', exact: true }).click();
await expect(a.getByTestId('backup-password')).toBeFocused();
+ for (const page of [a, b]) {
+ await switchSettingsPage(page, 'recovery');
+ expect(await currentId(page)).toBe(kept);
+ }
});
test('a live sync outlives Settings: an item made in the vault reaches the peer at once, until End (D225)', async ({
@@ -614,6 +647,8 @@ test('a live sync outlives Settings: an item made in the vault reaches the peer
await expect(a.getByTestId('sync')).toHaveCount(0);
await expect(a.getByTestId('live-sync')).toBeVisible();
await button(a, 'Back').click();
+ await button(a, 'Back').click();
+ await button(b, 'Back').click();
await button(b, 'Back').click();
await button(b, 'Open the vault').click();
await expect(a.getByTestId('live-sync')).toBeVisible();
@@ -637,6 +672,7 @@ test('a lock ends the live sync on both (D93, D225)', async ({ browser }) => {
await synced(a, b);
await expect(a.getByTestId('live-sync')).toBeVisible({ timeout: 30_000 });
await button(a, 'Back').click();
+ await button(a, 'Back').click();
await expect(a.getByTestId('live-sync')).toBeVisible();
await button(b, 'Lock').click();
await expect(a.getByTestId('live-sync')).toHaveCount(0, { timeout: 30_000 });
@@ -648,8 +684,10 @@ test('Settings shows the sync on the row of its peer while it runs, and the last
const { a, b } = await paired(browser);
// The pairing is the first completed sync (D34).
for (const page of [a, b]) {
- await expect(page.getByTestId('device-last-sync-any')).toHaveText(/^Last sync: .+, with .+\.$/);
await expect(page.getByTestId('device-sync-state')).toHaveCount(0);
+ await switchSettingsPage(page, 'storage');
+ await expect(page.getByTestId('device-last-sync-any')).toHaveText(/^Last sync: .+, with .+\.$/);
+ await switchSettingsPage(page, 'devices');
}
const before = await a.getByTestId('device-last-sync').innerText();
await synced(a, b);
@@ -661,9 +699,11 @@ test('Settings shows the sync on the row of its peer while it runs, and the last
await expect(page.getByTestId('device-last-sync')).toHaveText(
/^Last synced with this device: .+\d{1,2}:\d{2}/,
);
+ await switchSettingsPage(page, 'storage');
await expect(page.getByTestId('device-last-sync-any')).toHaveText(
/^Last sync: .+\d{1,2}:\d{2}.*, with .+\.$/,
);
+ await switchSettingsPage(page, 'devices');
}
expect(before).toMatch(/\d{1,2}:\d{2}/);
await a.getByTestId('sync-end').click();
diff --git a/apps/web/e2e/totp.spec.ts b/apps/web/e2e/totp.spec.ts
index bbb1fe4..548a78a 100644
--- a/apps/web/e2e/totp.spec.ts
+++ b/apps/web/e2e/totp.spec.ts
@@ -2,7 +2,7 @@ import { createHmac } from 'node:crypto';
import { type Page, expect, test } from '@playwright/test';
-import { createVault } from './create';
+import { createVault, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9), on every browser.
test.setTimeout(120_000);
@@ -105,11 +105,12 @@ test('copies a TOTP code and a secret, then clears the clipboard after the timeo
page,
}) => {
await create(page);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'locking');
await page.getByTestId('clipboardClearSeconds').fill('10');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.', { exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Back', exact: true }).click();
+ await page.getByRole('button', { name: 'Back', exact: true }).click();
await page.getByRole('button', { name: 'New item', exact: true }).click();
await page.getByRole('button', { name: 'Login', exact: true }).click();
diff --git a/apps/web/e2e/vault.spec.ts b/apps/web/e2e/vault.spec.ts
index 55bf71a..f037478 100644
--- a/apps/web/e2e/vault.spec.ts
+++ b/apps/web/e2e/vault.spec.ts
@@ -1,7 +1,7 @@
import { type Page, expect, test } from '@playwright/test';
import { formAlert } from './alerts';
-import { confirmPhrase, createVault } from './create';
+import { confirmPhrase, createVault, openSettingsPage } from './create';
// Calibrating Argon2id targets about 1 s per derivation (D9), on every browser.
test.setTimeout(90_000);
@@ -125,7 +125,7 @@ test('keeps the vault after a reload, locked', async ({ page }) => {
test('changes the password only with the current one (D168)', async ({ page }) => {
await create(page);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'password');
const change = page.getByRole('button', { name: 'Change password' });
await page.getByTestId('current-password').fill('not the password at all');
await page.getByTestId('new-password').fill(NEXT);
@@ -144,7 +144,7 @@ test('changes the password only with the current one (D168)', async ({ page }) =
test('refuses a lock preference outside its range (D151)', async ({ page }) => {
await create(page);
- await page.getByRole('button', { name: 'Settings' }).first().click();
+ await openSettingsPage(page, 'locking');
await page.getByTestId('autoLockMinutes').fill('0');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('A value is outside its range.')).toBeVisible();
diff --git a/apps/web/src/app/nav.test.ts b/apps/web/src/app/nav.test.ts
index f85c382..2f7d7d5 100644
--- a/apps/web/src/app/nav.test.ts
+++ b/apps/web/src/app/nav.test.ts
@@ -176,3 +176,28 @@ describe('leaving an editor with unsaved changes (D234)', () => {
expect(nav.current().name).toBe('vault');
});
});
+
+describe('Settings pages (D252)', () => {
+ it('opens a focus on its page over the index, and back leads to the index', () => {
+ const nav = new Navigation();
+ nav.select({ name: 'item', id: ID });
+ nav.openSettings('pair');
+ expect(nav.current()).toEqual({ name: 'settings', page: 'devices', focus: 'pair' });
+ nav.back();
+ expect(nav.current()).toEqual({ name: 'settings' });
+ nav.back();
+ expect(nav.current().name).toBe('item');
+ });
+
+ it('replaces the Settings screens already open', () => {
+ const nav = new Navigation();
+ nav.open({ name: 'settings' });
+ nav.open({ name: 'settings', page: 'appearance' });
+ nav.openSettings('backup');
+ expect(nav.current()).toEqual({ name: 'settings', page: 'backup', focus: 'backup' });
+ nav.back();
+ nav.back();
+ expect(nav.current().name).toBe('vault');
+ expect(nav.deeper()).toBe(false);
+ });
+});
diff --git a/apps/web/src/app/nav.ts b/apps/web/src/app/nav.ts
index aec7b51..e062880 100644
--- a/apps/web/src/app/nav.ts
+++ b/apps/web/src/app/nav.ts
@@ -15,9 +15,42 @@ export type ListScreen =
export type SettingsFocus =
'backup' | 'passkey' | 'recovery' | 'devices' | 'pair' | 'readmit' | 'refound' | 'import';
+/** A page of Settings, opened from its index (D252). */
+export type SettingsPage =
+ | 'appearance'
+ | 'language'
+ | 'locking'
+ | 'password'
+ | 'passkey'
+ | 'recovery'
+ | 'devices'
+ | 'backup'
+ | 'merge'
+ | 'import'
+ | 'storage'
+ | 'refound'
+ | 'erase'
+ | 'about';
+
+/** The page each focus is on. */
+export const FOCUS_PAGE: Readonly> = {
+ backup: 'backup',
+ passkey: 'passkey',
+ recovery: 'recovery',
+ devices: 'devices',
+ pair: 'devices',
+ readmit: 'devices',
+ refound: 'refound',
+ import: 'import',
+};
+
export type Screen =
| ListScreen
- | { readonly name: 'settings'; readonly focus?: SettingsFocus }
+ | {
+ readonly name: 'settings';
+ readonly page?: SettingsPage;
+ readonly focus?: SettingsFocus;
+ }
| { readonly name: 'item'; readonly id: Uint8Array }
| { readonly name: 'edit'; readonly id: Uint8Array }
| { readonly name: 'history'; readonly id: Uint8Array }
@@ -109,6 +142,20 @@ export class Navigation {
this.#set([...this.#stack, screen]);
}
+ /**
+ * Settings over the screens below it: its index, then the page `focus` is on, so back from the
+ * page leads to the index (D252). Any Settings screen already open is replaced.
+ */
+ openSettings(focus?: SettingsFocus): void {
+ const below = this.#stack.filter((s) => s.name !== 'settings');
+ const index: Screen = { name: 'settings' };
+ this.#set(
+ focus
+ ? [...below, index, { name: 'settings', page: FOCUS_PAGE[focus], focus }]
+ : [...below, index],
+ );
+ }
+
/** The detail of the current list, replacing any other detail. */
select(screen: DetailScreen): void {
this.#set([...this.#listStack(), screen]);
diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts
index f481658..03038c0 100644
--- a/apps/web/src/i18n/messages.ts
+++ b/apps/web/src/i18n/messages.ts
@@ -144,7 +144,7 @@ const en = {
leaving:
'Leaving BlindVault to copy or paste counts as hiding it: if it stays hidden for {seconds} seconds, the pairing ends.',
leavingImmediate:
- 'This vault locks as soon as BlindVault is hidden, which ends the pairing: copy and paste without leaving the app, or change the hidden-app lock below.',
+ 'This vault locks as soon as BlindVault is hidden, which ends the pairing: copy and paste without leaving the app, or change the hidden-app lock in Settings › Locking.',
answerPaste: 'The other device’s answer',
connect: 'Connect',
connecting: 'Connecting…',
@@ -333,7 +333,7 @@ const en = {
leaving:
'Leaving BlindVault to copy or paste counts as hiding it: if it stays hidden for {seconds} seconds, the sync ends.',
leavingImmediate:
- 'This vault locks as soon as BlindVault is hidden, which ends the sync: copy and paste without leaving the app, or change the hidden-app lock below.',
+ 'This vault locks as soon as BlindVault is hidden, which ends the sync: copy and paste without leaving the app, or change the hidden-app lock in Settings › Locking.',
paste: {
malformed: 'This is not a whole BlindVault sync text. Copy it again, whole.',
version: 'This text comes from another version of BlindVault: update both devices.',
@@ -504,7 +504,7 @@ const en = {
"This backup holds records dated ahead of this device's clock. It can be merged from {date}.",
locked: 'The merge stopped before anything was saved. Open the file again.',
phraseChanged:
- 'The Recovery Phrase was regenerated on another device: the new one, {id}, was created on {date}. New backups open only with it: check yours with Check Recovery Phrase below.',
+ 'The Recovery Phrase was regenerated on another device: the new one, {id}, was created on {date}. New backups open only with it: check yours in Settings › Recovery Phrase.',
phraseSuperseded:
'The Recovery Phrase you wrote down for this device, {id}, created on {date}, was replaced by one regenerated on another device. Backups made with it open only with it, so keep it until you have a new backup. New backups open only with the new phrase.',
backupNow: 'Make a backup now',
@@ -772,6 +772,33 @@ const en = {
},
settings: {
title: 'Settings',
+ groups: {
+ preferences: 'Preferences',
+ security: 'Security',
+ devices: 'Devices',
+ data: 'Data',
+ danger: 'Danger zone',
+ },
+ pages: {
+ appearance: 'Appearance',
+ language: 'Language',
+ locking: 'Locking',
+ password: 'Local password',
+ passkey: 'Passkey unlock',
+ recovery: 'Recovery Phrase',
+ devices: 'Devices',
+ backup: 'Backup',
+ merge: 'Merge a backup',
+ import: 'Import',
+ storage: 'This device',
+ refound: 'Re-found the vault',
+ erase: 'Erase from this device',
+ about: 'About BlindVault',
+ },
+ hintMinutes: '{n} min',
+ hintOn: 'On',
+ hintOff: 'Off',
+ hintNever: 'Never',
appearance: 'Appearance',
theme: 'Theme',
themes: { system: 'System', light: 'Light', dark: 'Dark' },
@@ -827,7 +854,7 @@ const en = {
recovery: 'Recovery Phrase',
recoveryCreated: 'The current Recovery Phrase was created on {date} ({id}).',
recoveryNoBackup:
- 'No backup made on this device opens with the current Recovery Phrase yet: make one in Backup, above.',
+ 'No backup made on this device opens with the current Recovery Phrase yet: make one in Settings › Backup.',
regenTitle: 'Regenerate the Recovery Phrase',
regenIntro:
'If you lost the Recovery Phrase, regenerate it while the vault is unlocked: a new phrase replaces it, and nothing in the vault changes.',
@@ -1193,7 +1220,7 @@ const ptBR: Messages = {
leaving:
'Sair do BlindVault para copiar ou colar conta como ocultá-lo: se ele ficar oculto por {seconds} segundos, o pareamento termina.',
leavingImmediate:
- 'Este cofre bloqueia assim que o BlindVault fica oculto, o que termina o pareamento: copie e cole sem sair do app, ou mude o bloqueio do app oculto abaixo.',
+ 'Este cofre bloqueia assim que o BlindVault fica oculto, o que termina o pareamento: copie e cole sem sair do app, ou mude o bloqueio do app oculto em Configurações › Bloqueio.',
answerPaste: 'Resposta do outro dispositivo',
connect: 'Conectar',
connecting: 'Conectando…',
@@ -1388,7 +1415,7 @@ const ptBR: Messages = {
leaving:
'Sair do BlindVault para copiar ou colar conta como ocultá-lo: se ele ficar oculto por {seconds} segundos, a sincronização termina.',
leavingImmediate:
- 'Este cofre bloqueia assim que o BlindVault fica oculto, o que encerra a sincronização: copie e cole sem sair do app, ou mude o bloqueio do app oculto abaixo.',
+ 'Este cofre bloqueia assim que o BlindVault fica oculto, o que encerra a sincronização: copie e cole sem sair do app, ou mude o bloqueio do app oculto em Configurações › Bloqueio.',
paste: {
malformed:
'Este não é um texto de sincronização do BlindVault inteiro. Copie-o de novo, inteiro.',
@@ -1562,7 +1589,7 @@ const ptBR: Messages = {
'Este backup contém registros com data à frente do relógio deste dispositivo. Ele pode ser mesclado a partir de {date}.',
locked: 'A mesclagem parou antes de salvar qualquer coisa. Abra o arquivo de novo.',
phraseChanged:
- 'A Frase de Recuperação foi regenerada em outro dispositivo: a nova, {id}, foi criada em {date}. Os novos backups só abrem com ela: confira a sua em Conferir a Frase de Recuperação, abaixo.',
+ 'A Frase de Recuperação foi regenerada em outro dispositivo: a nova, {id}, foi criada em {date}. Os novos backups só abrem com ela: confira a sua em Configurações › Frase de Recuperação.',
phraseSuperseded:
'A Frase de Recuperação que você anotou para este dispositivo, {id}, criada em {date}, foi substituída por uma regenerada em outro dispositivo. Os backups feitos com ela só abrem com ela, então guarde-a até ter um novo backup. Os novos backups só abrem com a frase nova.',
backupNow: 'Fazer um backup agora',
@@ -1828,6 +1855,33 @@ const ptBR: Messages = {
},
settings: {
title: 'Configurações',
+ groups: {
+ preferences: 'Preferências',
+ security: 'Segurança',
+ devices: 'Dispositivos',
+ data: 'Dados',
+ danger: 'Zona de perigo',
+ },
+ pages: {
+ appearance: 'Aparência',
+ language: 'Idioma',
+ locking: 'Bloqueio',
+ password: 'Senha local',
+ passkey: 'Desbloqueio com passkey',
+ recovery: 'Frase de Recuperação',
+ devices: 'Dispositivos',
+ backup: 'Backup',
+ merge: 'Mesclar um backup',
+ import: 'Importar',
+ storage: 'Este dispositivo',
+ refound: 'Refundar o cofre',
+ erase: 'Apagar deste dispositivo',
+ about: 'Sobre o BlindVault',
+ },
+ hintMinutes: '{n} min',
+ hintOn: 'Ativado',
+ hintOff: 'Desativado',
+ hintNever: 'Nunca',
appearance: 'Aparência',
theme: 'Tema',
themes: { system: 'Sistema', light: 'Claro', dark: 'Escuro' },
@@ -1883,7 +1937,7 @@ const ptBR: Messages = {
recovery: 'Frase de Recuperação',
recoveryCreated: 'A Frase de Recuperação atual foi criada em {date} ({id}).',
recoveryNoBackup:
- 'Nenhum backup feito neste dispositivo abre com a Frase de Recuperação atual ainda: faça um em Backup, acima.',
+ 'Nenhum backup feito neste dispositivo abre com a Frase de Recuperação atual ainda: faça um em Configurações › Backup.',
regenTitle: 'Regenerar a Frase de Recuperação',
regenIntro:
'Se você perdeu a Frase de Recuperação, regenere-a com o cofre desbloqueado: uma frase nova substitui a atual, e nada no cofre muda.',
diff --git a/apps/web/src/ui/FirstSteps.tsx b/apps/web/src/ui/FirstSteps.tsx
index 496a4e7..38d05c2 100644
--- a/apps/web/src/ui/FirstSteps.tsx
+++ b/apps/web/src/ui/FirstSteps.tsx
@@ -21,7 +21,7 @@ export function FirstSteps({
const m = useMessages();
const id = useId();
const settings = (focus: SettingsFocus) => () => {
- nav.open({ name: 'settings', focus });
+ nav.openSettings(focus);
};
const steps = [
...(session.writable
diff --git a/apps/web/src/ui/ImportItems.tsx b/apps/web/src/ui/ImportItems.tsx
index fd87cb6..45bb187 100644
--- a/apps/web/src/ui/ImportItems.tsx
+++ b/apps/web/src/ui/ImportItems.tsx
@@ -85,9 +85,6 @@ export function ImportItems({
return (
-
- {m.import.title}
-
{m.import.intro}
{!session.writable ? (
{m.import.readOnly}
diff --git a/apps/web/src/ui/MergeBackup.tsx b/apps/web/src/ui/MergeBackup.tsx
index ebc748f..4192690 100644
--- a/apps/web/src/ui/MergeBackup.tsx
+++ b/apps/web/src/ui/MergeBackup.tsx
@@ -6,7 +6,7 @@ import { type BackupHeaderInfo, type MergeOutcome, type VaultController } from '
import { useLanguage } from '../i18n/context';
import { type Messages, format } from '../i18n/messages';
import { Button, Notice } from './components';
-import { focusBackup } from './RegeneratePhrase';
+import { useOpenBackup } from './RegeneratePhrase';
import { ChooseFile, OpenBackup, fileError } from './RestoreVault';
import { recordTypeName } from './Settings';
@@ -47,7 +47,6 @@ export function MergeBackup({
}).format(at);
return (
-
{m.merge.title}
{m.merge.intro}
{step.kind === 'idle' || step.kind === 'done' ? (
{m.settings.regenOtherDevices}
{m.settings.regenOldBackups}
-
+
{m.settings.regenBackupNow}
diff --git a/apps/web/src/ui/Settings.tsx b/apps/web/src/ui/Settings.tsx
index dc2e753..938a504 100644
--- a/apps/web/src/ui/Settings.tsx
+++ b/apps/web/src/ui/Settings.tsx
@@ -5,7 +5,14 @@ import {
type SyncPurpose,
type VaultSession,
} from '@blindvault/vault';
-import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useId,
+ useState,
+ useSyncExternalStore,
+} from 'react';
import {
ACCENTS,
@@ -17,7 +24,7 @@ import {
import { download, phraseLabel, recordIdPrefix } from '../app/backup-file';
import { devicesOf, eraseAdvice, peerSyncState, rejectedPeersOf } from '../app/devices';
import { folderNameOf } from '../app/folders';
-import { type Navigation, type SettingsFocus } from '../app/nav';
+import { type Navigation, type SettingsFocus, type SettingsPage } from '../app/nav';
import {
type BackupExport,
type ExportAuth,
@@ -26,7 +33,7 @@ import {
type VaultController,
} from '../app/controller';
import { useLanguage } from '../i18n/context';
-import { LANGUAGES, type Messages, format } from '../i18n/messages';
+import { LANGUAGES, type Language, type Messages, format } from '../i18n/messages';
import { AboutBuild } from './About';
import { Button, ConfirmDialog, Field, INPUT, Notice, TARGET, TextArea } from './components';
import { EraseVault } from './EraseVault';
@@ -37,57 +44,184 @@ import { NewPassword } from './NewPassword';
import { InviteDevice, ReadmitDevice } from './Pairing';
import { SyncDevice } from './Sync';
import { RefoundVault } from './RefoundVault';
-import { RegeneratePhrase, focusBackup } from './RegeneratePhrase';
+import { OpenBackup, RegeneratePhrase, focusBackup, useOpenBackup } from './RegeneratePhrase';
+import { Icon, type IconName } from './icons';
import { DeviceStorage } from './StorageState';
+/** The index's groups, in order; the last has no title (D252). */
+const GROUPS: readonly (readonly [
+ keyof Messages['settings']['groups'] | undefined,
+ readonly SettingsPage[],
+])[] = [
+ ['preferences', ['appearance', 'language', 'locking']],
+ ['security', ['password', 'passkey', 'recovery']],
+ ['devices', ['devices']],
+ ['data', ['backup', 'merge', 'import', 'storage']],
+ ['danger', ['refound', 'erase']],
+ [undefined, ['about']],
+];
+
+const PAGE_ICON: Readonly> = {
+ appearance: 'palette',
+ language: 'languages',
+ locking: 'timer',
+ password: 'key-round',
+ passkey: 'fingerprint',
+ recovery: 'shield-check',
+ devices: 'monitor-smartphone',
+ backup: 'upload',
+ merge: 'git-merge',
+ import: 'download',
+ storage: 'hard-drive',
+ refound: 'refresh-cw',
+ erase: 'trash-2',
+ about: 'info',
+};
+
+const LANGUAGE_NAME: Readonly> = {
+ en: 'English',
+ 'pt-BR': 'Português (Brasil)',
+};
+
+/**
+ * Settings (D252): an index of pages in groups, each row with its current value where one helps;
+ * a row opens its page over the index, and back returns to it.
+ */
export function Settings({
controller,
session,
nav,
+ page,
focus,
}: {
readonly controller: VaultController;
readonly session: VaultSession;
readonly nav: Navigation;
+ readonly page?: SettingsPage | undefined;
/**
* Opened from the backup reminder (D188), "Vault ready" (D198) or the clock notice (D221): where
- * the cursor goes.
+ * the cursor goes on `page`.
*/
readonly focus?: SettingsFocus | undefined;
+}) {
+ const openBackup = useCallback(() => {
+ nav.openSettings('backup');
+ }, [nav]);
+ return (
+
+ {page ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function SettingsIndex({
+ session,
+ nav,
+}: {
+ readonly session: VaultSession;
+ readonly nav: Navigation;
+}) {
+ const { messages: m, language } = useLanguage();
+ const s = m.settings;
+ const last = session.lastBackupAt;
+ const hint: Partial> = {
+ appearance: s.themes[currentAppearance().theme],
+ language: LANGUAGE_NAME[language],
+ locking: format(s.hintMinutes, { n: String(session.preferences.autoLockMinutes) }),
+ passkey: session.passkeyCredentialId === undefined ? s.hintOff : s.hintOn,
+ backup:
+ last === undefined
+ ? s.hintNever
+ : new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(last),
+ devices: String(devicesOf(session).listed.length),
+ };
+ return (
+
{settings ? (
-
+
) : null}
>
);
@@ -430,7 +435,7 @@ export function VaultShell({
variant="quiet"
data-testid="clock-refound"
onClick={() => {
- nav.open({ name: 'settings', focus: 'refound' });
+ nav.openSettings('refound');
}}
>
{m.refound.title}
@@ -492,8 +497,7 @@ export function VaultShell({
{
- if (screen.name === 'settings') focusBackup();
- else nav.open({ name: 'settings', focus: 'backup' });
+ nav.openSettings('backup');
}}
>
{m.vault.reminderBackup}
diff --git a/apps/web/src/ui/icons/index.tsx b/apps/web/src/ui/icons/index.tsx
index 3d0a669..7c7ccbd 100644
--- a/apps/web/src/ui/icons/index.tsx
+++ b/apps/web/src/ui/icons/index.tsx
@@ -8,6 +8,7 @@ import type { ReactNode } from 'react';
const PATHS = {
check: ,
'chevron-left': ,
+ 'chevron-right': ,
'code-xml': (
<>
@@ -36,6 +37,13 @@ const PATHS = {
>
),
+ download: (
+ <>
+
+
+
+ >
+ ),
ellipsis: (
<>
@@ -66,9 +74,29 @@ const PATHS = {
>
),
+ fingerprint: (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ ),
folder: (
),
+ 'git-merge': (
+ <>
+
+
+
+ >
+ ),
globe: (
<>
@@ -76,6 +104,14 @@ const PATHS = {
>
),
+ 'hard-drive': (
+ <>
+
+
+
+
+ >
+ ),
history: (
<>
@@ -92,12 +128,29 @@ const PATHS = {
>
),
+ info: (
+ <>
+
+
+
+ >
+ ),
'key-round': (
<>
>
),
+ languages: (
+ <>
+
+
+
+
+
+
+ >
+ ),
list: (
<>
@@ -115,6 +168,23 @@ const PATHS = {
>
),
+ 'monitor-smartphone': (
+ <>
+
+
+
+
+ >
+ ),
+ palette: (
+ <>
+
+
+
+
+
+ >
+ ),
pencil: (
<>
@@ -127,12 +197,26 @@ const PATHS = {
>
),
+ 'refresh-cw': (
+ <>
+
+
+
+
+ >
+ ),
settings: (
<>
>
),
+ 'shield-check': (
+ <>
+
+
+ >
+ ),
'sticky-note': (
<>
@@ -145,6 +229,13 @@ const PATHS = {
>
),
+ timer: (
+ <>
+
+
+
+ >
+ ),
'trash-2': (
<>
@@ -154,6 +245,13 @@ const PATHS = {
>
),
+ upload: (
+ <>
+
+
+
+ >
+ ),
wallet: (
<>
diff --git a/docs/overview.md b/docs/overview.md
index aa84dcc..b97c126 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -1012,3 +1012,4 @@ This document is **normative**.
| D249 | Item password strength | The fifth area of Phase 7b (D244). **What is rated.** A template's `password` field of type `secret`, the one secret whose use is known; a PIN, a card's security code, a TOTP secret, a seed phrase and the user's own secret fields are not rated, since a score for them would mislead. **How.** zxcvbn with the dictionaries of D71, already loaded before the first unlock (D191), run in the page on the value in memory; nothing is stored or sent. Scores 0 to 2 are Weak, 3 Fair, 4 Strong. **Editor.** Under the password, a meter of four bars, seen only (`aria-hidden`), and the field's hint, its accessible description: "Strength: .", then zxcvbn's warning or a fixed advice below Strong. **Detail.** A Weak badge beside the password's name, only when weak; nothing for Fair or Strong. **Never blocks.** Unlike the local and export passwords (D71, D134), a weak item password is saved like any other: the site sets its rules, and the user may hold a password they cannot change. **Exposure.** The badge says a password is weak to someone who sees the screen, as the masked value does not; the detail is already shown only unlocked (section 13.2), and the list row shows no rating (CRYPTO-INV-024). |
| D250 | Feedback | The sixth area of Phase 7b (D244). **Toast.** One region of the app's own, a `role=status` mounted with the vault shell, so each toast is announced and none outlives a lock; at the bottom, above the phone's navigation, never taking the pointer. A new toast replaces the last, and each goes after 5 s. It says that an item was saved, moved to the trash or restored, and what a copy said beside its button before: copied, with the clearing that is not guaranteed (D54, D127), or that the browser refused; the check of D231 stays on the button. A toast holds no secret. Its entrance is motion under D236, none under `prefers-reduced-motion`. **Bottom sheet.** On a phone the confirmation of D231 is a bottom sheet: the same native modal `