From 0c800bcd4f4ef3f9e06d372819a0b24c83eacde2 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 12 Apr 2026 10:52:51 -0400 Subject: [PATCH] docs: add vault initialization wizard implementation plan 6 tasks: WASM embed function, setup HTML, wizard TypeScript, webpack/manifest updates, and build integration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-12-idfoto-init-wizard.md | 955 ++++++++++++++++++ 1 file changed, 955 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md diff --git a/docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md b/docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md new file mode 100644 index 0000000..a7cbe68 --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md @@ -0,0 +1,955 @@ +# idfoto Vault Initialization Wizard 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:** Build a browser-based wizard that creates a new idfoto vault, pushes it to Gitea/GitHub via API, downloads the reference image, and optionally configures the Chrome extension. + +**Architecture:** Single HTML page (`extension/setup.html`) bundled by webpack as a new entry point. Reuses the existing git API layer and WASM module. New `embed_image_secret` function added to the WASM crate. The wizard runs entirely client-side — all crypto happens in the browser via WASM. + +**Tech Stack:** TypeScript, wasm-bindgen (existing WASM crate), webpack, Chrome extension APIs + +**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-init-wizard-design.md` + +--- + +## File Structure + +### Rust (modified) + +``` +crates/idfoto-wasm/src/lib.rs # Add embed_image_secret function +``` + +### Extension (new) + +``` +extension/ +├── setup.html # Standalone wizard page +└── src/ + └── setup/ + └── setup.ts # 4-step wizard logic +``` + +### Extension (modified) + +``` +extension/webpack.config.js # Add 'setup' entry point + copy setup.html +extension/manifest.json # Add web_accessible_resources for setup.html +``` + +--- + +## Task 1: Add `embed_image_secret` to WASM Crate + +**Files:** +- Modify: `crates/idfoto-wasm/src/lib.rs` + +- [ ] **Step 1: Write the test** + +Add to the `#[cfg(test)] mod tests` block in `crates/idfoto-wasm/src/lib.rs`: + +```rust +#[test] +fn embed_then_extract_round_trip() { + // Create a synthetic test JPEG (same approach as idfoto-core tests) + use image::codecs::jpeg::JpegEncoder; + use image::{ImageBuffer, ImageEncoder, Rgb}; + + let img = ImageBuffer::from_fn(400, 300, |x, y| { + Rgb([ + ((x * 7 + y * 13) % 256) as u8, + ((x * 11 + y * 3) % 256) as u8, + ((x * 5 + y * 17) % 256) as u8, + ]) + }); + let mut jpeg_buf = Vec::new(); + let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92); + encoder + .write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8) + .unwrap(); + + let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, + 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8]; + + let stego = embed_image_secret(&jpeg_buf, &secret).unwrap(); + let extracted = extract_image_secret(&stego).unwrap(); + assert_eq!(extracted, secret); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p idfoto-wasm embed_then_extract` +Expected: FAIL — `embed_image_secret` not defined. + +- [ ] **Step 3: Add `image` dev-dependency to Cargo.toml** + +Add to `crates/idfoto-wasm/Cargo.toml` under `[dev-dependencies]`: + +```toml +[dev-dependencies] +wasm-bindgen-test = "0.3" +image = { version = "0.25", default-features = false, features = ["jpeg"] } +``` + +- [ ] **Step 4: Implement the function** + +Add to `crates/idfoto-wasm/src/lib.rs`, after the `extract_image_secret` function: + +```rust +/// Embed a 256-bit secret into a carrier JPEG image. +/// +/// Takes the raw bytes of a JPEG image and a 32-byte secret, returns the +/// modified JPEG with the secret embedded via DCT steganography. +/// +/// The returned JPEG should be saved as the "reference image" — the user +/// needs it alongside their passphrase to unlock the vault. +#[wasm_bindgen] +pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result, JsValue> { + let secret: [u8; 32] = secret + .try_into() + .map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?; + idfoto_core::imgsecret::embed(carrier_jpeg, &secret) + .map_err(|e| JsValue::from_str(&e.to_string())) +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cargo test -p idfoto-wasm embed_then_extract` +Expected: PASS + +- [ ] **Step 6: Rebuild WASM** + +Run: `wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm` +Expected: Builds successfully. + +- [ ] **Step 7: Commit** + +```bash +git add crates/idfoto-wasm/src/lib.rs crates/idfoto-wasm/Cargo.toml +git commit -m "feat: add embed_image_secret to WASM crate" +``` + +--- + +## Task 2: Add WASM Type Declaration for New Function + +**Files:** +- Modify: `extension/src/wasm.d.ts` + +- [ ] **Step 1: Add the type declaration** + +Add to `extension/src/wasm.d.ts` alongside the existing declarations: + +```typescript +export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array; +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/wasm.d.ts +git commit -m "feat: add embed_image_secret type declaration" +``` + +--- + +## Task 3: Create Setup Page HTML + +**Files:** +- Create: `extension/setup.html` + +- [ ] **Step 1: Create the HTML file** + +Create `extension/setup.html`: + +```html + + + + + + idfoto — vault setup + + + + +
+ + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/setup.html +git commit -m "feat: add setup wizard HTML page" +``` + +--- + +## Task 4: Create Setup Wizard TypeScript + +**Files:** +- Create: `extension/src/setup/setup.ts` + +This is the main task. The wizard is a 4-step state machine that reuses the existing git API layer and WASM module. + +- [ ] **Step 1: Create the setup wizard** + +Create `extension/src/setup/setup.ts`: + +```typescript +/// Standalone vault initialization wizard. +/// +/// 4-step flow: +/// 1. Choose git host (Gitea/GitHub) with setup instructions +/// 2. Configure connection (URL, repo, token) with test button +/// 3. Create vault (carrier image, passphrase, generate + push) +/// 4. Finish (download reference image, push config to extension) + +import { createGitHost, uint8ArrayToBase64, base64ToUint8Array } from '../service-worker/git-host'; +import type { GitHost } from '../service-worker/git-host'; +import type { VaultConfig } from '../shared/types'; + +// --- State --- + +interface WizardState { + step: number; + hostType: 'gitea' | 'github'; + hostUrl: string; + repoPath: string; + apiToken: string; + connectionTested: boolean; + carrierImageBytes: Uint8Array | null; + carrierImageName: string; + passphrase: string; + passphraseConfirm: string; + referenceImageBytes: Uint8Array | null; + creating: boolean; + error: string; + extensionDetected: boolean; + configPushed: boolean; +} + +let state: WizardState = { + step: 1, + hostType: 'gitea', + hostUrl: '', + repoPath: '', + apiToken: '', + connectionTested: false, + carrierImageBytes: null, + carrierImageName: '', + passphrase: '', + passphraseConfirm: '', + referenceImageBytes: null, + creating: false, + error: '', + extensionDetected: false, + configPushed: false, +}; + +// --- WASM --- + +type WasmModule = typeof import('idfoto-wasm'); +let wasm: WasmModule | null = null; + +async function initWasm(): Promise { + if (wasm) return wasm; + const mod = await import(/* webpackIgnore: true */ '../idfoto_wasm.js'); + await mod.default(); + wasm = mod; + return mod; +} + +// --- Helpers --- + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} + +function passwordStrength(pw: string): { label: string; color: string; pct: number } { + if (pw.length < 8) return { label: 'too short', color: '#f85149', pct: 10 }; + let score = 0; + if (pw.length >= 12) score++; + if (pw.length >= 16) score++; + if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++; + if (/[0-9]/.test(pw)) score++; + if (/[^a-zA-Z0-9]/.test(pw)) score++; + if (score <= 1) return { label: 'weak', color: '#f85149', pct: 30 }; + if (score <= 3) return { label: 'ok', color: '#d29922', pct: 60 }; + return { label: 'strong', color: '#3fb950', pct: 100 }; +} + +// --- Render --- + +function render(): void { + const app = document.getElementById('app')!; + const stepNames = ['git host', 'connection', 'create vault', 'done']; + + let html = ` +
idfoto setup
+
step ${state.step} of 4 — ${stepNames[state.step - 1]}
+
+ `; + + if (state.error) { + html += `
${escapeHtml(state.error)}
`; + } + + switch (state.step) { + case 1: html += renderStep1(); break; + case 2: html += renderStep2(); break; + case 3: html += renderStep3(); break; + case 4: html += renderStep4(); break; + } + + app.innerHTML = html; + attachListeners(); +} + +// --- Step 1: Choose Git Host --- + +function renderStep1(): string { + return ` +
+
HOST TYPE
+
+ + +
+
+ +
+ ${state.hostType === 'gitea' ? ` +
GITEA SETUP
+
    +
  1. Log in to your Gitea instance
  2. +
  3. Click +New Repository
  4. +
  5. Name it (e.g. idfoto-vault), leave it empty — no README, no .gitignore
  6. +
  7. Go to SettingsApplicationsManage Access Tokens
  8. +
  9. Generate a new token with repo scope (read/write)
  10. +
  11. Copy the token — you'll need it in the next step
  12. +
+ ` : ` +
GITHUB SETUP
+
    +
  1. Go to github.comNew Repository
  2. +
  3. Name it (e.g. idfoto-vault), set to Private, leave it empty — no README, no .gitignore, no license
  4. +
  5. Go to SettingsDeveloper SettingsPersonal Access TokensFine-grained tokens
  6. +
  7. Click Generate new token
  8. +
  9. Select only the vault repository under "Repository access"
  10. +
  11. Under Permissions → Repository → Contents: set to Read and write
  12. +
  13. Generate and copy the token
  14. +
+ `} +
+ +
+ +
+ `; +} + +// --- Step 2: Configure Connection --- + +function renderStep2(): string { + const defaultUrl = state.hostType === 'github' ? 'https://github.com' : ''; + + return ` +
+
HOST URL
+ +
+
+
REPO PATH
+ +
+
+
API TOKEN
+ +
+
+
+ + + +
+ `; +} + +// --- Step 3: Create Vault --- + +function renderStep3(): string { + const strength = state.passphrase ? passwordStrength(state.passphrase) : null; + const mismatch = state.passphraseConfirm && state.passphrase !== state.passphraseConfirm; + + return ` +
+
CARRIER IMAGE
+

