From 295e543b37726ca6c5b15d2b0e91052684db6be0 Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Thu, 12 Nov 2026 18:51:34 -0300 Subject: [PATCH 1/2] feat(web): the Preview badge, a one-line storage warning, the backup reminder in the list and a neutral lock badge (D254) Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/erase.spec.ts | 3 +- apps/web/e2e/refounding.spec.ts | 4 +- apps/web/e2e/smoke.spec.ts | 6 +- apps/web/e2e/storage-state.spec.ts | 9 +- apps/web/e2e/vault.spec.ts | 9 +- apps/web/src/i18n/messages.ts | 6 ++ apps/web/src/ui/ItemList.tsx | 6 +- apps/web/src/ui/VaultShell.tsx | 80 ++++++++++------- apps/web/src/ui/components.tsx | 135 +++++++++++++++++++++++++---- apps/web/src/ui/icons/index.tsx | 19 ++++ docs/roadmap.md | 2 +- 11 files changed, 220 insertions(+), 59 deletions(-) diff --git a/apps/web/e2e/erase.spec.ts b/apps/web/e2e/erase.spec.ts index 960d34f..a419f29 100644 --- a/apps/web/e2e/erase.spec.ts +++ b/apps/web/e2e/erase.spec.ts @@ -61,7 +61,8 @@ test('erases the unlocked vault from Settings, saying it is the only device (D11 await startScreen(page); await expect(page.getByTestId('erased')).toContainText('The vault was erased from this device.'); - await expect(page.getByTestId('lock-state')).toHaveText('Locked'); + // No vault left, so no lock state (D254). + await expect(page.getByTestId('lock-state')).toHaveCount(0); expect(await databases(page)).toEqual([]); // Not kept anywhere: a reload shows the start screen alone. diff --git a/apps/web/e2e/refounding.spec.ts b/apps/web/e2e/refounding.spec.ts index 93c35bf..9e8fcb0 100644 --- a/apps/web/e2e/refounding.spec.ts +++ b/apps/web/e2e/refounding.spec.ts @@ -106,8 +106,6 @@ 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); @@ -126,6 +124,8 @@ test('re-founds the vault: warnings, proof, new phrase and password, and only th await button(page, 'Back').click(); await button(page, 'Back').click(); await expect(button(page, 'ACME')).toBeVisible(); + // The new vault also shows the backup reminder, at the top of the list (D188, D254). + await expect(page.getByTestId('backup-reminder')).toBeVisible(); await expect(button(page, 'Discarded')).toHaveCount(0); await button(page, 'Trash').click(); await expect(button(page, 'Discarded')).toHaveCount(0); diff --git a/apps/web/e2e/smoke.spec.ts b/apps/web/e2e/smoke.spec.ts index 93834bf..b472a32 100644 --- a/apps/web/e2e/smoke.spec.ts +++ b/apps/web/e2e/smoke.spec.ts @@ -1,10 +1,12 @@ import { expect, test } from '@playwright/test'; import { startScreen } from './create'; -test('renders the first screen, locked, with its version', async ({ page }) => { +test('renders the first screen, with no lock state before a vault exists, and its version', async ({ + page, +}) => { await page.goto('/'); await startScreen(page); await expect(page.getByRole('button', { name: 'Restore from a backup' })).toBeVisible(); - await expect(page.getByTestId('lock-state')).toHaveText('Locked'); + await expect(page.getByTestId('lock-state')).toHaveCount(0); await expect(page.getByText(/^v\d+\.\d+\.\d+$/)).toBeVisible(); }); diff --git a/apps/web/e2e/storage-state.spec.ts b/apps/web/e2e/storage-state.spec.ts index 1ad228a..93c813a 100644 --- a/apps/web/e2e/storage-state.spec.ts +++ b/apps/web/e2e/storage-state.spec.ts @@ -108,8 +108,13 @@ test('warns while storage is not kept on the only device, in every layout, until await page.setViewportSize({ width, height: 800 }); await expect(banner).toHaveAttribute('data-warning', 'not-kept'); await expect(banner).toContainText('this is the only device of the vault'); - // Nothing dismisses it. - await expect(banner.getByRole('button')).toHaveCount(0); + // Nothing dismisses it: its one button shows the whole text (D254). + const details = banner.getByRole('button'); + await expect(details).toHaveText('Details'); + await details.click(); + await expect(details).toHaveText('Less'); + await expect(details).toHaveAttribute('aria-expanded', 'true'); + await details.click(); } await openSettings(page); diff --git a/apps/web/e2e/vault.spec.ts b/apps/web/e2e/vault.spec.ts index f037478..751542c 100644 --- a/apps/web/e2e/vault.spec.ts +++ b/apps/web/e2e/vault.spec.ts @@ -36,6 +36,10 @@ test('refuses a weak or mismatched password before creating (D71)', async ({ pag await page.getByTestId('confirm-password').fill(`${PASSWORD}x`); await expect(page.getByText('The passwords do not match.')).toBeVisible(); await expect(submit).toBeDisabled(); + // A badge beside the app's name opens the whole text (D254). + await expect(page.getByTestId('preview-banner')).toBeHidden(); + await page.getByTestId('preview-badge').click(); + await expect(page.getByTestId('preview-badge')).toHaveAttribute('aria-expanded', 'true'); await expect(page.getByTestId('preview-banner')).toHaveText( 'Preview build: vaults and backups made here may not open in a later version, and no migration is planned for them. Do not keep anything here you cannot lose.', ); @@ -45,7 +49,7 @@ test('creates, locks, refuses a wrong password and unlocks, keeping the URL and page, }) => { await create(page); - await expect(page.getByTestId('preview-banner')).toBeVisible(); + await expect(page.getByTestId('preview-badge')).toHaveText('Preview'); const before = await page.evaluate(() => window.history.length); await page.getByRole('button', { name: 'Settings' }).first().click(); await page.getByRole('button', { name: 'Back', exact: true }).click(); @@ -193,6 +197,7 @@ test.describe('in Portuguese', () => { await expect(create.or(advice)).toBeVisible(); if (await advice.isVisible()) await advice.click(); await expect(create).toBeVisible(); - await expect(page.getByTestId('lock-state')).toHaveText('Bloqueado'); + // No vault yet, so no lock state; the preview badge is translated (D254). + await expect(page.getByTestId('preview-badge')).toHaveText('Prévia'); }); }); diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index 123f47e..62d5e31 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -43,6 +43,7 @@ const en = { continue: 'I verified, continue', }, preview: { + badge: 'Preview', banner: 'Preview build: vaults and backups made here may not open in a later version, and no migration is planned for them. Do not keep anything here you cannot lose.', }, @@ -51,6 +52,8 @@ const en = { 'This copy of BlindVault runs under a sub-path of a github.io address, which other sites share: they can read and delete its storage. Do not keep anything here; use a copy served from its own address.', }, storage: { + details: 'Details', + less: 'Less', title: 'This device', kept: 'The browser keeps this vault’s storage, even when space runs low.', notKept: @@ -1197,6 +1200,7 @@ const ptBR: Messages = { continue: 'Verifiquei, continuar', }, preview: { + badge: 'Prévia', banner: 'Versão de prévia: cofres e backups feitos aqui podem não abrir numa versão futura, e não há migração prevista para eles. Não guarde aqui nada que você não possa perder.', }, @@ -1205,6 +1209,8 @@ const ptBR: Messages = { 'Esta cópia do BlindVault roda num subcaminho de um endereço github.io, que outros sites compartilham: eles podem ler e apagar os dados dela. Não guarde nada aqui; use uma cópia servida no próprio endereço.', }, storage: { + details: 'Detalhes', + less: 'Menos', title: 'Este dispositivo', kept: 'O navegador mantém o armazenamento deste cofre, mesmo quando falta espaço.', notKept: diff --git a/apps/web/src/ui/ItemList.tsx b/apps/web/src/ui/ItemList.tsx index 084a27b..cddbcd9 100644 --- a/apps/web/src/ui/ItemList.tsx +++ b/apps/web/src/ui/ItemList.tsx @@ -1,5 +1,5 @@ import { type Item, type TreeItem, type VaultSession } from '@blindvault/vault'; -import { type RefObject, useRef, useState, useSyncExternalStore } from 'react'; +import { type ReactNode, type RefObject, useRef, useState, useSyncExternalStore } from 'react'; import { findNode, treeItems } from '../app/folders'; import { hex } from '../app/ids'; @@ -40,6 +40,7 @@ export function ItemList({ query, onQuery, searchRef, + notice, }: { readonly session: VaultSession; readonly nav: Navigation; @@ -48,6 +49,8 @@ export function ItemList({ readonly query: string; readonly onQuery: (query: string) => void; readonly searchRef: RefObject; + /** Shown below the list's head, such as the backup reminder (D254). */ + readonly notice?: ReactNode; }) { const { messages: m, language } = useLanguage(); const layout = useLayout(); @@ -207,6 +210,7 @@ export function ItemList({ ) : null} + {notice} {list.name === 'folder' && !searching ? ( +
{back ? ( ) : null} - +
); @@ -284,8 +293,40 @@ export function VaultShell({ window.removeEventListener('keydown', keys); }; }, [controller, nav, session]); + // At the top of the item list, with its two actions (§13.4, D188, D254). + const backupReminder = reminder ? ( +
+

