From eb443c38b481dc1560762afe0761d5cbf6082a2b Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 1 May 2026 16:25:33 -0400 Subject: [PATCH] docs(plans): recovery QR + entropy floor; password coloring Two implementation plans, one per spec landed in 00da7e7. Each plan decomposes its spec into bite-sized TDD tasks with exact file paths, complete code, and per-task commits. - recovery-qr-and-entropy-floor.md (15 tasks, 6 phases): core crypto module + wasm bindings + CLI subcommands (imgsecret embed, recovery-qr generate/unlock, --force-weak-passphrase) + extension popup window with canvas QR + vault-tab button + unlock-flow recovery link + zxcvbn>=3 hard gate at init (CLI + setup wizard) + soft warning at unlock for grandfathered weak vaults. - password-coloring.md (9 tasks, 6 phases): pure colorizePassword() utility + chrome.storage.sync round-trip + applyColorScheme() boot step + four reveal-surface integrations (field history, popup item detail, fullscreen item detail, generator preview) + settings UI with color pickers and live-preview swatch. Task 6 (fullscreen) flagged for coordination with in-flight Phase 1 UX work. Both plans follow the subagent-driven execution preference per feedback_subagent_default. Co-Authored-By: Claude Opus 4.7 --- .../plans/2026-05-01-password-coloring.md | 804 ++++++++ ...026-05-01-recovery-qr-and-entropy-floor.md | 1791 +++++++++++++++++ 2 files changed, 2595 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-password-coloring.md create mode 100644 docs/superpowers/plans/2026-05-01-recovery-qr-and-entropy-floor.md diff --git a/docs/superpowers/plans/2026-05-01-password-coloring.md b/docs/superpowers/plans/2026-05-01-password-coloring.md new file mode 100644 index 0000000..3402bfc --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-password-coloring.md @@ -0,0 +1,804 @@ +# Password Display Character-Class Coloring — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Color revealed passwords in the extension UI by character class (digits, symbols, letters), defaulting to digits-blue / symbols-red / letters-inherit, with user-configurable colors persisted in `chrome.storage.sync`. + +**Architecture:** A single pure utility `colorizePassword(text)` that returns a `DocumentFragment` of class-named `` runs. CSS rules in the existing extension stylesheet(s) bind those classes to CSS custom properties (`--relicario-pwd-digit-color`, `--relicario-pwd-symbol-color`). User overrides are stored in `chrome.storage.sync` and applied on popup/vault startup by setting the custom properties on `document.documentElement`. All four password-revealing surfaces (popup field-history viewer, popup item detail, fullscreen item detail, generator preview) call the same utility. + +**Tech Stack:** TypeScript, Vitest with JSDOM for unit tests, existing `chrome.storage.sync` plumbing in the extension, existing settings UI patterns in `extension/src/popup/components/settings*.ts`. + +**Spec:** `docs/superpowers/specs/2026-05-01-password-coloring-design.md` + +--- + +## File Structure + +### Created + +- `extension/src/shared/password-coloring.ts` — pure `colorizePassword()` utility + class-name constants. +- `extension/src/shared/__tests__/password-coloring.test.ts` — Vitest unit tests for the utility. +- `extension/src/shared/color-scheme.ts` — read/write/apply helpers for the user's stored color scheme. +- `extension/src/shared/__tests__/color-scheme.test.ts` — Vitest unit tests for storage round-trip + apply. + +(If `extension/src/shared/` does not exist, create it. Otherwise place under whatever the extension's existing shared/utility directory is — match the established convention.) + +### Modified + +- The popup stylesheet (`extension/src/popup/styles.css` and any vault stylesheet): add `:root` defaults + `.pwd-digit/.pwd-symbol/.pwd-letter` rules. +- `extension/src/popup/components/field-history.ts:72` — replace text-content assignment with `colorizePassword()` fragment. +- The popup's vault item detail component (find via `grep -n "password.*reveal\|passwordCell" extension/src/popup/`). +- `extension/src/vault/` item-detail component — same change, fullscreen surface. +- The generator preview component — same change. +- The popup's bootstrap (`extension/src/popup/popup.ts` or `index.ts`) — call `applyColorScheme()` once at startup. +- The vault's bootstrap (`extension/src/vault/vault.ts`) — same `applyColorScheme()` call. +- A settings page component — add the Display section with two color pickers, preview swatch, reset button. + +--- + +## Phase A — Core utility + +### Task 1: `colorizePassword()` pure utility + +**Files:** +- Create: `extension/src/shared/password-coloring.ts` +- Create: `extension/src/shared/__tests__/password-coloring.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`extension/src/shared/__tests__/password-coloring.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { colorizePassword, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER } from '../password-coloring'; + +describe('colorizePassword', () => { + beforeEach(() => { + const dom = new JSDOM(''); + (global as any).document = dom.window.document; + }); + + function classes(frag: DocumentFragment): string[] { + return Array.from(frag.querySelectorAll('span')).map(s => s.className); + } + function texts(frag: DocumentFragment): string[] { + return Array.from(frag.querySelectorAll('span')).map(s => s.textContent ?? ''); + } + + it('returns empty fragment for empty input', () => { + const frag = colorizePassword(''); + expect(frag.childNodes.length).toBe(0); + }); + + it('classifies a mixed-class run', () => { + const frag = colorizePassword('aB3$xY'); + expect(classes(frag)).toEqual([PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER]); + expect(texts(frag)).toEqual(['aB', '3', '$', 'xY']); + }); + + it('all-letters produces a single letter span', () => { + const frag = colorizePassword('passwd'); + expect(classes(frag)).toEqual([PWD_LETTER]); + expect(texts(frag)).toEqual(['passwd']); + }); + + it('all-digits produces a single digit span', () => { + const frag = colorizePassword('123456'); + expect(classes(frag)).toEqual([PWD_DIGIT]); + expect(texts(frag)).toEqual(['123456']); + }); + + it('all-symbols produces a single symbol span', () => { + const frag = colorizePassword('!@#$%^'); + expect(classes(frag)).toEqual([PWD_SYMBOL]); + expect(texts(frag)).toEqual(['!@#$%^']); + }); + + it('classifies unicode letters as letters', () => { + const frag = colorizePassword('áñü'); + expect(classes(frag)).toEqual([PWD_LETTER]); + }); + + it('classifies whitespace as symbol', () => { + const frag = colorizePassword('a b'); + expect(classes(frag)).toEqual([PWD_LETTER, PWD_SYMBOL, PWD_LETTER]); + expect(texts(frag)).toEqual(['a', ' ', 'b']); + }); + + it('representative password snapshot: aB3$xY7&_!', () => { + const frag = colorizePassword('aB3$xY7&_!'); + expect(classes(frag)).toEqual([ + PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, PWD_LETTER, PWD_DIGIT, PWD_SYMBOL, + ]); + expect(texts(frag)).toEqual(['aB', '3', '$', 'xY', '7', '&_!']); + }); +}); +``` + +- [ ] **Step 2: Run — expect compile failure (module missing)** + +``` +cd extension && npm run test -- password-coloring +``` + +Expected: `Cannot find module '../password-coloring'`. + +- [ ] **Step 3: Implement the utility** + +`extension/src/shared/password-coloring.ts`: + +```ts +export const PWD_DIGIT = 'pwd-digit'; +export const PWD_SYMBOL = 'pwd-symbol'; +export const PWD_LETTER = 'pwd-letter'; + +type Class = typeof PWD_DIGIT | typeof PWD_SYMBOL | typeof PWD_LETTER; + +function classify(ch: string): Class { + if (/^\d$/.test(ch)) return PWD_DIGIT; + if (/^\p{L}$/u.test(ch)) return PWD_LETTER; + return PWD_SYMBOL; +} + +/** + * Split `text` into runs of same-class codepoints and return a DocumentFragment + * of class-named nodes (one span per run). Returns an empty fragment + * for empty input. + * + * Pure: does not mutate any DOM outside the returned fragment, does not perform + * I/O. Safe to call on every render. + */ +export function colorizePassword(text: string): DocumentFragment { + const frag = document.createDocumentFragment(); + if (text.length === 0) return frag; + + // Iterate by codepoint so unicode letters classify correctly. + const codepoints = Array.from(text); + let runStart = 0; + let runClass = classify(codepoints[0]); + + for (let i = 1; i <= codepoints.length; i++) { + const c = i < codepoints.length ? classify(codepoints[i]) : null; + if (c !== runClass) { + const span = document.createElement('span'); + span.className = runClass; + span.textContent = codepoints.slice(runStart, i).join(''); + frag.appendChild(span); + if (c !== null) { + runStart = i; + runClass = c; + } + } + } + return frag; +} +``` + +- [ ] **Step 4: Run — expect pass** + +``` +cd extension && npm run test -- password-coloring +``` + +Expected: all 8 PASS. + +- [ ] **Step 5: Commit** + +``` +git add extension/src/shared/password-coloring.ts extension/src/shared/__tests__/password-coloring.test.ts +git commit -m "feat(ext/shared): add colorizePassword utility" +``` + +--- + +## Phase B — Color scheme storage + apply + +### Task 2: `applyColorScheme()` + storage round-trip + +**Files:** +- Create: `extension/src/shared/color-scheme.ts` +- Create: `extension/src/shared/__tests__/color-scheme.test.ts` + +- [ ] **Step 1: Write the failing tests** + +`extension/src/shared/__tests__/color-scheme.test.ts`: + +```ts +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { JSDOM } from 'jsdom'; +import { + loadColorScheme, saveColorScheme, resetColorScheme, applyColorScheme, + DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, +} from '../color-scheme'; + +function mockChromeStorage(initial: any = {}) { + const store = { ...initial }; + (global as any).chrome = { + storage: { + sync: { + get: vi.fn((key: string) => Promise.resolve( + key in store ? { [key]: store[key] } : {})), + set: vi.fn((kv: any) => { Object.assign(store, kv); return Promise.resolve(); }), + remove: vi.fn((key: string) => { delete store[key]; return Promise.resolve(); }), + }, + }, + }; + return store; +} + +describe('color-scheme storage', () => { + beforeEach(() => { + const dom = new JSDOM(''); + (global as any).document = dom.window.document; + }); + + it('load returns defaults when storage is empty', async () => { + mockChromeStorage(); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR); + expect(scheme.symbol_color).toBe(DEFAULT_SYMBOL_COLOR); + }); + + it('load returns stored values when present', async () => { + mockChromeStorage({ + password_display_scheme: { digit_color: '#123456', symbol_color: '#abcdef' }, + }); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe('#123456'); + expect(scheme.symbol_color).toBe('#abcdef'); + }); + + it('save round-trips', async () => { + mockChromeStorage(); + await saveColorScheme({ digit_color: '#111111', symbol_color: '#222222' }); + const scheme = await loadColorScheme(); + expect(scheme).toEqual({ digit_color: '#111111', symbol_color: '#222222' }); + }); + + it('reset removes the storage key', async () => { + const store = mockChromeStorage({ + password_display_scheme: { digit_color: '#000', symbol_color: '#fff' }, + }); + await resetColorScheme(); + expect(store.password_display_scheme).toBeUndefined(); + const scheme = await loadColorScheme(); + expect(scheme.digit_color).toBe(DEFAULT_DIGIT_COLOR); + }); + + it('apply sets CSS custom properties on document.documentElement', async () => { + mockChromeStorage({ + password_display_scheme: { digit_color: '#deadbe', symbol_color: '#feed00' }, + }); + await applyColorScheme(); + const root = document.documentElement.style; + expect(root.getPropertyValue('--relicario-pwd-digit-color').trim()).toBe('#deadbe'); + expect(root.getPropertyValue('--relicario-pwd-symbol-color').trim()).toBe('#feed00'); + }); + + it('save rejects malformed hex values', async () => { + mockChromeStorage(); + await expect(saveColorScheme({ digit_color: 'not-a-color', symbol_color: '#ffffff' })) + .rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run — expect compile failure** + +``` +cd extension && npm run test -- color-scheme +``` + +Expected: missing module. + +- [ ] **Step 3: Implement** + +`extension/src/shared/color-scheme.ts`: + +```ts +export const DEFAULT_DIGIT_COLOR = '#2563eb'; +export const DEFAULT_SYMBOL_COLOR = '#dc2626'; +const STORAGE_KEY = 'password_display_scheme'; +const HEX_RE = /^#[0-9a-fA-F]{6}$/; + +export interface ColorScheme { + digit_color: string; + symbol_color: string; +} + +export const DEFAULT_SCHEME: ColorScheme = { + digit_color: DEFAULT_DIGIT_COLOR, + symbol_color: DEFAULT_SYMBOL_COLOR, +}; + +function isValid(s: ColorScheme): boolean { + return HEX_RE.test(s.digit_color) && HEX_RE.test(s.symbol_color); +} + +export async function loadColorScheme(): Promise { + const result = await chrome.storage.sync.get(STORAGE_KEY); + const stored = result[STORAGE_KEY] as Partial | undefined; + if (!stored) return { ...DEFAULT_SCHEME }; + const merged: ColorScheme = { + digit_color: typeof stored.digit_color === 'string' && HEX_RE.test(stored.digit_color) + ? stored.digit_color : DEFAULT_DIGIT_COLOR, + symbol_color: typeof stored.symbol_color === 'string' && HEX_RE.test(stored.symbol_color) + ? stored.symbol_color : DEFAULT_SYMBOL_COLOR, + }; + return merged; +} + +export async function saveColorScheme(scheme: ColorScheme): Promise { + if (!isValid(scheme)) { + throw new Error('Invalid color values; expected #rrggbb hex strings.'); + } + await chrome.storage.sync.set({ [STORAGE_KEY]: scheme }); +} + +export async function resetColorScheme(): Promise { + await chrome.storage.sync.remove(STORAGE_KEY); +} + +/** + * Read the user's stored scheme (or defaults) and apply the colors as inline + * CSS custom properties on `document.documentElement`. Idempotent — safe to + * call on every popup/vault boot, and from a chrome.storage.onChanged handler + * to react to live edits from another open extension surface. + */ +export async function applyColorScheme(): Promise { + const scheme = await loadColorScheme(); + const root = document.documentElement.style; + root.setProperty('--relicario-pwd-digit-color', scheme.digit_color); + root.setProperty('--relicario-pwd-symbol-color', scheme.symbol_color); +} +``` + +- [ ] **Step 4: Run — expect pass** + +``` +cd extension && npm run test -- color-scheme +``` + +Expected: 6 PASS. + +- [ ] **Step 5: Commit** + +``` +git add extension/src/shared/color-scheme.ts extension/src/shared/__tests__/color-scheme.test.ts +git commit -m "feat(ext/shared): color-scheme storage + applyColorScheme" +``` + +--- + +## Phase C — Stylesheet integration + +### Task 3: Add CSS rules + custom-property defaults + +**Files:** +- Modify: `extension/src/popup/styles.css` +- Modify: `extension/src/vault/vault.css` (and any other extension stylesheet that styles password reveal cells) + +- [ ] **Step 1: Add the rules** + +Append to each stylesheet (or to a single shared partial if the build supports CSS imports): + +```css +:root { + --relicario-pwd-digit-color: #2563eb; + --relicario-pwd-symbol-color: #dc2626; +} +.pwd-digit { color: var(--relicario-pwd-digit-color); } +.pwd-symbol { color: var(--relicario-pwd-symbol-color); } +.pwd-letter { color: inherit; } +``` + +- [ ] **Step 2: Build the extension** + +``` +cd extension && npm run build +``` + +Expected: clean build, no CSS errors. + +- [ ] **Step 3: Commit** + +``` +git add extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "style(ext): add password-coloring CSS rules + custom property defaults" +``` + +--- + +## Phase D — Wire into reveal surfaces + +### Task 4: Field-history viewer + +**Files:** +- Modify: `extension/src/popup/components/field-history.ts` + +- [ ] **Step 1: Locate the text-content assignment** + +``` +grep -n "history-entry__value\|displayValue" extension/src/popup/components/field-history.ts +``` + +The line near 72 reads roughly: + +```ts +
${displayValue}
+``` + +This is template-string interpolation, so `displayValue` is escaped HTML. The change requires switching from a string-template render to an imperative DOM patch (since `colorizePassword()` returns DOM, not HTML strings). + +- [ ] **Step 2: Update the render to imperatively set content** + +After the template renders the entry's outer markup, query the `.history-entry__value` element for revealed entries and replace its `textContent` with `colorizePassword(value)`: + +```ts +import { colorizePassword } from '../../shared/password-coloring'; + +// existing render ... + +container.querySelectorAll('.history-entry__value.revealed').forEach((el, idx) => { + el.textContent = ''; + el.appendChild(colorizePassword(revealedValues[idx])); +}); +``` + +(`revealedValues` here stands in for whatever array of revealed-entry values was already computed; adapt to actual variable names.) + +- [ ] **Step 3: Update or add a test for this surface** + +If `extension/src/popup/components/__tests__/field-history.test.ts` exists, add a case asserting that a revealed password's DOM contains `.pwd-*` spans. Otherwise just verify by running the existing test suite + a manual check. + +```ts +it('revealed entry colorizes by character class', () => { + const dom = render(/* item with password "aB3$" in field history, revealed */); + const revealed = dom.querySelector('.history-entry__value.revealed')!; + expect(revealed.querySelector('.pwd-digit')?.textContent).toBe('3'); + expect(revealed.querySelector('.pwd-symbol')?.textContent).toBe('$'); +}); +``` + +- [ ] **Step 4: Run tests + manual visual check** + +``` +cd extension && npm run test +``` + +Expected: PASS. Then build and load the extension to verify a revealed password in the field-history viewer is colored. + +- [ ] **Step 5: Commit** + +``` +git add extension/src/popup/components/field-history.ts \ + extension/src/popup/components/__tests__/field-history.test.ts +git commit -m "feat(ext/popup/field-history): colorize revealed password entries" +``` + +--- + +### Task 5: Popup vault item detail (password reveal) + +**Files:** +- Modify: the popup component that renders the password field's revealed value (find via `grep -rn "field.*Password\|FieldKind.Password\|reveal" extension/src/popup/components/`) + +- [ ] **Step 1: Find the surface** + +Read the matched files and identify the line(s) that set the password text when revealed. The likely shape is a function `renderField(field)` with a branch on `field.kind === FieldKind.Password`. + +- [ ] **Step 2: Apply the same imperative pattern** + +Replace whatever currently sets the password's text content with: + +```ts +import { colorizePassword } from '../../shared/password-coloring'; + +passwordValueEl.textContent = ''; +if (revealed) { + passwordValueEl.appendChild(colorizePassword(field.value)); +} else { + passwordValueEl.textContent = '••••••••'; +} +``` + +- [ ] **Step 3: Run tests + manual check** + +``` +cd extension && npm run test +``` + +Build, load, reveal a password — confirm coloring. + +- [ ] **Step 4: Commit** + +``` +git add extension/src/popup/components/ +git commit -m "feat(ext/popup/item-detail): colorize revealed password field" +``` + +--- + +### Task 6: Fullscreen vault item detail + +**Files:** +- Modify: the equivalent component under `extension/src/vault/` + +The fullscreen vault is currently undergoing a Phase 1 redesign (see `9ed7e7c` and the Phase 1 plan in `docs/superpowers/plans/2026-04-30-fullscreen-ux-phase-1-visual-foundation.md`). Coordinate with that work — if the password-reveal surface is in active flux, land this change after Phase 1 settles, or fold it into Phase 2 if the user is doing that work themselves. + +- [ ] **Step 1: Find the fullscreen reveal surface** + +``` +grep -rn "FieldKind.Password\|password.*reveal\|reveal.*password" extension/src/vault/ +``` + +- [ ] **Step 2: Apply the same pattern as Task 5** + +Same code shape. Different file. + +- [ ] **Step 3: Run tests + manual check** + +Open the fullscreen vault, reveal a password, confirm coloring. + +- [ ] **Step 4: Commit** + +``` +git add extension/src/vault/ +git commit -m "feat(ext/vault): colorize revealed password field in fullscreen view" +``` + +--- + +### Task 7: Generator preview + +**Files:** +- Modify: the generator component (find via `grep -rn "generate_password\|generator.*preview" extension/src/`) + +- [ ] **Step 1: Find the surface** + +The generator likely has a live preview element that updates as the user adjusts character-class toggles, length, etc. + +- [ ] **Step 2: Apply the imperative pattern** + +```ts +import { colorizePassword } from '../../shared/password-coloring'; + +previewEl.textContent = ''; +previewEl.appendChild(colorizePassword(generatedPassword)); +``` + +- [ ] **Step 3: Run tests + manual check** + +Open the generator, click roll/regenerate a few times — confirm the preview updates with coloring intact. + +- [ ] **Step 4: Commit** + +``` +git add extension/src/popup/components/ # or wherever the generator lives +git commit -m "feat(ext/generator): colorize live password preview" +``` + +--- + +## Phase E — Boot wiring + +### Task 8: Call `applyColorScheme()` on popup + vault startup + +**Files:** +- Modify: `extension/src/popup/popup.ts` (or `popup/index.ts` — the popup's bootstrap) +- Modify: `extension/src/vault/vault.ts` — the fullscreen vault's bootstrap + +- [ ] **Step 1: Add the call in popup boot** + +Near the top of the popup's `init()` / `main()` function: + +```ts +import { applyColorScheme } from '../shared/color-scheme'; + +await applyColorScheme(); +``` + +The `await` is fine — it runs once per popup open, the storage round-trip is cheap (sub-millisecond). + +Also wire a `chrome.storage.onChanged` listener so live edits from another open extension surface (e.g., the settings page) reflect immediately: + +```ts +chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'sync' && 'password_display_scheme' in changes) { + void applyColorScheme(); + } +}); +``` + +- [ ] **Step 2: Add the call in vault boot** + +Same pattern in the fullscreen vault's bootstrap. + +- [ ] **Step 3: Manual verification** + +Open both surfaces, edit the colors via the (about-to-exist) settings page, observe the change reflect in real time. + +- [ ] **Step 4: Commit** + +``` +git add extension/src/popup/popup.ts extension/src/vault/vault.ts +git commit -m "feat(ext): apply color scheme on popup + vault startup, react to storage changes" +``` + +--- + +## Phase F — Settings UI + +### Task 9: Display section in settings with color pickers + preview swatch + reset + +**Files:** +- Modify: an existing settings component — best candidate is `extension/src/popup/components/settings.ts` (general settings) or a new dedicated section if settings are split. Read the existing settings layout before deciding. +- Test: `extension/src/popup/components/__tests__/settings.test.ts` (extend existing tests) + +- [ ] **Step 1: Find the existing settings shape** + +``` +grep -n "render\|section\|setting" extension/src/popup/components/settings.ts | head -30 +``` + +Identify the pattern used to render a settings group (likely a `section` builder + child controls). + +- [ ] **Step 2: Add the Display section** + +Following the existing pattern: + +```ts +import { + loadColorScheme, saveColorScheme, resetColorScheme, + DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, +} from '../../shared/color-scheme'; +import { colorizePassword } from '../../shared/password-coloring'; + +async function renderDisplaySection(parent: HTMLElement) { + const section = createSection('Display'); + parent.appendChild(section); + + const scheme = await loadColorScheme(); + + const digitInput = createColorInput('Digit color', scheme.digit_color); + const symbolInput = createColorInput('Symbol color', scheme.symbol_color); + const swatch = document.createElement('div'); + swatch.className = 'color-preview-swatch'; + + const SAMPLE = 'Abc123!@#xyz'; + + const updateSwatch = () => { + swatch.style.setProperty('--relicario-pwd-digit-color', digitInput.value); + swatch.style.setProperty('--relicario-pwd-symbol-color', symbolInput.value); + swatch.textContent = ''; + swatch.appendChild(colorizePassword(SAMPLE)); + }; + updateSwatch(); + + const onChange = async () => { + updateSwatch(); + try { + await saveColorScheme({ + digit_color: digitInput.value, symbol_color: symbolInput.value, + }); + } catch (e) { + // Show inline error; keep current swatch. + } + }; + digitInput.addEventListener('change', onChange); + symbolInput.addEventListener('change', onChange); + + const resetBtn = document.createElement('button'); + resetBtn.textContent = 'Reset to defaults'; + resetBtn.addEventListener('click', async () => { + digitInput.value = DEFAULT_DIGIT_COLOR; + symbolInput.value = DEFAULT_SYMBOL_COLOR; + await resetColorScheme(); + updateSwatch(); + }); + + section.append(digitInput, symbolInput, swatch, resetBtn); +} + +function createColorInput(label: string, value: string): HTMLInputElement & { label: string } { + // simple