From 7d0f9827656b5457528f347679c4081b95f826c5 Mon Sep 17 00:00:00 2001 From: SudoThijn Date: Mon, 12 Oct 2026 10:37:51 +0200 Subject: [PATCH 1/9] feat(extension): add theme switching and the Settings tab's first section (ext-theme) Theme offers System default, Light and Dark, stored globally in `settings.global` so it also applies before an account is unlocked. The popup mirrors it in localStorage to paint the first frame in the right theme. The Settings tab lists its six sections; Appearance opens, the rest wait for ext-settings, which is trimmed to build on this change. --- browser-extension/ARCHITECTURE.md | 3 + browser-extension/entrypoints/popup/App.tsx | 2 + .../entrypoints/popup/components/Icon.tsx | 4 + .../entrypoints/popup/components/Select.tsx | 30 ++++++ .../popup/components/components.test.tsx | 13 +++ .../entrypoints/popup/hooks/useSettings.ts | 32 ++++++ .../entrypoints/popup/hooks/useTheme.ts | 18 ++++ browser-extension/entrypoints/popup/main.tsx | 2 + browser-extension/entrypoints/popup/popup.css | 11 +- .../entrypoints/popup/testing.ts | 8 +- .../entrypoints/popup/theme.test.ts | 46 ++++++++ browser-extension/entrypoints/popup/theme.ts | 39 +++++++ .../entrypoints/popup/views/Placeholder.tsx | 2 +- .../entrypoints/popup/views/Shell.test.tsx | 6 +- .../entrypoints/popup/views/Shell.tsx | 12 ++- .../popup/views/settings/Appearance.tsx | 27 +++++ .../popup/views/settings/Settings.test.tsx | 101 ++++++++++++++++++ .../popup/views/settings/Settings.tsx | 7 ++ .../popup/views/settings/SettingsIndex.tsx | 48 +++++++++ browser-extension/locales/en.yml | 12 +++ browser-extension/locales/nl.yml | 12 +++ browser-extension/openspec/README.md | 7 +- .../openspec/changes/ext-settings/design.md | 14 +-- .../openspec/changes/ext-settings/proposal.md | 17 +-- .../ext-settings/specs/settings/spec.md | 14 +-- .../openspec/changes/ext-settings/tasks.md | 14 +-- .../openspec/changes/ext-theme/design.md | 63 +++++++++++ .../openspec/changes/ext-theme/proposal.md | 47 ++++++++ .../changes/ext-theme/specs/theme/spec.md | 96 +++++++++++++++++ .../openspec/changes/ext-theme/tasks.md | 29 +++++ .../src/background/requests.test.ts | 22 ++++ browser-extension/src/background/requests.ts | 7 ++ browser-extension/src/messages.ts | 13 +++ browser-extension/src/settings/global.test.ts | 27 +++++ browser-extension/src/settings/global.ts | 22 ++++ docs/browser-extension/privacy.md | 3 + docs/browser-extension/using.md | 6 +- 37 files changed, 793 insertions(+), 43 deletions(-) create mode 100644 browser-extension/entrypoints/popup/components/Select.tsx create mode 100644 browser-extension/entrypoints/popup/hooks/useSettings.ts create mode 100644 browser-extension/entrypoints/popup/hooks/useTheme.ts create mode 100644 browser-extension/entrypoints/popup/theme.test.ts create mode 100644 browser-extension/entrypoints/popup/theme.ts create mode 100644 browser-extension/entrypoints/popup/views/settings/Appearance.tsx create mode 100644 browser-extension/entrypoints/popup/views/settings/Settings.test.tsx create mode 100644 browser-extension/entrypoints/popup/views/settings/Settings.tsx create mode 100644 browser-extension/entrypoints/popup/views/settings/SettingsIndex.tsx create mode 100644 browser-extension/openspec/changes/ext-theme/design.md create mode 100644 browser-extension/openspec/changes/ext-theme/proposal.md create mode 100644 browser-extension/openspec/changes/ext-theme/specs/theme/spec.md create mode 100644 browser-extension/openspec/changes/ext-theme/tasks.md create mode 100644 browser-extension/src/settings/global.test.ts create mode 100644 browser-extension/src/settings/global.ts diff --git a/browser-extension/ARCHITECTURE.md b/browser-extension/ARCHITECTURE.md index d6db4be78..5b2e9cec1 100644 --- a/browser-extension/ARCHITECTURE.md +++ b/browser-extension/ARCHITECTURE.md @@ -20,6 +20,7 @@ How the extension is put together. Decisions live in the ADRs under [openspec/ar - `settings.ts`: the per-account timeout settings and the policy clamp. - `verify.ts`: checks credentials before an account is stored. - `normalize-server-url.ts`: turns whatever was typed into a server URL. +- `src/settings/global.ts`: settings that apply before an account is chosen, today the theme. - `src/vault/`: - `key-store.ts`: where the unlocked key lives ([ADR-002](openspec/architecture/adr-002-key-lifetime-and-vault-cache.md)). - `unlock.ts`: unlock, lock and the suite checks. @@ -63,6 +64,8 @@ All types are in `src/messages.ts`. The background only answers extension pages; | `accounts` | local | Account records, app passwords included | | `activeAccountId` | local | The active account; heals itself when it points nowhere | | `settings.` | local | Vault timeout and action | +| `settings.global` | local | The theme; survives logout | +| `keepiq.theme` | popup `localStorage` | A mirror of the theme for the first frame, never read by the background | | `suite.` | local | The active suite row, private key still wrapped | | `vaultCache.` | local | The snapshot: secret rows, folders, types, suite id and epoch, counts | | `vaultCheckedAt.` | local | When the server was last checked | diff --git a/browser-extension/entrypoints/popup/App.tsx b/browser-extension/entrypoints/popup/App.tsx index f22797344..5831cebb7 100644 --- a/browser-extension/entrypoints/popup/App.tsx +++ b/browser-extension/entrypoints/popup/App.tsx @@ -5,6 +5,7 @@ import { Header } from './components/Header' import { Loading } from './components/Loading' import { useBackgroundMessage } from './hooks/useBackgroundMessage' import { usePopupState } from './hooks/usePopupState' +import { useTheme } from './hooks/useTheme' import { AccountSwitcher } from './views/AccountSwitcher' import { AddAccount } from './views/AddAccount' import { LogInAgain } from './views/LogInAgain' @@ -16,6 +17,7 @@ type Panel = 'switcher' | 'add_account' | null export default function App() { const { state, error, dispatch } = usePopupState() + useTheme() const [panel, setPanel] = useState(null) const refreshState = useCallback(() => void dispatch({ kind: 'vault.status', passive: true }), [dispatch]) // A lock from the timeout or another window swaps this popup to the unlock view. diff --git a/browser-extension/entrypoints/popup/components/Icon.tsx b/browser-extension/entrypoints/popup/components/Icon.tsx index 1c78a8041..9332176ce 100644 --- a/browser-extension/entrypoints/popup/components/Icon.tsx +++ b/browser-extension/entrypoints/popup/components/Icon.tsx @@ -16,6 +16,10 @@ const PATHS = { search: <>, close: , chevronDown: , + chevronRight: , + bell: <>, + info: <>, + contrast: <>, folder: , refresh: <>, blocked: <>, diff --git a/browser-extension/entrypoints/popup/components/Select.tsx b/browser-extension/entrypoints/popup/components/Select.tsx new file mode 100644 index 000000000..6e30b1e7e --- /dev/null +++ b/browser-extension/entrypoints/popup/components/Select.tsx @@ -0,0 +1,30 @@ +import { useId } from 'react' +import { Icon } from './Icon' + +export interface SelectOption { + value: T + label: string +} + +interface Props { + label: string + value: T + options: SelectOption[] + onChange: (value: T) => void + disabled?: boolean +} + +export function Select({ label, value, options, onChange, disabled }: Props) { + const id = useId() + return ( +
+ +
+ + +
+
+ ) +} diff --git a/browser-extension/entrypoints/popup/components/components.test.tsx b/browser-extension/entrypoints/popup/components/components.test.tsx index 7bf803aeb..e38117732 100644 --- a/browser-extension/entrypoints/popup/components/components.test.tsx +++ b/browser-extension/entrypoints/popup/components/components.test.tsx @@ -9,6 +9,7 @@ import { Avatar, initials } from './Avatar' import { Button } from './Button' import { Header } from './Header' import { MaskedField } from './MaskedField' +import { Select } from './Select' import { TabBar } from './TabBar' import { Toast } from './Toast' import { ShellContext } from '../shell-context' @@ -92,6 +93,18 @@ describe('TabBar', () => { }) }) +describe('Select', () => { + it('is named by its label and reports the chosen value', async () => { + const onChange = vi.fn() + const options = [{ value: 'a', label: 'First' }, { value: 'b', label: 'Second' }] + render(