diff --git a/apps/web/e2e/axe-devices.spec.ts b/apps/web/e2e/axe-devices.spec.ts index e26da6e..aa9d173 100644 --- a/apps/web/e2e/axe-devices.spec.ts +++ b/apps/web/e2e/axe-devices.spec.ts @@ -5,7 +5,7 @@ import { type Browser, type Page, expect, test } from '@playwright/test'; import { THEMES, WIDTHS, audit } from './axe'; -import { createVault, startScreen } from './create'; +import { checkSameNetwork, createVault, startScreen } from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -66,7 +66,7 @@ for (const [layout, width] of WIDTHS) { await settings(a); await button(a, 'Pair a device…').click(); await audit(a, 'the pairing password'); - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); const offer = a.getByTestId('pairing-text'); @@ -77,7 +77,7 @@ for (const [layout, width] of WIDTHS) { await button(b, 'Pair with an existing device').click(); await fillPassword(b.getByTestId('new-password'), PASSWORD_B); await fillPassword(b.getByTestId('confirm-password'), PASSWORD_B); - await b.getByTestId('pairing-same-network').check(); + await checkSameNetwork(b); await button(b, 'Start pairing').click(); await expect(b.getByTestId('pairing-paste')).toBeVisible({ timeout: 30_000 }); await audit(b, "B waiting for A's text"); @@ -109,14 +109,14 @@ for (const [layout, width] of WIDTHS) { await a.getByTestId('device-sync').click(); const syncA = a.getByTestId('sync'); await audit(a, 'the sync choice'); - await syncA.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncA); await syncA.getByTestId('sync-first').click(); const code = syncA.getByTestId('pairing-text'); await expect(code).not.toHaveValue('', { timeout: 30_000 }); await audit(a, "A's sync code"); await b.getByTestId('device-sync').click(); const syncB = b.getByTestId('sync'); - await syncB.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncB); await syncB.getByTestId('sync-read').click(); await syncB.getByTestId('pairing-paste').fill(await code.inputValue()); await button(b, 'Show my answer').click(); diff --git a/apps/web/e2e/axe.spec.ts b/apps/web/e2e/axe.spec.ts index 90b0924..e6006b4 100644 --- a/apps/web/e2e/axe.spec.ts +++ b/apps/web/e2e/axe.spec.ts @@ -38,7 +38,7 @@ for (const [layout, width] of WIDTHS) { await button(page, 'Pair with an existing device').click(); await audit(page, 'joining a vault'); - await button(page, 'Back').click(); + await button(page, 'Cancel').click(); await button(page, 'Restore from a backup').click(); await audit(page, 'restoring a backup'); await button(page, 'Cancel').click(); diff --git a/apps/web/e2e/backup-merge.spec.ts b/apps/web/e2e/backup-merge.spec.ts index c84a032..cefaa0d 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, openSettingsPage, openVault, startScreen } from './create'; +import { checkSameNetwork, createVault, openSettingsPage, openVault, startScreen } from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -275,7 +275,7 @@ test.describe('devices a merge brings', () => { async function pair(browser: Browser, a: Page, logged: string[]) { await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD); await button(a, 'Start pairing').click(); const offer = a.getByTestId('pairing-text'); @@ -286,7 +286,7 @@ test.describe('devices a merge brings', () => { await button(b, 'Pair with an existing device').click(); await fillPassword(b.getByTestId('new-password'), B_PASSWORD); await fillPassword(b.getByTestId('confirm-password'), B_PASSWORD); - await b.getByTestId('pairing-same-network').check(); + await checkSameNetwork(b); await button(b, 'Start pairing').click(); await b.getByTestId('pairing-paste').fill(await offer.inputValue(), { timeout: 30_000 }); await button(b, 'Show my answer').click(); diff --git a/apps/web/e2e/create.ts b/apps/web/e2e/create.ts index b473c5f..71f92db 100644 --- a/apps/web/e2e/create.ts +++ b/apps/web/e2e/create.ts @@ -1,7 +1,7 @@ // Vault creation as a user goes through it (§13.3, D179): the Recovery Phrase, its confirmation, // then the local password. -import { type Page, expect } from '@playwright/test'; +import { type Locator, type Page, expect } from '@playwright/test'; /** * Waits for the three starts. Playwright's WebKit is Safari in a plain tab, where the first screen @@ -113,3 +113,17 @@ export async function switchSettingsPage(page: Page, name: SettingsPageName): Pr await page.getByRole('button', { name: 'Back', exact: true }).click(); await page.getByTestId(`settings-${name}`).click(); } + +/** Opens the advanced options of a device wizard's first step, unless open already (D253). */ +export async function openAdvanced(scope: Page | Locator): Promise { + const box = scope.getByTestId('pairing-same-network'); + if (!(await box.isVisible())) + await scope.getByTestId('advanced-options').locator('summary').click(); + await expect(box).toBeVisible(); +} + +/** Checks "Same network only" in the advanced options (D203, D253). */ +export async function checkSameNetwork(scope: Page | Locator): Promise { + await openAdvanced(scope); + await scope.getByTestId('pairing-same-network').check(); +} diff --git a/apps/web/e2e/onboarding.spec.ts b/apps/web/e2e/onboarding.spec.ts index c0ee1fb..112fe14 100644 --- a/apps/web/e2e/onboarding.spec.ts +++ b/apps/web/e2e/onboarding.spec.ts @@ -58,8 +58,11 @@ test('says that no server can reset the vault, then offers the three starts (§1 await expect(page.getByRole('heading', { name: 'Pair with an existing device' })).toBeVisible(); await expect(page.getByText(/First create this device’s local password/)).toBeVisible(); await expect(page.getByTestId('new-password')).toBeVisible(); + await expect(page.getByTestId('wizard-step')).toHaveText( + 'Step 1 of 5 · Create the local password', + ); await expect(page.getByTestId('pairing-same-network')).not.toBeChecked(); - await button(page, 'Back').click(); + await button(page, 'Cancel').click(); await expect(button(page, 'Pair with an existing device')).toBeVisible(); await button(page, 'Restore from a backup').click(); await expect(page.getByRole('heading', { name: 'Restore from a backup' })).toBeVisible(); diff --git a/apps/web/e2e/pairing-qr.spec.ts b/apps/web/e2e/pairing-qr.spec.ts index 95a4ee1..59123f1 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, openSettingsPage } from './create'; +import { checkSameNetwork, createVault, openSettingsPage, openVault, startScreen } from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -61,7 +61,7 @@ async function invite(a: Page): Promise { await expect(a.getByRole('heading', { name: TITLE })).toBeVisible(); await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); await expect(a.getByTestId('pairing-qr')).toBeVisible({ timeout: 30_000 }); @@ -73,7 +73,7 @@ async function join(b: Page): Promise { await button(b, 'Pair with an existing device').click(); await fillPassword(b.getByTestId('new-password'), PASSWORD_B); await fillPassword(b.getByTestId('confirm-password'), PASSWORD_B); - await b.getByTestId('pairing-same-network').check(); + await checkSameNetwork(b); await button(b, 'Start pairing').click(); await expect(b.getByTestId('pairing-paste')).toBeVisible({ timeout: 30_000 }); } diff --git a/apps/web/e2e/pairing-stun.spec.ts b/apps/web/e2e/pairing-stun.spec.ts index 81309cd..295e4c7 100644 --- a/apps/web/e2e/pairing-stun.spec.ts +++ b/apps/web/e2e/pairing-stun.spec.ts @@ -1,6 +1,12 @@ import { type Browser, type Page, expect, test } from '@playwright/test'; -import { createVault, startScreen, openSettingsPage } from './create'; +import { + checkSameNetwork, + openAdvanced, + createVault, + openSettingsPage, + startScreen, +} from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -66,6 +72,7 @@ test('a STUN list of its own: refused when invalid, saved, used by the pairing, await createVault(a, PASSWORD_A); await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); + await openAdvanced(a); const hint = a.getByTestId('same-network-hint'); await expect(hint).toContainText('(stun.l.google.com, stun.cloudflare.com)'); await a.getByTestId('stun-open').click(); @@ -115,6 +122,7 @@ test('a STUN list of its own: refused when invalid, saved, used by the pairing, // Cancelled, the screen reads the list again: it stays, until the defaults are restored. await expect(a.getByTestId('invite-password')).toBeVisible(); + await openAdvanced(a); await a.getByTestId('stun-open').click(); await expect(list).toHaveText(['stun:127.0.0.1:3478', 'stun:stun.invalid:3479']); await a.getByTestId('stun-reset').click(); @@ -123,7 +131,7 @@ test('a STUN list of its own: refused when invalid, saved, used by the pairing, expect(await stored(a)).toBeNull(); // The same network only hides the list and uses no STUN server. - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await expect(a.getByTestId('stun')).toHaveCount(0); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); @@ -146,7 +154,7 @@ test('an unreadable stored list: the same network only, locked on, and the pairi await createVault(a, PASSWORD_A); await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); const offerText = a.getByTestId('pairing-text'); diff --git a/apps/web/e2e/pairing.spec.ts b/apps/web/e2e/pairing.spec.ts index 0a1d966..42571ab 100644 --- a/apps/web/e2e/pairing.spec.ts +++ b/apps/web/e2e/pairing.spec.ts @@ -3,6 +3,7 @@ import { type Browser, type Page, expect, test } from '@playwright/test'; import { corruptRecords } from './corrupt'; import { + checkSameNetwork, createVault, openSettingsPage, openVault, @@ -62,11 +63,16 @@ async function invite(a: Page): Promise { await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); - await a.getByTestId('pairing-same-network').check(); + // One step at a time, with what to do on the other device (D253). + await expect(a.getByTestId('wizard-step')).toHaveText('Step 1 of 4 · Confirm it is you'); + await expect(a.getByTestId('wizard-other')).toContainText('Pair with an existing device'); + await expect(a.getByTestId('pairing-same-network')).toBeHidden(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); const text = a.getByTestId('pairing-text'); await expect(text).not.toHaveValue('', { timeout: 30_000 }); + await expect(a.getByTestId('wizard-step')).toHaveText('Step 2 of 4 · Exchange the codes'); // What the code or the text carries shows this device's network addresses (D208). await expect(a.getByText(CHANNEL)).toBeVisible(); return text.inputValue(); @@ -78,12 +84,13 @@ async function join(b: Page, offer: string): Promise { await button(b, 'Pair with an existing device').click(); await fillPassword(b.getByTestId('new-password'), PASSWORD_B); await fillPassword(b.getByTestId('confirm-password'), PASSWORD_B); - await b.getByTestId('pairing-same-network').check(); + await checkSameNetwork(b); await button(b, 'Start pairing').click(); await b.getByTestId('pairing-paste').fill(offer, { timeout: 30_000 }); await button(b, 'Show my answer').click(); const text = b.getByTestId('pairing-text'); await expect(text).not.toHaveValue('', { timeout: 30_000 }); + await expect(b.getByTestId('wizard-step')).toHaveText('Step 3 of 5 · Show the answer'); await expect(b.getByText(CHANNEL)).toBeVisible(); return text.inputValue(); } @@ -103,6 +110,8 @@ async function toSas(browser: Browser, clockA = false) { await expect(sasB).toHaveText(/^\d{3} \d{3}$/, { timeout: 30_000 }); const sas = await sasA.textContent(); expect(await sasB.textContent()).toBe(sas); + await expect(a.getByTestId('wizard-step')).toHaveText('Step 3 of 4 · Compare the numbers'); + await expect(b.getByTestId('wizard-step')).toHaveText('Step 4 of 5 · Compare the numbers'); return { a, b }; } @@ -286,7 +295,7 @@ test('a hidden-app lock at 0 says it locks at once, in Settings, the export and await switchSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); await expect(a.getByTestId('pairing-text')).not.toHaveValue('', { timeout: 30_000 }); @@ -306,7 +315,7 @@ test('a hidden-app lock at 0 says it locks at once, in Settings, the export and await expect(a.getByText(exportHidden)).toHaveCount(0); await switchSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); await expect(a.getByTestId('pairing-text')).not.toHaveValue('', { timeout: 30_000 }); @@ -336,7 +345,7 @@ async function replacing(browser: Browser) { ); await fillPassword(b.getByTestId('new-password'), PASSWORD_NEW); await fillPassword(b.getByTestId('confirm-password'), PASSWORD_NEW); - await b.getByTestId('pairing-same-network').check(); + await checkSameNetwork(b); // Nothing starts before the user confirms the replacement (D183, D204). await expect(button(b, 'Start pairing')).toBeDisabled(); await b.getByTestId('replace-understood').check(); @@ -382,7 +391,7 @@ test('numbers said to differ leave the vault being replaced intact (D150, D153)' await button(b, 'No, it differs').click(); await expect(a.getByTestId('pairing-failed')).toHaveAttribute('data-reason', 'sas-mismatch'); await button(b, 'Start again').click(); - await button(b, 'Back').click(); + await button(b, 'Cancel').click(); await fillPassword(b.getByTestId('unlock-password'), PASSWORD_B); await button(b, 'Unlock').click(); await expect(b.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 }); diff --git a/apps/web/e2e/readmission.spec.ts b/apps/web/e2e/readmission.spec.ts index fe3856e..f45794b 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, openSettingsPage, openVault, startScreen } from './create'; +import { checkSameNetwork, createVault, openSettingsPage, openVault, startScreen } from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -58,7 +58,7 @@ async function addItem(page: Page, title: string): Promise { async function startReadmission(page: Page, first: boolean): Promise { 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 checkSameNetwork(page.getByTestId('readmit-device')); await page.getByTestId(first ? 'readmission-first' : 'readmission-paste').click(); } @@ -162,7 +162,7 @@ test('a pairing answered by a device of the vault is refused, and a restored dev // A invites; B answers from its re-admission, as a device that already holds the vault. await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); const offer = a.getByTestId('pairing-text'); @@ -184,7 +184,7 @@ test('a pairing answered by a device of the vault is refused, and a restored dev // B, restored and unregistered, pairs a new device C: the others know neither yet. await button(b, 'Pair a device…').click(); - await b.getByTestId('invite').getByTestId('pairing-same-network').check(); + await checkSameNetwork(b.getByTestId('invite')); await fillPassword(b.getByTestId('invite-password'), PASSWORD_B); await button(b, 'Start pairing').click(); const invite = b.getByTestId('pairing-text'); @@ -194,7 +194,7 @@ test('a pairing answered by a device of the vault is refused, and a restored dev await button(c, 'Pair with an existing device').click(); await fillPassword(c.getByTestId('new-password'), PASSWORD_A); await fillPassword(c.getByTestId('confirm-password'), PASSWORD_A); - await c.getByTestId('pairing-same-network').check(); + await checkSameNetwork(c); await button(c, 'Start pairing').click(); await c.getByTestId('pairing-paste').fill(await invite.inputValue(), { timeout: 30_000 }); await button(c, 'Show my answer').click(); diff --git a/apps/web/e2e/sync.spec.ts b/apps/web/e2e/sync.spec.ts index 4f3cdb3..86e4b94 100644 --- a/apps/web/e2e/sync.spec.ts +++ b/apps/web/e2e/sync.spec.ts @@ -4,6 +4,7 @@ import { type Browser, type Page, expect, test } from '@playwright/test'; import { corruptRecords } from './corrupt'; import { answerConfirmation, + checkSameNetwork, createVault, openSettingsPage, startScreen, @@ -64,7 +65,7 @@ async function paired(browser: Browser) { await createVault(a, PASSWORD_A); await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); - await a.getByTestId('pairing-same-network').check(); + await checkSameNetwork(a); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); const offer = a.getByTestId('pairing-text'); @@ -74,7 +75,7 @@ async function paired(browser: Browser) { await button(b, 'Pair with an existing device').click(); await fillPassword(b.getByTestId('new-password'), PASSWORD_B); await fillPassword(b.getByTestId('confirm-password'), PASSWORD_B); - await b.getByTestId('pairing-same-network').check(); + await checkSameNetwork(b); await button(b, 'Start pairing').click(); await b.getByTestId('pairing-paste').fill(await offer.inputValue(), { timeout: 30_000 }); await button(b, 'Show my answer').click(); @@ -106,7 +107,7 @@ test('two paired devices connect from their sync codes, each with the other pick // Each picks the other device; A shows its code first, B reads it. await a.getByTestId('device-sync').click(); const syncA = a.getByTestId('sync'); - await syncA.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncA); await syncA.getByTestId('sync-first').click(); const offer = syncA.getByTestId('pairing-text'); await expect(offer).not.toHaveValue('', { timeout: 30_000 }); @@ -121,7 +122,7 @@ test('two paired devices connect from their sync codes, each with the other pick await b.getByTestId('device-sync').click(); const syncB = b.getByTestId('sync'); - await syncB.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncB); await syncB.getByTestId('sync-read').click(); await expect( syncB.getByText( @@ -184,7 +185,7 @@ test('A retires B, then tells it: B merges its retired record and shows the D41 await expect(syncA).toContainText( `On ${nameB}, open Settings → Devices and choose “Sync” next to this device. This device sends ${nameB} only its Device record, marked retired, and the sync ends: ${nameB} then shows that it was retired and stops syncing.`, ); - await syncA.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncA); await syncA.getByTestId('sync-first').click(); const offer = syncA.getByTestId('pairing-text'); await expect(offer).not.toHaveValue('', { timeout: 30_000 }); @@ -192,7 +193,7 @@ test('A retires B, then tells it: B merges its retired record and shows the D41 // B does not know yet: it picks A as for any sync. await b.getByTestId('device-sync').click(); const syncB = b.getByTestId('sync'); - await syncB.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncB); await syncB.getByTestId('sync-read').click(); await syncB.getByTestId('pairing-paste').fill(await offer.inputValue()); await button(b, 'Show my answer').click(); @@ -241,13 +242,13 @@ test('A retires B during an open sync: B merges its retired record and shows the await a.getByTestId('device-sync').click(); const syncA = a.getByTestId('sync'); - await syncA.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncA); await syncA.getByTestId('sync-first').click(); const offer = syncA.getByTestId('pairing-text'); await expect(offer).not.toHaveValue('', { timeout: 30_000 }); await b.getByTestId('device-sync').click(); const syncB = b.getByTestId('sync'); - await syncB.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncB); await syncB.getByTestId('sync-read').click(); await syncB.getByTestId('pairing-paste').fill(await offer.inputValue()); await button(b, 'Show my answer').click(); @@ -274,13 +275,13 @@ test('A retires B during an open sync: B merges its retired record and shows the async function connect(a: Page, b: Page) { await a.getByTestId('device-sync').click(); const syncA = a.getByTestId('sync'); - await syncA.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncA); await syncA.getByTestId('sync-first').click(); const offer = syncA.getByTestId('pairing-text'); await expect(offer).not.toHaveValue('', { timeout: 30_000 }); await b.getByTestId('device-sync').click(); const syncB = b.getByTestId('sync'); - await syncB.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncB); await syncB.getByTestId('sync-read').click(); await syncB.getByTestId('pairing-paste').fill(await offer.inputValue()); await button(b, 'Show my answer').click(); @@ -385,13 +386,13 @@ test('the stored record of a retired B that fails verification: A sends nothing, await retired.locator('summary').click(); await retired.getByTestId('device-notify').click(); const syncA = a.getByTestId('sync'); - await syncA.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncA); await syncA.getByTestId('sync-first').click(); const offer = syncA.getByTestId('pairing-text'); await expect(offer).not.toHaveValue('', { timeout: 30_000 }); await b.getByTestId('device-sync').click(); const syncB = b.getByTestId('sync'); - await syncB.getByTestId('pairing-same-network').check(); + await checkSameNetwork(syncB); await syncB.getByTestId('sync-read').click(); await syncB.getByTestId('pairing-paste').fill(await offer.inputValue()); await button(b, 'Show my answer').click(); diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index 03038c0..8f1f85b 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -104,6 +104,15 @@ const en = { 'Share → Add to Home Screen on iPhone and iPad, File → Add to Dock on a Mac. Then open BlindVault from there.', continueTab: 'Continue in this tab', }, + wizard: { + step: 'Step {n} of {total} · {name}', + other: 'On the other device', + cancel: 'Cancel', + advanced: 'Advanced options', + codes: + 'One device shows a code and the other scans or pastes it; the answer comes back the same way.', + sas: 'The same six digits show there: answer on both devices.', + }, pairing: { title: 'Pair a device', intro: @@ -160,6 +169,17 @@ const en = { close: 'Close', again: 'Start again', cancel: 'Cancel', + steps: { + auth: 'Confirm it is you', + codes: 'Exchange the codes', + sas: 'Compare the numbers', + done: 'Done', + }, + other: { + auth: 'Open BlindVault, choose “Pair with an existing device” and create its local password.', + codes: 'Scan or paste this device’s code there, then scan or paste its answer here.', + done: 'It holds the vault now, unlocked with the local password created there.', + }, pasteInvalid: 'This is not a BlindVault pairing text. Copy it again, whole.', pasteVersion: 'This text comes from another version of BlindVault: update both devices.', pasteWrongSession: @@ -225,6 +245,20 @@ const en = { answerLabel: 'This device’s answer', waitingConnection: 'Waiting for the other device to connect…', receiving: 'Receiving the vault…', + steps: { + password: 'Create the local password', + scan: 'Read the other device’s code', + answer: 'Show the answer', + sas: 'Compare the numbers', + done: 'Done', + }, + other: { + password: + 'On the device that holds the vault, open Settings › Devices › “Pair a device…” and enter its local password.', + scan: 'It shows a code: scan it, or paste its text here.', + answer: 'Scan or paste this answer there.', + done: 'It shows the pairing done.', + }, }, readmission: { title: 'Re-admission', @@ -234,6 +268,15 @@ const en = { how: 'On the other device, open Settings → Devices → “Re-admit with another device of this vault…” too. One device shows its text first; the other pastes it.', first: 'Show this device’s text first', paste: 'Paste the other device’s text', + chooseRole: + 'Choose which device shows its text first. On the other device, choose the other option.', + steps: { + role: 'Choose who shows first', + codes: 'Exchange the codes', + sas: 'Compare the numbers', + done: 'Done', + }, + otherDone: 'It shows the re-admission done.', readOnly: 'The device clock went back, so the vault is read-only: re-admission comes back once writing does.', retired: 'This device was retired: it cannot be re-admitted. Replace it by pairing to sync.', @@ -292,6 +335,17 @@ const en = { how: 'On {name}, open Settings → Devices and choose “Sync” next to this device too. One device shows its code first; the other scans or pastes it.', first: 'Show this device’s code first', read: 'Read the code of {name}', + chooseRole: 'Choose which device shows its code first. On {name}, choose the other option.', + steps: { + role: 'Choose who shows first', + codes: 'Exchange the codes', + syncing: 'Syncing', + done: 'Done', + }, + other: { + syncing: '{name} exchanges its records with this device.', + done: '{name} shows the sync done.', + }, cancel: 'Cancel', unavailable: 'This browser cannot sync: it offers no WebRTC.', refused: { @@ -1180,6 +1234,15 @@ const ptBR: Messages = { 'Compartilhar → Adicionar à Tela de Início no iPhone e no iPad, Arquivo → Adicionar ao Dock no Mac. Depois abra o BlindVault por lá.', continueTab: 'Continuar nesta aba', }, + wizard: { + step: 'Passo {n} de {total} · {name}', + other: 'No outro dispositivo', + cancel: 'Cancelar', + advanced: 'Opções avançadas', + codes: + 'Um dispositivo mostra um código e o outro o escaneia ou cola; a resposta volta do mesmo jeito.', + sas: 'Os mesmos seis dígitos aparecem lá: responda nos dois dispositivos.', + }, pairing: { title: 'Parear um dispositivo', intro: @@ -1236,6 +1299,18 @@ const ptBR: Messages = { close: 'Fechar', again: 'Começar de novo', cancel: 'Cancelar', + steps: { + auth: 'Confirmar que é você', + codes: 'Trocar os códigos', + sas: 'Conferir os números', + done: 'Pronto', + }, + other: { + auth: 'Abra o BlindVault, escolha “Parear com um dispositivo existente” e crie a senha local dele.', + codes: + 'Escaneie ou cole lá o código deste dispositivo; depois escaneie ou cole aqui a resposta dele.', + done: 'Ele já tem o cofre, que abre com a senha local criada nele.', + }, pasteInvalid: 'Isto não é um texto de pareamento do BlindVault. Copie-o de novo, inteiro.', pasteVersion: 'Este texto vem de outra versão do BlindVault: atualize os dois dispositivos.', pasteWrongSession: 'Esta resposta é de outro pareamento: cole a resposta ao texto acima.', @@ -1303,6 +1378,20 @@ const ptBR: Messages = { answerLabel: 'Resposta deste dispositivo', waitingConnection: 'Aguardando o outro dispositivo conectar…', receiving: 'Recebendo o cofre…', + steps: { + password: 'Criar a senha local', + scan: 'Ler o código do outro dispositivo', + answer: 'Mostrar a resposta', + sas: 'Conferir os números', + done: 'Pronto', + }, + other: { + password: + 'No dispositivo que tem o cofre, abra Configurações › Dispositivos › “Parear um dispositivo…” e digite a senha local dele.', + scan: 'Ele mostra um código: escaneie-o, ou cole aqui o texto dele.', + answer: 'Escaneie ou cole esta resposta lá.', + done: 'Ele mostra o pareamento concluído.', + }, }, readmission: { title: 'Readmissão', @@ -1312,6 +1401,15 @@ const ptBR: Messages = { how: 'No outro dispositivo, abra também Configurações → Dispositivos → “Readmitir com outro dispositivo deste cofre…”. Um dispositivo mostra o texto dele primeiro; o outro o cola.', first: 'Mostrar primeiro o texto deste dispositivo', paste: 'Colar o texto do outro dispositivo', + chooseRole: + 'Escolha qual dispositivo mostra o texto primeiro. No outro dispositivo, escolha a outra opção.', + steps: { + role: 'Escolher quem mostra primeiro', + codes: 'Trocar os códigos', + sas: 'Conferir os números', + done: 'Pronto', + }, + otherDone: 'Ele mostra a readmissão concluída.', readOnly: 'O relógio do dispositivo voltou, então o cofre está somente leitura: a readmissão volta quando a escrita voltar.', retired: @@ -1373,6 +1471,18 @@ const ptBR: Messages = { how: 'Em {name}, abra também Configurações → Dispositivos e escolha “Sincronizar” ao lado deste dispositivo. Um dispositivo mostra o código dele primeiro; o outro o escaneia ou cola.', first: 'Mostrar primeiro o código deste dispositivo', read: 'Ler o código de {name}', + chooseRole: + 'Escolha qual dispositivo mostra o código primeiro. Em {name}, escolha a outra opção.', + steps: { + role: 'Escolher quem mostra primeiro', + codes: 'Trocar os códigos', + syncing: 'Sincronizando', + done: 'Pronto', + }, + other: { + syncing: '{name} troca os registros dele com este dispositivo.', + done: '{name} mostra a sincronização concluída.', + }, cancel: 'Cancelar', unavailable: 'Este navegador não consegue sincronizar: ele não oferece WebRTC.', refused: { diff --git a/apps/web/src/ui/Pairing.tsx b/apps/web/src/ui/Pairing.tsx index e1b1d65..64e805d 100644 --- a/apps/web/src/ui/Pairing.tsx +++ b/apps/web/src/ui/Pairing.tsx @@ -28,6 +28,7 @@ import { MergeNotices } from './MergeBackup'; import { NewPassword } from './NewPassword'; import { QrCode, QrScanner } from './Qr'; import { recordTypeName } from './Settings'; +import { AdvancedOptions, Wizard, type WizardStep, useStep } from './Wizard'; // The pairing screens (§8.4, D202, D203): A invites from Settings › Devices, B joins from the pair // start. Each payload is shown as a QR code and as text; the other device scans it or pastes it @@ -173,6 +174,25 @@ export function SameNetwork({ ); } +/** + * "Same network only" in the advanced options of a wizard's first step, open at once when the stored + * STUN list cannot be read, which forces it (D209, D253). + */ +export function NetworkOptions({ + checked, + onChange, +}: { + readonly checked: boolean; + readonly onChange: (checked: boolean) => void; +}) { + const [unreadable] = useState(() => loadStunSettings().kind === 'unreadable'); + return ( + + + + ); +} + function stunProblem(m: Messages, list: Extract): string { const p = m.stun.problems; const url = list.url ?? ''; @@ -432,7 +452,67 @@ export function rejectedText(m: Messages, rejected: readonly RejectedRecord[]): return format(r.rejected, { n: String(rejected.length), records }); } -/** Every stage after the start, on either device. */ +/** The steps of each flow, with what to do on the other device (D253). */ +function flowSteps(m: Messages, flow: PairingFlow): readonly WizardStep[] { + const w = m.wizard; + switch (flow) { + case 'invite': { + const p = m.pairing; + return [ + { name: p.steps.auth, other: p.other.auth }, + { name: p.steps.codes, other: p.other.codes }, + { name: p.steps.sas, other: w.sas }, + { name: p.steps.done, other: p.other.done }, + ]; + } + case 'join': { + const j = m.join; + return [ + { name: j.steps.password, other: j.other.password }, + { name: j.steps.scan, other: j.other.scan }, + { name: j.steps.answer, other: j.other.answer }, + { name: j.steps.sas, other: w.sas }, + { name: j.steps.done, other: j.other.done }, + ]; + } + case 'readmit': { + const r = m.readmission; + return [ + { name: r.steps.role, other: r.how }, + { name: r.steps.codes, other: w.codes }, + { name: r.steps.sas, other: w.sas }, + { name: r.steps.done, other: r.otherDone }, + ]; + } + } +} + +/** + * The step `stage` belongs to: preparing and connecting wait within the exchange of codes, and the + * transfer within the numbers' step (D253). `undefined` for a failure. + */ +function stageStep(flow: PairingFlow, stage: PairingStage): number | undefined { + const join = flow === 'join'; + switch (stage.kind) { + case 'preparing': + case 'offer': + case 'scan': + return 2; + case 'answer': + case 'connecting': + return join ? 3 : 2; + case 'sas': + case 'transfer': + return join ? 4 : 3; + case 'done': + case 'readmitted': + return join ? 5 : 4; + case 'failed': + return undefined; + } +} + +/** Every stage after the start, on either device, as a step of the wizard (D253). */ function Stage({ controller, session, @@ -449,31 +529,62 @@ function Stage({ /** What hiding the app does to the pairing (D93, D105). */ readonly leaving: string; readonly onAgain: () => void; +}) { + const m = useMessages(); + const step = useStep(stageStep(flow, stage)); + // Nothing to cancel once the vault is on its way, done or failed. + const final = + stage.kind === 'transfer' || + stage.kind === 'done' || + stage.kind === 'readmitted' || + stage.kind === 'failed'; + return ( + { + controller.cancelPairing(); + } + } + cancelTestId="pairing-cancel" + > + + + ); +} + +function StageBody({ + controller, + session, + stage, + flow, + leaving, + onAgain, +}: { + readonly controller: VaultController; + readonly session?: VaultSession | undefined; + readonly stage: PairingStage; + readonly flow: PairingFlow; + readonly leaving: string; + readonly onAgain: () => void; }) { const { messages: m, language } = useLanguage(); const p = m.pairing; const r = m.readmission; const readmit = flow === 'readmit'; - const cancel = ( - - ); switch (stage.kind) { case 'preparing': - return ( - <> -

{p.preparing}

- {cancel} - - ); + return

{p.preparing}

; case 'offer': return ( <> @@ -486,7 +597,6 @@ function Stage({ onPaste={(text) => Promise.resolve(controller.pairingAnswer(text))} errorOf={(check) => payloadError(m, check, flow)} /> - {cancel} ); case 'scan': @@ -500,7 +610,6 @@ function Stage({ onPaste={(text) => controller.joinOffer(text)} errorOf={(check) => payloadError(m, check, flow)} /> - {cancel} ); case 'answer': @@ -510,16 +619,10 @@ function Stage({ {leaving}

{m.join.waitingConnection}

- {cancel} ); case 'connecting': - return ( - <> -

{p.connecting}

- {cancel} - - ); + return

{p.connecting}

; case 'sas': return ( <> @@ -555,7 +658,6 @@ function Stage({ )} - {cancel} ); case 'transfer': @@ -752,9 +854,20 @@ export function InviteDevice({ ) : ( - <> + { + setOpen(false); + setPassword(''); + setOutcome(undefined); + } + } + >

{p.auth}

- {passkeys && controller.hasPasskey ? ( - - + + - +
)} ); @@ -854,9 +955,16 @@ export function JoinVault({ /> ); } - if (working) return

{m.pairing.preparing}

; + const steps = flowSteps(m, 'join'); + if (working) { + return ( + +

{m.pairing.preparing}

+
+ ); + } return ( - <> +

{j.intro}

- + {replace ? (
); } @@ -1005,9 +1108,20 @@ export function ReadmitDevice({ ) : ( - <> -

{r.how}

- + { + setOpen(false); + setOutcome(undefined); + } + } + > +

{r.chooseRole}

+ {error ? (

{error} @@ -1033,18 +1147,8 @@ export function ReadmitDevice({ > {r.paste} - - + )} ); diff --git a/apps/web/src/ui/Sync.tsx b/apps/web/src/ui/Sync.tsx index 0d80109..1ee51f0 100644 --- a/apps/web/src/ui/Sync.tsx +++ b/apps/web/src/ui/Sync.tsx @@ -8,8 +8,9 @@ import { useLanguage, useMessages } from '../i18n/context'; import { type Messages, format } from '../i18n/messages'; import { Button, Notice } from './components'; import { MergeNotices } from './MergeBackup'; -import { PasteText, PayloadText, Rejected, SameNetwork } from './Pairing'; +import { NetworkOptions, PasteText, PayloadText, Rejected } from './Pairing'; import { recordTypeName } from './Settings'; +import { Wizard, type WizardStep, useStep } from './Wizard'; // The sync screens (§9.1, D216): the user picks the peer in Settings › Devices on both devices, then // chooses on each whether it shows its code first or reads the other's. Each code is shown as a QR @@ -51,11 +52,41 @@ function leavingText(m: Messages, session: VaultSession): string { : format(m.sync.leaving, { seconds: String(hidden) }); } +/** The steps of a sync, with what to do on `name` (D253). */ +function syncSteps(m: Messages, name: string, how: string): readonly WizardStep[] { + const s = m.sync; + return [ + { name: s.steps.role, other: how }, + { name: s.steps.codes, other: m.wizard.codes }, + { name: s.steps.syncing, other: format(s.other.syncing, { name }) }, + { name: s.steps.done, other: format(s.other.done, { name }) }, + ]; +} + +/** The step `stage` belongs to; `undefined` for a failure (D253). */ +function stageStep(stage: SyncStage): number | undefined { + switch (stage.kind) { + case 'preparing': + case 'offer': + case 'scan': + case 'answer': + case 'connecting': + return 2; + case 'connected': + return 3; + case 'synced': + return 4; + case 'failed': + return undefined; + } +} + function Stage({ controller, session, stage, name, + steps, leaving, onClose, }: { @@ -63,29 +94,59 @@ function Stage({ readonly session: VaultSession; readonly stage: SyncStage; readonly name: string; + readonly steps: readonly WizardStep[]; readonly leaving: string; readonly onClose: () => void; }) { - const { messages: m, language } = useLanguage(); - const s = m.sync; + const step = useStep(stageStep(stage)); const close = () => { controller.cancelSync(); onClose(); }; - const cancel = ( - + const final = stage.kind === 'synced' || stage.kind === 'failed'; + return ( + + + ); +} + +function StageBody({ + controller, + session, + stage, + name, + leaving, + onClose, + close, +}: { + readonly controller: VaultController; + readonly session: VaultSession; + readonly stage: SyncStage; + readonly name: string; + readonly leaving: string; + readonly onClose: () => void; + readonly close: () => void; +}) { + const { messages: m, language } = useLanguage(); + const s = m.sync; const channel = format(s.channel, { name }); switch (stage.kind) { case 'preparing': - return ( - <> -

{s.preparing}

- {cancel} - - ); + return

{s.preparing}

; case 'offer': return ( <> @@ -98,7 +159,6 @@ function Stage({ onPaste={(text) => controller.syncPayload(text)} errorOf={(check) => pasteError(m, check, name)} /> - {cancel} ); case 'scan': @@ -112,7 +172,6 @@ function Stage({ onPaste={(text) => controller.syncPayload(text)} errorOf={(check) => pasteError(m, check, name)} /> - {cancel} ); case 'answer': @@ -122,24 +181,15 @@ function Stage({ {leaving}

{s.waiting}

- {cancel} ); case 'connecting': - return ( - <> -

{s.connecting}

- {cancel} - - ); + return

{s.connecting}

; case 'connected': return ( - <> -

- {format(s.connected, { name })} -

- {cancel} - +

+ {format(s.connected, { name })} +

); case 'synced': return ( @@ -242,6 +292,11 @@ export function SyncDevice({ setOutcome(controller.startSync(peer.deviceId, role, { sameNetwork }, purpose)); }; const error = startError(m, outcome, name); + const steps = syncSteps( + m, + name, + format(purpose === 'retirement' ? s.notifyHow : s.how, { name }), + ); // The panel opens below the device list: bring it into view where the user clicked (D234). const heading = useRef(null); useEffect(() => { @@ -259,15 +314,14 @@ export function SyncDevice({ session={session} stage={mine} name={name} + steps={steps} leaving={leavingText(m, session)} onClose={onClose} /> ) : ( - <> -

- {format(purpose === 'retirement' ? s.notifyHow : s.how, { name })} -

- + +

{format(s.chooseRole, { name })}

+ {error ? (

{error} @@ -291,11 +345,8 @@ export function SyncDevice({ > {format(s.read, { name })} - - + )} ); diff --git a/apps/web/src/ui/Wizard.tsx b/apps/web/src/ui/Wizard.tsx new file mode 100644 index 0000000..85d69ab --- /dev/null +++ b/apps/web/src/ui/Wizard.tsx @@ -0,0 +1,100 @@ +import { type ReactNode, useState } from 'react'; + +import { useMessages } from '../i18n/context'; +import { format } from '../i18n/messages'; +import { Button } from './components'; + +/** A step of a wizard: its name, and what to do meanwhile on the other device, if any (D253). */ +export interface WizardStep { + readonly name: string; + readonly other?: string; +} + +/** + * One step at a time (D253): "Step N of M · name" with a bar, the step's content, what to do on the + * other device, and Cancel while there is something to cancel. There is no Back: a failure says why + * and starts again from step 1. + */ +export function Wizard({ + steps, + step, + onCancel, + cancelTestId, + children, +}: { + readonly steps: readonly WizardStep[]; + /** 1-based. */ + readonly step: number; + readonly onCancel?: (() => void) | undefined; + readonly cancelTestId?: string; + readonly children: ReactNode; +}) { + const m = useMessages(); + const current = steps[step - 1]; + return ( +

+
+

+ {format(m.wizard.step, { + n: String(step), + total: String(steps.length), + name: current?.name ?? '', + })} +

+ +
+ {children} + {current?.other ? ( + + ) : null} + {onCancel ? ( + + ) : null} +
+ ); +} + +/** The step shown: `step`, or while it is `undefined` (a failure), the last one shown. */ +export function useStep(step: number | undefined): number { + const [last, setLast] = useState(step ?? 1); + if (step !== undefined && step !== last) setLast(step); + return step ?? last; +} + +/** Options most never need, closed at first: the same network only, the STUN list (D253). */ +export function AdvancedOptions({ + open = false, + children, +}: { + /** Open at first, when one of the options needs the user. */ + readonly open?: boolean; + readonly children: ReactNode; +}) { + const m = useMessages(); + return ( +
+ {m.wizard.advanced} +
{children}
+
+ ); +} diff --git a/docs/overview.md b/docs/overview.md index b97c126..ac361f4 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1013,3 +1013,4 @@ This document is **normative**. | 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 ``, with its focus, Escape and inert page, placed at the bottom at full width, its buttons full width and 44 px tall, the action first; wider, the centered dialog as before. No drag handle: nothing is dragged. No confirmation is added: moving an item to the trash stays one action, since the trash restores it. **First steps.** All items, with no item outside the trash and no search, shows first steps in place of "No items yet.": the vault made, marked done; add an item, opening the template picker; import, opening Settings at the import; pair a device, opening the pairing as "Vault ready" does (D198). A vault that cannot be written leaves out the item and the import. **Error boundary.** Around every screen, a render error locks the vault (the session's keys and plaintext, as on any lock) and clears the clipboard (D54) in the commit, before the browser paints its failure screen; the screens that held plaintext are already unmounted. The screen shows nothing of the vault: what happened, that what was saved stays on the device, and Reload. Errors outside a render (an event handler, a promise) are handled where they occur, as before. | | D251 | Keyboard and length slider | The last area of Phase 7b (D244); keys, like D197, work in every layout. **List.** With the focus on an item of the list, ↓ or J moves it to the next item and ↑ or K to the one before, with no modifier; past the last item it stays, and above the first it goes to the search. They move the focus only: Enter opens the item, so a key never shows an item the user did not choose. A row's Copy is reached with Tab, as before. **Search.** ↓ moves the focus to the first item shown; Esc clears a non-empty search, the same in every browser (Firefox leaves `type=search` as typed), and keeps the focus in it; an empty search ignores Esc. The search's hint says both. **Editor.** Ctrl/⌘ + Enter submits the editor from any of its fields, the notes included, where Enter alone is a new line; never while the discard dialog is open or a save runs. The save goes through the same validation as Save. **Declared.** `aria-keyshortcuts` names the keys that activate or focus a control: Save carries Ctrl/⌘ + Enter and the search keeps Ctrl/⌘ + K. The movement keys are not declared on each row, where a screen reader would read them on every item; the search's hint says them instead. **Generator.** A range slider beside the length field, from 12 to 128 (D128), named as the field; either sets the same length, and the field keeps typed values the slider cannot show. | | D252 | Settings pages | A Phase 7b area (D244), after the v0.7.0 hands-on test found Settings one long page of every section. **Index.** Settings opens on an index of rows in groups: Preferences (Appearance, Language, Locking), Security (Local password, Passkey unlock, Recovery Phrase), Devices (Devices), Data (Backup, Merge a backup, Import, This device), Danger zone (Re-found the vault, Erase from this device, in the danger color) and, untitled and last, About. Each row has its icon, copied from Lucide like the others, and a chevron; six rows show their current value where it helps: the theme, the language, the auto-lock minutes, passkey unlock on or off, the date of the last backup made on this device or Never, and the count of listed devices. **Pages.** A row opens its page over the index, with the page's name as its heading and only that section; Back, the header's or the system's, returns to the index, and from there to where Settings was opened. Every layout drills down the same way, the page filling the frame as Settings did. A page opened from a row puts the cursor on its heading. **Shortcuts.** What opened Settings at a section (the backup reminder, "Vault ready", First steps, the clock notice, a live sync) now opens that section's page over the index, with the cursor where it went before; "Make a backup now" after a merge, a regeneration or before an erase opens the Backup page. No setting, check, warning or step changes. Amends D192 and D196. | +| D253 | Wizards | A Phase 7b area (D244), after the v0.7.0 hands-on test found the pairing and sync screens unclear about where the user stands and what the other device expects. **Wizard.** A flow of several steps shows one step at a time under "Step N of M · name" and a bar of M segments, then the step's content, then, in a fixed box headed "On the other device", what to do there meanwhile, then Cancel while there is something to cancel. There is no Back between steps: a failure says why and offers "Start again", which returns to step 1. Waiting states (preparing, connecting, sending, merging) are not steps: each shows within the step it belongs to. **Device flows.** Pair, on the device that holds the vault: Confirm it is you, Exchange the codes, Compare the numbers, Done. Join, on the device that gets it: Create the local password, Read the other device's code, Show the answer, Compare the numbers, Done. Re-admission: Choose who shows first, Exchange the codes, Compare the numbers, Done. Sync: Choose who shows first, Exchange the codes, Syncing, Done. The choice of who shows first stays manual on both devices (D207, D216), and its step says that the other device chooses the other option. "Same network only" and the STUN list move into "Advanced options", closed on step 1 unless the stored list cannot be read, which forces the option (D209). Leaving the page still ends the flow, except a live sync (D225). **Other flows.** The same wizard runs create (Phrase, Confirm, Password), restore (File, Secret, Password), reset the password (Phrase, New password), regenerate the phrase (Warning, Phrase, Confirm, Authorize, Done), re-found (Warning, Authorize, Phrase, Confirm, New password), merge a backup (File, Secret, Confirm, Done) and import (File, the file's password when it has one, Preview, Done, the count set once the file is read). The start's choice between create, join and restore stays a menu, and Cancel returns to it. A destructive flow's warning is its step 1, with no summary added before it writes. No step, check, warning or confirmation is removed or reordered. Amends D203, D207, D216 and D234. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 7e734e3..9a89577 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -429,7 +429,7 @@ and the password generator. Then `docs/record-content.md` vectors, `packages/vau - [x] Keyboard: list movement, search to list, Esc, Ctrl/⌘+Enter; generator length slider (D251) - [x] Release v0.7.0 for another hands-on test - [x] Settings pages: an index in groups, each row opening its page (D252) -- [ ] A wizard for pairing, joining, re-admission and sync: one step at a time, what to do on the other device (D253) +- [x] A wizard for pairing, joining, re-admission and sync: one step at a time, what to do on the other device (D253) - [ ] The same wizard on the other step flows: create, restore, reset the password, regenerate the phrase, re-found, merge, import (D253) - [ ] Release v0.7.1 for another hands-on test