+ Pick any JPEG photo. A phone photo works great — at least 400x300 pixels. +

+ + ${state.carrierImageName ? `
✓ ${escapeHtml(state.carrierImageName)}
` : ''} +
+
+
PASSPHRASE
+ + ${strength ? ` +
+
${strength.label}
+ ` : ''} +
+
+
CONFIRM PASSPHRASE
+ + ${mismatch ? '
Passphrases do not match
' : ''} +
+
+ + +
+ `; +} + +// --- Step 4: Finish --- + +function renderStep4(): string { + return ` +
+
✓ Vault created
+

+ Your vault has been pushed to ${escapeHtml(state.repoPath)}. +

+
+ +
+
REFERENCE IMAGE
+

+ Download this image and keep it safe. You need it alongside your passphrase to unlock the vault. + Store it somewhere you won't lose it — a USB drive, a safe, your phone's photo library. +

+ +
+ + ${state.extensionDetected ? ` +
+
EXTENSION
+ ${state.configPushed ? ` +

+ ✓ Extension configured. Open the extension popup and enter your passphrase to unlock. +

+ ` : ` +

+ idfoto extension detected. Push your vault config to it? +

+ + `} +
+ ` : ` +
+
EXTENSION SETUP
+

+ Install the idfoto extension, then enter these details in the setup wizard: +

+
+ ${escapeHtml(JSON.stringify({ + hostType: state.hostType, + hostUrl: state.hostUrl, + repoPath: state.repoPath, + apiToken: state.apiToken, + }, null, 2))} +
+
Click to copy
+
+ `} + `; +} + +// --- Event Listeners --- + +function attachListeners(): void { + // Host type toggle + document.querySelectorAll('[data-action="host"]').forEach(btn => { + btn.addEventListener('click', () => { + state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github'; + state.connectionTested = false; + render(); + }); + }); + + // Navigation + document.querySelectorAll('[data-action="next"]').forEach(btn => { + btn.addEventListener('click', () => { + readInputs(); + state.error = ''; + state.step++; + render(); + }); + }); + + document.querySelectorAll('[data-action="back"]').forEach(btn => { + btn.addEventListener('click', () => { + readInputs(); + state.error = ''; + state.step--; + render(); + }); + }); + + // Test connection + document.querySelector('[data-action="test-connection"]')?.addEventListener('click', async () => { + readInputs(); + state.error = ''; + + if (!state.hostUrl || !state.repoPath || !state.apiToken) { + state.error = 'All fields are required'; + render(); + return; + } + + const resultEl = document.getElementById('test-result')!; + resultEl.innerHTML = '
Testing...
'; + + try { + const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken); + // Try to list the root directory — if the repo exists and token works, this succeeds + await git.listDir(''); + state.connectionTested = true; + resultEl.innerHTML = '
✓ Connected
'; + // Re-render to enable Next button + const nextBtn = document.querySelector('[data-action="next"]') as HTMLButtonElement; + if (nextBtn) nextBtn.disabled = false; + } catch (err) { + state.connectionTested = false; + resultEl.innerHTML = `
✗ ${escapeHtml(String(err))}
`; + } + }); + + // Carrier image file picker + document.getElementById('carrier-image')?.addEventListener('change', (e) => { + const input = e.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + const arrayBuf = reader.result as ArrayBuffer; + state.carrierImageBytes = new Uint8Array(arrayBuf); + state.carrierImageName = file.name; + render(); + }; + reader.readAsArrayBuffer(file); + }); + + // Passphrase inputs (read on change, re-render for strength indicator) + document.getElementById('passphrase')?.addEventListener('input', (e) => { + state.passphrase = (e.target as HTMLInputElement).value; + // Only re-render the strength indicator, not the whole page (avoids losing focus) + const strength = passwordStrength(state.passphrase); + const bar = document.querySelector('.strength-bar-fill') as HTMLElement; + if (bar) { + bar.style.width = `${strength.pct}%`; + bar.style.background = strength.color; + } + const label = bar?.parentElement?.nextElementSibling as HTMLElement; + if (label) { + label.textContent = strength.label; + label.style.color = strength.color; + } + }); + + document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => { + state.passphraseConfirm = (e.target as HTMLInputElement).value; + }); + + // Create vault + document.querySelector('[data-action="create-vault"]')?.addEventListener('click', async () => { + readInputs(); + state.error = ''; + + // Validation + if (!state.carrierImageBytes) { + state.error = 'Select a carrier image'; + render(); + return; + } + if (state.passphrase.length < 8) { + state.error = 'Passphrase must be at least 8 characters'; + render(); + return; + } + if (state.passphrase !== state.passphraseConfirm) { + state.error = 'Passphrases do not match'; + render(); + return; + } + + state.creating = true; + render(); + + try { + await createVault(); + state.creating = false; + + // Detect extension + state.extensionDetected = await detectExtension(); + + state.step = 4; + render(); + } catch (err) { + state.creating = false; + state.error = `Vault creation failed: ${String(err)}`; + render(); + } + }); + + // Download reference image + document.querySelector('[data-action="download-image"]')?.addEventListener('click', () => { + if (!state.referenceImageBytes) return; + const blob = new Blob([state.referenceImageBytes], { type: 'image/jpeg' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'reference.jpg'; + a.click(); + URL.revokeObjectURL(url); + }); + + // Push config to extension + document.querySelector('[data-action="push-to-extension"]')?.addEventListener('click', async () => { + try { + const config: VaultConfig = { + hostType: state.hostType, + hostUrl: state.hostUrl, + repoPath: state.repoPath, + apiToken: state.apiToken, + }; + const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes!); + + chrome.runtime.sendMessage( + { type: 'save_setup', config, imageBase64 }, + (response) => { + if (response?.ok) { + state.configPushed = true; + render(); + } + } + ); + } catch { + state.error = 'Failed to push config to extension'; + render(); + } + }); + + // Copy config blob + document.querySelector('[data-action="copy-config"]')?.addEventListener('click', async () => { + const config = JSON.stringify({ + hostType: state.hostType, + hostUrl: state.hostUrl, + repoPath: state.repoPath, + apiToken: state.apiToken, + }, null, 2); + await navigator.clipboard.writeText(config); + const el = document.querySelector('[data-action="copy-config"]')!; + el.classList.add('test-ok'); + setTimeout(() => el.classList.remove('test-ok'), 1500); + }); +} + +// --- Read form inputs into state --- + +function readInputs(): void { + const hostUrl = document.getElementById('host-url') as HTMLInputElement; + const repoPath = document.getElementById('repo-path') as HTMLInputElement; + const apiToken = document.getElementById('api-token') as HTMLInputElement; + const passphrase = document.getElementById('passphrase') as HTMLInputElement; + const passphraseConfirm = document.getElementById('passphrase-confirm') as HTMLInputElement; + + if (hostUrl) state.hostUrl = hostUrl.value.trim(); + if (repoPath) state.repoPath = repoPath.value.trim(); + if (apiToken) state.apiToken = apiToken.value.trim(); + if (passphrase) state.passphrase = passphrase.value; + if (passphraseConfirm) state.passphraseConfirm = passphraseConfirm.value; +} + +// --- Vault Creation --- + +async function createVault(): Promise { + const w = await initWasm(); + const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken); + + // 1. Generate random 32-byte image_secret + const imageSecret = new Uint8Array(32); + crypto.getRandomValues(imageSecret); + + // 2. Embed secret into carrier JPEG + const referenceJpeg = w.embed_image_secret(state.carrierImageBytes!, imageSecret); + state.referenceImageBytes = new Uint8Array(referenceJpeg); + + // 3. Generate random 32-byte salt + const salt = new Uint8Array(32); + crypto.getRandomValues(salt); + + // 4. Create KDF params + const params = { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; + const paramsJson = JSON.stringify(params); + + // 5. Derive master_key + const masterKey = w.derive_master_key(state.passphrase, imageSecret, salt, paramsJson); + + // 6. Encrypt empty manifest + const emptyManifest = JSON.stringify({ entries: {}, version: 1 }); + const manifestEnc = w.encrypt_manifest(emptyManifest, masterKey); + + // 7. Push vault files to repo + await git.writeFile('.idfoto/salt', salt, 'feat: initialize idfoto vault'); + await git.writeFile('.idfoto/params.json', new TextEncoder().encode(paramsJson), 'chore: add KDF params'); + await git.writeFile('.idfoto/devices.json', new TextEncoder().encode('[]'), 'chore: add empty devices list'); + await git.writeFile('manifest.enc', new Uint8Array(manifestEnc), 'feat: add encrypted manifest'); +} + +// --- Extension Detection --- + +function detectExtension(): Promise { + return new Promise((resolve) => { + try { + if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) { + resolve(false); + return; + } + chrome.runtime.sendMessage( + { type: 'get_setup_state' }, + (response) => { + if (chrome.runtime.lastError || !response) { + resolve(false); + } else { + resolve(true); + } + } + ); + } catch { + resolve(false); + } + }); +} + +// --- Init --- + +document.addEventListener('DOMContentLoaded', render); +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "feat: add vault initialization wizard" +``` + +--- + +## Task 5: Update Webpack and Manifest + +**Files:** +- Modify: `extension/webpack.config.js` +- Modify: `extension/manifest.json` + +- [ ] **Step 1: Add setup entry point to webpack** + +In `extension/webpack.config.js`, add `setup` to the `entry` object: + +```javascript +entry: { + 'service-worker': './src/service-worker/index.ts', + popup: './src/popup/popup.ts', + content: './src/content/detector.ts', + setup: './src/setup/setup.ts', +}, +``` + +Add `setup.html` to the CopyPlugin patterns array: + +```javascript +{ from: 'setup.html', to: '.' }, +``` + +- [ ] **Step 2: Add web_accessible_resources to manifest.json** + +Add to `extension/manifest.json`, after the `content_security_policy` block: + +```json +"web_accessible_resources": [{ + "resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"], + "matches": [""] +}] +``` + +- [ ] **Step 3: Build and verify** + +```bash +cd extension && bun run build +``` + +Expected: Builds successfully with `dist/setup.js` and `dist/setup.html` in the output. + +- [ ] **Step 4: Commit** + +```bash +git add extension/webpack.config.js extension/manifest.json +git commit -m "feat: add setup wizard to webpack build and extension manifest" +``` + +--- + +## Task 6: Build and Manual Test + +**Files:** None (integration testing) + +- [ ] **Step 1: Rebuild WASM** + +```bash +wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm +``` + +- [ ] **Step 2: Rebuild extension** + +```bash +cd extension && bun run build +``` + +- [ ] **Step 3: Run Rust tests** + +```bash +cargo test +``` + +Expected: All tests pass (including the new `embed_then_extract_round_trip`). + +- [ ] **Step 4: Load in Chrome and test** + +1. Open `chrome://extensions/`, reload the unpacked extension from `extension/dist/` +2. Open `chrome-extension:///setup.html` +3. Verify: + - Step 1: host toggle switches between Gitea/GitHub instructions + - Step 2: enter real host/token/repo, test connection works + - Step 3: pick a JPEG, enter passphrase, create vault pushes files + - Step 4: download reference image works, extension detection works +4. Verify the vault repo now has `.idfoto/salt`, `.idfoto/params.json`, `.idfoto/devices.json`, `manifest.enc` +5. Open extension popup, unlock with passphrase — should work with the just-created vault + +- [ ] **Step 5: Fix any issues found** + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "feat: complete vault initialization wizard" +``` + +--- + +## Task Summary + +| Task | Description | Dependencies | +|------|-------------|--------------| +| 1 | Add `embed_image_secret` to WASM crate | None | +| 2 | Add WASM type declaration | Task 1 | +| 3 | Create setup page HTML | None | +| 4 | Create setup wizard TypeScript | Task 2, 3 | +| 5 | Update webpack and manifest | Task 3, 4 | +| 6 | Build and manual test | All | + +Tasks 1 and 3 can run in parallel. Tasks 2 and 4 are sequential after 1. Task 5 depends on 3+4. Task 6 is final integration.