{reminderText(m, language, reminder)}

+ + +
+ ) : null; const list = (
- {clock ? ( @@ -488,30 +528,6 @@ export function VaultShell({
) : null} - {reminder ? ( -
- {reminderText(m, language, reminder)} - - -
- ) : null} {controller.leftoverPasskey ? (
-
+ + {unlocked ? m.app.unlocked : m.app.locked} ); } -/** Vaults and backups of the preview build are throwaway, with no migration owed (D177). */ -export function PreviewBanner() { +/** + * Vaults and backups of the preview build are throwaway, with no migration owed (D177): a badge + * beside the app's name whose popover holds the whole text (D254). The browser closes it on Escape + * or a click outside. + */ +export function PreviewBadge() { const m = useMessages(); + const [open, setOpen] = useState(false); + const id = useId(); + const button = useRef(null); + const popover = useRef(null); + useEffect(() => { + const pop = popover.current; + if (!pop) return; + // Placed under its button each time it opens, kept inside the viewport. + const place = (e: Event) => { + const at = button.current?.getBoundingClientRect(); + if (!at || (e as ToggleEvent).newState !== 'open') return; + const width = Math.min(320, document.documentElement.clientWidth - 32); + const left = Math.max( + 16, + Math.min(at.left, document.documentElement.clientWidth - 16 - width), + ); + pop.style.top = `${String(at.bottom + 4)}px`; + pop.style.left = `${String(left)}px`; + pop.style.width = `${String(width)}px`; + }; + const toggle = (e: Event) => { + setOpen((e as ToggleEvent).newState === 'open'); + }; + pop.addEventListener('beforetoggle', place); + pop.addEventListener('toggle', toggle); + return () => { + pop.removeEventListener('beforetoggle', place); + pop.removeEventListener('toggle', toggle); + }; + }, []); return ( - - {m.preview.banner} - + <> + +
+ {m.preview.banner} +
+ ); } @@ -440,7 +512,10 @@ export function SharedOriginWarning() { ); } -/** A storage warning: none can be dismissed, each goes once its condition ends (D196). */ +/** + * A storage warning: none can be dismissed, each goes once its condition ends (D196). One line with + * its icon; "Details" shows the whole text (D254). + */ export function StorageAlert({ warning, text, @@ -448,9 +523,32 @@ export function StorageAlert({ readonly warning: StorageWarning; readonly text: string; }) { + const m = useMessages(); + const [open, setOpen] = useState(false); + const id = useId(); return ( - - {text} + + +

+ {text} +

+
); } @@ -486,6 +584,7 @@ export function Card({ readonly children: ReactNode; }) { const m = useMessages(); + const held = useContext(VaultHeld); // Before unlock is where verifying the build matters most (D192). const [about, setAbout] = useAbout(); const updates = useUpdates(); @@ -497,13 +596,17 @@ export function Card({
-
-
- {m.app.name} - + {/* The app's name once (D254), and no lock state before a vault exists. */} +
+ {title === m.app.name ? null : ( + {m.app.name} + )} + + + {held === 'yes' ? : null}

{about ? m.about.title : title}

{about ? ( diff --git a/apps/web/src/ui/icons/index.tsx b/apps/web/src/ui/icons/index.tsx index 7c7ccbd..d1d7bd1 100644 --- a/apps/web/src/ui/icons/index.tsx +++ b/apps/web/src/ui/icons/index.tsx @@ -168,6 +168,12 @@ const PATHS = { ), + 'lock-open': ( + <> + + + + ), 'monitor-smartphone': ( <> @@ -245,6 +251,13 @@ const PATHS = { ), + 'triangle-alert': ( + <> + + + + + ), upload: ( <> @@ -258,6 +271,12 @@ const PATHS = { ), + x: ( + <> + + + + ), } satisfies Record; export type IconName = keyof typeof PATHS; diff --git a/docs/roadmap.md b/docs/roadmap.md index d134e9b..2e41df1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -433,7 +433,7 @@ and the password generator. Then `docs/record-content.md` vectors, `packages/vau - [x] The same wizard on the other step flows: create, restore, reset the password, regenerate the phrase, re-found, merge, import (D253) - [x] Release v0.7.1 for another hands-on test - [x] Capture every v0.7.1 screen and approve an HTML prototype of the layout and presentation changes (`docs/prototypes/phase-7b-layout.html`, D254) -- [ ] Notices: the Preview badge, the storage warning on one line with Details, the backup reminder at the top of the list, a neutral lock badge (D254) +- [x] Notices: the Preview badge, the storage warning on one line with Details, the backup reminder at the top of the list, a neutral lock badge (D254) - [ ] Lists: folders and types as pills opening a bottom sheet on a phone, the toast above the bottom navigation (D254) - [ ] Item detail and editor: the header bar, divided field rows, icon buttons on non-secret fields, the fixed Cancel and Save bar, the generator in a dialog or bottom sheet (D254) - [ ] Show and Hide on every password field, masked again on submit, on a screen change and on lock (D254) From 2f638d0ae6aba5071160facbfa4c9b20b519a4fe Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Thu, 12 Nov 2026 19:15:34 -0300 Subject: [PATCH 2/2] test(e2e): the Preview badge in the phrase screen and the screen-reader snapshots (D254) Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/recovery-phrase.spec.ts | 9 +++++++-- apps/web/e2e/screen-reader.spec.ts | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/web/e2e/recovery-phrase.spec.ts b/apps/web/e2e/recovery-phrase.spec.ts index 6416a95..1714cb7 100644 --- a/apps/web/e2e/recovery-phrase.spec.ts +++ b/apps/web/e2e/recovery-phrase.spec.ts @@ -76,8 +76,13 @@ test('shows the phrase once, asks for three of its words, and shows it again on { exact: true }, ), ).toBeVisible(); - // No copy or download control on the phrase screen: only the wizard's Cancel (D253). - await expect(page.getByRole('button')).toHaveText(['I wrote them down', 'Cancel']); + // No copy or download control on the phrase screen: only the Preview badge and the wizard's + // Cancel (D253, D254). + await expect(page.getByRole('main').getByRole('button')).toHaveText([ + 'Preview', + 'I wrote them down', + 'Cancel', + ]); await expect(page.getByRole('link')).toHaveCount(0); await button(page, 'I wrote them down').click(); diff --git a/apps/web/e2e/screen-reader.spec.ts b/apps/web/e2e/screen-reader.spec.ts index 315c9b7..7854356 100644 --- a/apps/web/e2e/screen-reader.spec.ts +++ b/apps/web/e2e/screen-reader.spec.ts @@ -34,7 +34,7 @@ for (const [layout, width] of WIDTHS) { await startScreen(page); await expect(main).toMatchAriaSnapshot(` - - text: BlindVault Locked + - button "Preview" - heading "BlindVault" [level=1] - list: - listitem: /no account and no server/ @@ -186,7 +186,9 @@ for (const [layout, width] of WIDTHS) { await page.keyboard.press('Enter'); await expect(page.getByText('Wrong password.')).toBeVisible({ timeout: 30_000 }); await expect(main).toMatchAriaSnapshot(` - - text: BlindVault Locked + - text: BlindVault + - button "Preview" + - text: Locked - heading "Unlock the vault" [level=1] - textbox "Local password" [invalid] - alert: Wrong password.