Files
relicario/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md
adlee-was-taken 01d5fd5d0d docs: add WASM + Chrome MV3 extension implementation plan
11 tasks covering core data model changes, WASM crate with TOTP,
extension scaffolding, git API layer, service worker, popup UI
with terminal aesthetic, content script autofill, and build integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:14:03 -04:00

97 KiB

idfoto WASM + Chrome MV3 Extension 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: Compile idfoto-core to WASM and wrap it in a Chrome MV3 browser extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access.

Architecture: Monolith service worker loads the WASM module and holds all state (master_key, cached manifest). Popup and content script are thin UI layers communicating via chrome.runtime.sendMessage. Vault data is fetched/committed directly via Gitea/GitHub REST APIs — no local clone, no CLI dependency.

Tech Stack: Rust + wasm-bindgen (WASM crate), TypeScript + webpack (extension), Chrome MV3 APIs

Spec: docs/superpowers/specs/2026-04-12-idfoto-wasm-extension-design.md


File Structure

Rust (new crate)

crates/idfoto-wasm/
├── Cargo.toml
└── src/
    └── lib.rs          # wasm-bindgen wrappers + TOTP implementation

Rust (modified)

crates/idfoto-core/src/entry.rs   # Add group field to Entry and ManifestEntry
Cargo.toml                         # Add idfoto-wasm to workspace members

Extension (all new)

extension/
├── manifest.json
├── package.json
├── tsconfig.json
├── webpack.config.js
├── src/
│   ├── service-worker/
│   │   ├── index.ts        # WASM init, message router, state
│   │   ├── vault.ts        # vault CRUD operations
│   │   ├── git-host.ts     # GitHost interface + factory
│   │   ├── gitea.ts        # Gitea API implementation
│   │   └── github.ts       # GitHub API implementation
│   ├── popup/
│   │   ├── index.html      # popup HTML shell
│   │   ├── popup.ts        # state machine: setup → locked → list → detail → edit
│   │   ├── styles.css      # terminal dark theme
│   │   └── components/
│   │       ├── unlock.ts       # passphrase prompt
│   │       ├── entry-list.ts   # search + group filter + entry rows
│   │       ├── entry-detail.ts # field display + TOTP countdown
│   │       ├── entry-form.ts   # add/edit form
│   │       └── setup-wizard.ts # 3-step setup flow
│   ├── content/
│   │   ├── detector.ts     # login form detection
│   │   ├── fill.ts         # credential injection
│   │   └── icon.ts         # field icon injection + inline picker
│   └── shared/
│       ├── messages.ts     # typed message definitions
│       └── types.ts        # Entry, ManifestEntry, VaultConfig types
└── icons/
    ├── icon-16.png
    ├── icon-48.png
    └── icon-128.png

Task 1: Add group Field to Core Data Model

Files:

  • Modify: crates/idfoto-core/src/entry.rs

  • Modify: crates/idfoto-core/src/vault.rs (test helpers)

  • Modify: crates/idfoto-cli/src/main.rs (Entry construction sites)

  • Test: crates/idfoto-core/src/entry.rs (inline tests)

  • Step 1: Add group field to Entry struct

In crates/idfoto-core/src/entry.rs, add the field after totp_secret:

pub struct Entry {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    pub password: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub totp_secret: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    pub created_at: String,
    pub updated_at: String,
}
  • Step 2: Add group field to ManifestEntry struct

In the same file:

pub struct ManifestEntry {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub group: Option<String>,
    pub updated_at: String,
}
  • Step 3: Fix all Entry construction sites in tests and CLI

Update every place that constructs Entry or ManifestEntry to include group: None. These are:

In crates/idfoto-core/src/entry.rs tests — entry_serialization_round_trip, manifest_add_and_lookup, manifest_serialization_round_trip, manifest_search_case_insensitive:

// Every Entry construction gets:
group: None,

// Every ManifestEntry construction gets:
group: None,

In crates/idfoto-core/src/vault.rs tests — sample_entry() helper and manifest_encrypt_decrypt_round_trip:

// sample_entry() gets:
group: None,

// ManifestEntry in manifest_encrypt_decrypt_round_trip gets:
group: None,

In crates/idfoto-core/tests/integration.rsfull_vault_workflow() Entry construction (line ~55) and ManifestEntry (line ~101):

// Entry construction gets:
group: None,

// ManifestEntry construction gets:
group: None,

In crates/idfoto-cli/src/main.rscmd_add() Entry construction (line ~328), cmd_add() ManifestEntry (line ~349), cmd_edit() Entry construction (line ~513), cmd_edit() ManifestEntry (line ~536):

// Every Entry construction gets:
group: None,

// Every ManifestEntry construction gets:
group: None,
  • Step 4: Add a test for backwards compatibility (deserialize without group)

In crates/idfoto-core/src/entry.rs tests:

#[test]
fn entry_deserializes_without_group_field() {
    let json = r#"{
        "name": "Legacy",
        "password": "old-pw",
        "created_at": "2024-01-01T00:00:00Z",
        "updated_at": "2024-01-01T00:00:00Z"
    }"#;
    let entry: Entry = serde_json::from_str(json).unwrap();
    assert_eq!(entry.name, "Legacy");
    assert_eq!(entry.group, None);
}

#[test]
fn manifest_entry_deserializes_without_group_field() {
    let json = r#"{
        "name": "Legacy",
        "updated_at": "2024-01-01T00:00:00Z"
    }"#;
    let entry: ManifestEntry = serde_json::from_str(json).unwrap();
    assert_eq!(entry.name, "Legacy");
    assert_eq!(entry.group, None);
}

#[test]
fn entry_with_group_round_trips() {
    let entry = Entry {
        name: "Work GitHub".to_string(),
        url: Some("https://github.com".to_string()),
        username: Some("alee-work".to_string()),
        password: "s3cr3t".to_string(),
        notes: None,
        totp_secret: None,
        group: Some("work".to_string()),
        created_at: "2024-01-01T00:00:00Z".to_string(),
        updated_at: "2024-01-01T00:00:00Z".to_string(),
    };
    let json = serde_json::to_string(&entry).unwrap();
    assert!(json.contains("\"group\":\"work\""));
    let decoded: Entry = serde_json::from_str(&json).unwrap();
    assert_eq!(decoded.group, Some("work".to_string()));
}
  • Step 5: Run all tests

Run: cargo test Expected: All tests pass, including new backwards-compatibility tests.

  • Step 6: Commit
git add crates/idfoto-core/src/entry.rs crates/idfoto-core/src/vault.rs crates/idfoto-core/tests/integration.rs crates/idfoto-cli/src/main.rs
git commit -m "feat: add group field to Entry and ManifestEntry"

Task 2: Create idfoto-wasm Crate

Files:

  • Create: crates/idfoto-wasm/Cargo.toml

  • Create: crates/idfoto-wasm/src/lib.rs

  • Modify: Cargo.toml (workspace members)

  • Step 1: Create Cargo.toml

Create crates/idfoto-wasm/Cargo.toml:

[package]
name = "idfoto-wasm"
version = "0.1.0"
edition = "2021"
description = "WASM bindings for idfoto password manager"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
idfoto-core = { path = "../idfoto-core" }
wasm-bindgen = "0.2"
js-sys = "0.3"
serde_json = "1"
hmac = "0.12"
sha1 = "0.10"
data-encoding = "2"

[dev-dependencies]
wasm-bindgen-test = "0.3"
  • Step 2: Add to workspace

In root Cargo.toml, add "crates/idfoto-wasm" to the members list:

[workspace]
resolver = "2"
members = [
    "crates/idfoto-core",
    "crates/idfoto-cli",
    "crates/idfoto-wasm",
]
  • Step 3: Write the WASM wrapper

Create crates/idfoto-wasm/src/lib.rs:

use wasm_bindgen::prelude::*;

/// Derive a 32-byte master key from passphrase + image_secret + salt.
/// `params_json` is a JSON string like: {"argon2_m":65536,"argon2_t":3,"argon2_p":4}
#[wasm_bindgen]
pub fn derive_master_key(
    passphrase: &str,
    image_secret: &[u8],
    salt: &[u8],
    params_json: &str,
) -> Result<Vec<u8>, JsValue> {
    let params: idfoto_core::KdfParams =
        serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?;

    let image_secret: [u8; 32] = image_secret
        .try_into()
        .map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?;
    let salt: [u8; 32] = salt
        .try_into()
        .map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?;

    let key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;

    Ok(key.to_vec())
}

/// Encrypt plaintext bytes with a 32-byte key. Returns version+nonce+ciphertext+tag.
#[wasm_bindgen]
pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    idfoto_core::crypto::encrypt(&key, plaintext).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Decrypt ciphertext with a 32-byte key. Returns plaintext bytes.
#[wasm_bindgen]
pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result<Vec<u8>, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    idfoto_core::crypto::decrypt(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Extract a 256-bit secret from a JPEG with an embedded secret.
#[wasm_bindgen]
pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
    let secret =
        idfoto_core::imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?;
    Ok(secret.to_vec())
}

/// Encrypt an entry (JSON string) with a 32-byte key. Returns encrypted bytes.
#[wasm_bindgen]
pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    let entry: idfoto_core::Entry =
        serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
    idfoto_core::encrypt_entry(&key, &entry).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Decrypt an entry from encrypted bytes. Returns JSON string.
#[wasm_bindgen]
pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    let entry =
        idfoto_core::decrypt_entry(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?;
    serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Encrypt a manifest (JSON string) with a 32-byte key. Returns encrypted bytes.
#[wasm_bindgen]
pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result<Vec<u8>, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    let manifest: idfoto_core::Manifest =
        serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
    idfoto_core::encrypt_manifest(&key, &manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Decrypt a manifest from encrypted bytes. Returns JSON string.
#[wasm_bindgen]
pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result<String, JsValue> {
    let key: [u8; 32] = key
        .try_into()
        .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?;
    let manifest = idfoto_core::decrypt_manifest(&key, ciphertext)
        .map_err(|e| JsValue::from_str(&e.to_string()))?;
    serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string()))
}

/// Generate a TOTP code for the given base32-encoded secret and unix timestamp.
/// Returns a 6-digit zero-padded string.
#[wasm_bindgen]
pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result<String, JsValue> {
    use hmac::{Hmac, Mac};
    use sha1::Sha1;

    let secret = data_encoding::BASE32_NOPAD
        .decode(secret_base32.to_uppercase().as_bytes())
        .or_else(|_| data_encoding::BASE32.decode(secret_base32.to_uppercase().as_bytes()))
        .map_err(|e| JsValue::from_str(&format!("invalid base32 secret: {e}")))?;

    let counter = timestamp_secs / 30;
    let counter_bytes = counter.to_be_bytes();

    let mut mac = Hmac::<Sha1>::new_from_slice(&secret)
        .map_err(|e| JsValue::from_str(&format!("HMAC error: {e}")))?;
    mac.update(&counter_bytes);
    let result = mac.finalize().into_bytes();

    let offset = (result[19] & 0x0f) as usize;
    let code = ((result[offset] as u32 & 0x7f) << 24)
        | ((result[offset + 1] as u32) << 16)
        | ((result[offset + 2] as u32) << 8)
        | (result[offset + 3] as u32);
    let code = code % 1_000_000;

    Ok(format!("{:06}", code))
}

/// Generate a random password of the given length.
#[wasm_bindgen]
pub fn generate_password(length: u32) -> String {
    use js_sys::Math;

    const CHARSET: &[u8] =
        b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+";

    (0..length)
        .map(|_| {
            let idx = (Math::random() * CHARSET.len() as f64).floor() as usize;
            CHARSET[idx % CHARSET.len()] as char
        })
        .collect()
}

/// Generate a random 8-character hex entry ID.
#[wasm_bindgen]
pub fn generate_entry_id() -> String {
    use js_sys::Math;

    let mut bytes = [0u8; 4];
    for b in &mut bytes {
        *b = (Math::random() * 256.0).floor() as u8;
    }
    bytes.iter().map(|b| format!("{:02x}", b)).collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn totp_rfc6238_test_vector() {
        // RFC 6238 test vector: SHA1, secret = "12345678901234567890" (ASCII),
        // time = 59, expected code = 287082
        let secret_ascii = b"12345678901234567890";
        let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
        let result = generate_totp(&secret_b32, 59).unwrap();
        assert_eq!(result, "287082");
    }

    #[test]
    fn totp_rfc6238_test_vector_2() {
        // time = 1111111109, expected = 081804
        let secret_ascii = b"12345678901234567890";
        let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
        let result = generate_totp(&secret_b32, 1111111109).unwrap();
        assert_eq!(result, "081804");
    }

    #[test]
    fn totp_rfc6238_test_vector_3() {
        // time = 1234567890, expected = 005924
        let secret_ascii = b"12345678901234567890";
        let secret_b32 = data_encoding::BASE32.encode(secret_ascii);
        let result = generate_totp(&secret_b32, 1234567890).unwrap();
        assert_eq!(result, "005924");
    }

    #[test]
    fn totp_invalid_base32_fails() {
        let result = generate_totp("not-valid-base32!!!", 1000);
        assert!(result.is_err());
    }

    #[test]
    fn derive_key_via_wasm_wrapper() {
        let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#;
        let image_secret = [0x42u8; 32];
        let salt = [0x01u8; 32];
        let key = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap();
        assert_eq!(key.len(), 32);

        // Same inputs should produce same output
        let key2 = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap();
        assert_eq!(key, key2);
    }

    #[test]
    fn encrypt_decrypt_via_wasm_wrapper() {
        let key = [0xABu8; 32];
        let plaintext = b"hello wasm";
        let ciphertext = encrypt(plaintext, &key).unwrap();
        let decrypted = decrypt(&ciphertext, &key).unwrap();
        assert_eq!(decrypted, plaintext);
    }

    #[test]
    fn encrypt_entry_decrypt_entry_round_trip() {
        let key = [0xABu8; 32];
        let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
        let ciphertext = encrypt_entry(entry_json, &key).unwrap();
        let result = decrypt_entry(&ciphertext, &key).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
        assert_eq!(parsed["name"], "Test");
        assert_eq!(parsed["password"], "secret");
    }
}
  • Step 4: Verify it compiles

Run: cargo build -p idfoto-wasm Expected: Compiles successfully.

  • Step 5: Run tests

Run: cargo test -p idfoto-wasm Expected: All tests pass, including TOTP RFC 6238 test vectors.

  • Step 6: Test WASM compilation

Run: cargo install wasm-pack (if not already installed), then:

wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm

Expected: Produces extension/wasm/idfoto_wasm.js and extension/wasm/idfoto_wasm_bg.wasm. Note the WASM binary size for later reference.

  • Step 7: Commit
git add crates/idfoto-wasm/ Cargo.toml extension/wasm/
git commit -m "feat: add idfoto-wasm crate with wasm-bindgen wrappers and TOTP"

Task 3: Extension Scaffolding

Files:

  • Create: extension/manifest.json

  • Create: extension/package.json

  • Create: extension/tsconfig.json

  • Create: extension/webpack.config.js

  • Create: extension/src/popup/index.html

  • Create: extension/icons/ (placeholder icons)

  • Modify: .gitignore (add extension build artifacts)

  • Step 1: Create extension/package.json

{
  "name": "idfoto-extension",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack --mode development --watch",
    "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm",
    "build:all": "npm run build:wasm && npm run build"
  },
  "devDependencies": {
    "typescript": "^5.4",
    "ts-loader": "^9.5",
    "webpack": "^5.90",
    "webpack-cli": "^5.1",
    "copy-webpack-plugin": "^12.0",
    "@anthropic-ai/sdk": "^0.39"
  }
}

Note: @anthropic-ai/sdk is NOT needed — remove that. The devDependencies should be:

{
  "name": "idfoto-extension",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack --mode development --watch",
    "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm",
    "build:all": "npm run build:wasm && npm run build"
  },
  "devDependencies": {
    "typescript": "^5.4",
    "ts-loader": "^9.5",
    "webpack": "^5.90",
    "webpack-cli": "^5.1",
    "copy-webpack-plugin": "^12.0",
    "@anthropic-ai/sdk": "^0.39.0"
  }
}

Actually, strike that — no anthropic SDK. Final version:

{
  "name": "idfoto-extension",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "webpack --mode production",
    "dev": "webpack --mode development --watch",
    "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm",
    "build:all": "npm run build:wasm && npm run build"
  },
  "devDependencies": {
    "typescript": "^5.4",
    "ts-loader": "^9.5",
    "webpack": "^5.90",
    "webpack-cli": "^5.1",
    "copy-webpack-plugin": "^12.0"
  }
}
  • Step 2: Create extension/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true,
    "lib": ["ES2022", "DOM"]
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "wasm"]
}
  • Step 3: Create extension/webpack.config.js
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');

module.exports = {
  entry: {
    'service-worker': './src/service-worker/index.ts',
    popup: './src/popup/popup.ts',
    content: './src/content/detector.ts',
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].js',
    clean: true,
  },
  resolve: {
    extensions: ['.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    new CopyPlugin({
      patterns: [
        { from: 'manifest.json', to: '.' },
        { from: 'src/popup/index.html', to: 'popup.html' },
        { from: 'src/popup/styles.css', to: 'styles.css' },
        { from: 'icons', to: 'icons' },
        { from: 'wasm/idfoto_wasm_bg.wasm', to: '.' },
        { from: 'wasm/idfoto_wasm.js', to: '.' },
      ],
    }),
  ],
  experiments: {
    asyncWebAssembly: true,
  },
};
  • Step 4: Create extension/manifest.json
{
  "manifest_version": 3,
  "name": "idfoto",
  "version": "0.1.0",
  "description": "Two-factor encrypted password manager",
  "permissions": ["storage", "activeTab", "clipboardWrite"],
  "host_permissions": ["<all_urls>"],
  "background": {
    "service_worker": "service-worker.js",
    "type": "module"
  },
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon-16.png",
      "48": "icons/icon-48.png",
      "128": "icons/icon-128.png"
    }
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "content_security_policy": {
    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
  }
}
  • Step 5: Create popup HTML shell

Create extension/src/popup/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=360">
  <link rel="stylesheet" href="styles.css">
  <title>idfoto</title>
</head>
<body>
  <div id="app"></div>
  <script src="popup.js"></script>
</body>
</html>
  • Step 6: Create placeholder icons

Generate simple placeholder PNGs for extension/icons/. These are 1x1 blue pixels scaled to 16, 48, 128 — functional placeholders to avoid Chrome errors. Use a simple script or create them manually.

cd extension && mkdir -p icons
# Create minimal valid PNGs (can replace with real icons later)
python3 -c "
import struct, zlib
def make_png(size, path):
    raw = b''
    for y in range(size):
        raw += b'\x00' + b'\x1f\x6f\xeb\xff' * size
    def chunk(ctype, data):
        c = ctype + data
        return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff)
    ihdr = struct.pack('>IIBBBBB', size, size, 8, 6, 0, 0, 0)
    with open(path, 'wb') as f:
        f.write(b'\x89PNG\r\n\x1a\n')
        f.write(chunk(b'IHDR', ihdr))
        f.write(chunk(b'IDAT', zlib.compress(raw)))
        f.write(chunk(b'IEND', b''))
make_png(16, 'icons/icon-16.png')
make_png(48, 'icons/icon-48.png')
make_png(128, 'icons/icon-128.png')
"
  • Step 7: Add to .gitignore

Append to the root .gitignore:

extension/node_modules/
extension/dist/
.superpowers/
  • Step 8: Commit
git add extension/manifest.json extension/package.json extension/tsconfig.json extension/webpack.config.js extension/src/popup/index.html extension/icons/ .gitignore
git commit -m "feat: add extension scaffolding (manifest, webpack, tsconfig)"

Task 4: Shared Types and Message Definitions

Files:

  • Create: extension/src/shared/types.ts

  • Create: extension/src/shared/messages.ts

  • Step 1: Create shared types

Create extension/src/shared/types.ts:

export interface Entry {
  name: string;
  url?: string;
  username?: string;
  password: string;
  notes?: string;
  totp_secret?: string;
  group?: string;
  created_at: string;
  updated_at: string;
}

export interface ManifestEntry {
  name: string;
  url?: string;
  username?: string;
  group?: string;
  updated_at: string;
}

export interface Manifest {
  entries: Record<string, ManifestEntry>;
  version: number;
}

export interface VaultConfig {
  hostType: 'gitea' | 'github';
  hostUrl: string;
  repoPath: string;
  apiToken: string;
}

export interface SetupState {
  config: VaultConfig | null;
  imageBase64: string | null; // reference JPEG stored as base64
  isConfigured: boolean;
}
  • Step 2: Create message definitions

Create extension/src/shared/messages.ts:

import type { Entry, ManifestEntry, VaultConfig } from './types';

// ─── Requests (popup/content → service worker) ─────────────────────────────

export type Request =
  | { type: 'unlock'; passphrase: string }
  | { type: 'lock' }
  | { type: 'is_unlocked' }
  | { type: 'list_entries'; group?: string }
  | { type: 'get_entry'; id: string }
  | { type: 'search_entries'; query: string }
  | { type: 'add_entry'; entry: Omit<Entry, 'created_at' | 'updated_at'> }
  | { type: 'update_entry'; id: string; entry: Omit<Entry, 'created_at' | 'updated_at'> }
  | { type: 'delete_entry'; id: string }
  | { type: 'get_totp'; id: string }
  | { type: 'get_autofill_candidates'; url: string }
  | { type: 'get_credentials'; id: string }
  | { type: 'sync' }
  | { type: 'get_setup_state' }
  | { type: 'save_setup'; config: VaultConfig; imageBase64: string }
  | { type: 'fill_credentials'; username: string; password: string };

// ─── Responses (service worker → popup/content) ────────────────────────────

export type Response =
  | { ok: true }
  | { ok: true; id: string }
  | { ok: true; unlocked: boolean }
  | { ok: true; entries: Array<{ id: string } & ManifestEntry> }
  | { ok: true; entry: Entry }
  | { ok: true; code: string; remaining_seconds: number }
  | { ok: true; candidates: Array<{ id: string } & ManifestEntry> }
  | { ok: true; username: string; password: string }
  | { ok: true; state: import('./types').SetupState }
  | { error: string };
  • Step 3: Commit
git add extension/src/shared/
git commit -m "feat: add shared types and message definitions"

Task 5: Git API Layer

Files:

  • Create: extension/src/service-worker/git-host.ts

  • Create: extension/src/service-worker/gitea.ts

  • Create: extension/src/service-worker/github.ts

  • Step 1: Create the GitHost interface

Create extension/src/service-worker/git-host.ts:

export interface GitHost {
  readFile(path: string): Promise<Uint8Array>;
  writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
  deleteFile(path: string, message: string): Promise<void>;
  listDir(path: string): Promise<string[]>;
}

export function createGitHost(
  hostType: 'gitea' | 'github',
  hostUrl: string,
  repoPath: string,
  apiToken: string
): GitHost {
  switch (hostType) {
    case 'gitea':
      return new (require('./gitea').GiteaHost)(hostUrl, repoPath, apiToken);
    case 'github':
      return new (require('./github').GitHubHost)(hostUrl, repoPath, apiToken);
  }
}

Actually, since we're using ES modules and webpack, use dynamic imports or just import both. Simpler approach:

import { GiteaHost } from './gitea';
import { GitHubHost } from './github';

export interface GitHost {
  readFile(path: string): Promise<Uint8Array>;
  writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
  deleteFile(path: string, message: string): Promise<void>;
  listDir(path: string): Promise<string[]>;
}

export function createGitHost(
  hostType: 'gitea' | 'github',
  hostUrl: string,
  repoPath: string,
  apiToken: string
): GitHost {
  if (hostType === 'gitea') {
    return new GiteaHost(hostUrl, repoPath, apiToken);
  }
  return new GitHubHost(hostUrl, repoPath, apiToken);
}
  • Step 2: Create Gitea implementation

Create extension/src/service-worker/gitea.ts:

import type { GitHost } from './git-host';

export class GiteaHost implements GitHost {
  private baseUrl: string;
  private headers: HeadersInit;

  constructor(hostUrl: string, repoPath: string, apiToken: string) {
    // Remove trailing slash from hostUrl
    const host = hostUrl.replace(/\/+$/, '');
    this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`;
    this.headers = {
      Authorization: `token ${apiToken}`,
      'Content-Type': 'application/json',
    };
  }

  async readFile(path: string): Promise<Uint8Array> {
    const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!resp.ok) {
      throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`);
    }
    const data = await resp.json();
    const decoded = atob(data.content);
    const bytes = new Uint8Array(decoded.length);
    for (let i = 0; i < decoded.length; i++) {
      bytes[i] = decoded.charCodeAt(i);
    }
    return bytes;
  }

  async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
    // Check if file exists to get its SHA
    let sha: string | undefined;
    try {
      const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
      if (resp.ok) {
        const data = await resp.json();
        sha = data.sha;
      }
    } catch {
      // File doesn't exist, that's fine
    }

    const body: Record<string, string> = {
      message,
      content: uint8ArrayToBase64(content),
    };
    if (sha) {
      body.sha = sha;
    }

    const method = sha ? 'PUT' : 'POST';
    const resp = await fetch(`${this.baseUrl}/${path}`, {
      method,
      headers: this.headers,
      body: JSON.stringify(body),
    });
    if (!resp.ok) {
      throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`);
    }
  }

  async deleteFile(path: string, message: string): Promise<void> {
    // Get file SHA first
    const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!getResp.ok) {
      throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`);
    }
    const data = await getResp.json();

    const resp = await fetch(`${this.baseUrl}/${path}`, {
      method: 'DELETE',
      headers: this.headers,
      body: JSON.stringify({ message, sha: data.sha }),
    });
    if (!resp.ok) {
      throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`);
    }
  }

  async listDir(path: string): Promise<string[]> {
    const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!resp.ok) {
      if (resp.status === 404) return [];
      throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`);
    }
    const data = await resp.json();
    if (!Array.isArray(data)) return [];
    return data.map((item: { name: string }) => item.name);
  }
}

function uint8ArrayToBase64(bytes: Uint8Array): string {
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}
  • Step 3: Create GitHub implementation

Create extension/src/service-worker/github.ts:

import type { GitHost } from './git-host';

export class GitHubHost implements GitHost {
  private baseUrl: string;
  private headers: HeadersInit;

  constructor(_hostUrl: string, repoPath: string, apiToken: string) {
    this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
    this.headers = {
      Authorization: `Bearer ${apiToken}`,
      Accept: 'application/vnd.github.v3+json',
      'Content-Type': 'application/json',
    };
  }

  async readFile(path: string): Promise<Uint8Array> {
    const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!resp.ok) {
      throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`);
    }
    const data = await resp.json();
    const decoded = atob(data.content.replace(/\n/g, ''));
    const bytes = new Uint8Array(decoded.length);
    for (let i = 0; i < decoded.length; i++) {
      bytes[i] = decoded.charCodeAt(i);
    }
    return bytes;
  }

  async writeFile(path: string, content: Uint8Array, message: string): Promise<void> {
    let sha: string | undefined;
    try {
      const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
      if (resp.ok) {
        const data = await resp.json();
        sha = data.sha;
      }
    } catch {
      // File doesn't exist
    }

    const body: Record<string, string> = {
      message,
      content: uint8ArrayToBase64(content),
    };
    if (sha) {
      body.sha = sha;
    }

    const resp = await fetch(`${this.baseUrl}/${path}`, {
      method: 'PUT',
      headers: this.headers,
      body: JSON.stringify(body),
    });
    if (!resp.ok) {
      throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`);
    }
  }

  async deleteFile(path: string, message: string): Promise<void> {
    const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!getResp.ok) {
      throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`);
    }
    const data = await getResp.json();

    const resp = await fetch(`${this.baseUrl}/${path}`, {
      method: 'DELETE',
      headers: this.headers,
      body: JSON.stringify({ message, sha: data.sha }),
    });
    if (!resp.ok) {
      throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`);
    }
  }

  async listDir(path: string): Promise<string[]> {
    const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
    if (!resp.ok) {
      if (resp.status === 404) return [];
      throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`);
    }
    const data = await resp.json();
    if (!Array.isArray(data)) return [];
    return data.map((item: { name: string }) => item.name);
  }
}

function uint8ArrayToBase64(bytes: Uint8Array): string {
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}
  • Step 4: Commit
git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts
git commit -m "feat: add git API layer with Gitea and GitHub implementations"

Task 6: Service Worker — Vault Operations

Files:

  • Create: extension/src/service-worker/vault.ts

  • Step 1: Create vault operations module

Create extension/src/service-worker/vault.ts:

import type { GitHost } from './git-host';
import type { Entry, Manifest, ManifestEntry } from '../shared/types';

// These will be set by the service worker index after WASM init
let wasm: typeof import('../../wasm/idfoto_wasm');

export function setWasm(w: typeof wasm) {
  wasm = w;
}

export async function fetchVaultMeta(git: GitHost): Promise<{ salt: Uint8Array; paramsJson: string }> {
  const salt = await git.readFile('.idfoto/salt');
  const paramsBytes = await git.readFile('.idfoto/params.json');
  const paramsJson = new TextDecoder().decode(paramsBytes);
  return { salt, paramsJson };
}

export async function fetchAndDecryptManifest(git: GitHost, masterKey: Uint8Array): Promise<Manifest> {
  const encBytes = await git.readFile('manifest.enc');
  const json = wasm.decrypt_manifest(encBytes, masterKey);
  return JSON.parse(json);
}

export async function fetchAndDecryptEntry(git: GitHost, masterKey: Uint8Array, id: string): Promise<Entry> {
  const encBytes = await git.readFile(`entries/${id}.enc`);
  const json = wasm.decrypt_entry(encBytes, masterKey);
  return JSON.parse(json);
}

export async function encryptAndWriteEntry(
  git: GitHost,
  masterKey: Uint8Array,
  id: string,
  entry: Entry,
  commitMsg: string
): Promise<void> {
  const json = JSON.stringify(entry);
  const encBytes = wasm.encrypt_entry(json, masterKey);
  await git.writeFile(`entries/${id}.enc`, encBytes, commitMsg);
}

export async function encryptAndWriteManifest(
  git: GitHost,
  masterKey: Uint8Array,
  manifest: Manifest,
  commitMsg: string
): Promise<void> {
  const json = JSON.stringify(manifest);
  const encBytes = wasm.encrypt_manifest(json, masterKey);
  await git.writeFile('manifest.enc', encBytes, commitMsg);
}

export function listEntries(manifest: Manifest, group?: string): Array<{ id: string } & ManifestEntry> {
  const entries = Object.entries(manifest.entries).map(([id, entry]) => ({ id, ...entry }));
  if (group) {
    return entries.filter((e) => e.group === group);
  }
  return entries;
}

export function searchEntries(manifest: Manifest, query: string): Array<{ id: string } & ManifestEntry> {
  const q = query.toLowerCase();
  return Object.entries(manifest.entries)
    .filter(([, e]) => {
      return (
        e.name.toLowerCase().includes(q) ||
        (e.url && e.url.toLowerCase().includes(q)) ||
        (e.username && e.username.toLowerCase().includes(q))
      );
    })
    .map(([id, entry]) => ({ id, ...entry }));
}

export function findByUrl(manifest: Manifest, url: string): Array<{ id: string } & ManifestEntry> {
  let hostname: string;
  try {
    hostname = new URL(url).hostname;
  } catch {
    return [];
  }
  return Object.entries(manifest.entries)
    .filter(([, e]) => {
      if (!e.url) return false;
      try {
        return new URL(e.url).hostname === hostname;
      } catch {
        return false;
      }
    })
    .map(([id, entry]) => ({ id, ...entry }));
}
  • Step 2: Commit
git add extension/src/service-worker/vault.ts
git commit -m "feat: add vault operations module for service worker"

Task 7: Service Worker — Main Entry Point

Files:

  • Create: extension/src/service-worker/index.ts

  • Step 1: Create the service worker entry point

Create extension/src/service-worker/index.ts:

import type { Manifest, VaultConfig, SetupState } from '../shared/types';
import type { Request, Response } from '../shared/messages';
import { createGitHost, type GitHost } from './git-host';
import {
  setWasm,
  fetchVaultMeta,
  fetchAndDecryptManifest,
  fetchAndDecryptEntry,
  encryptAndWriteEntry,
  encryptAndWriteManifest,
  listEntries,
  searchEntries,
  findByUrl,
} from './vault';

// ─── State ──────────────────────────────────────────────────────────────────

let masterKey: Uint8Array | null = null;
let manifest: Manifest | null = null;
let gitHost: GitHost | null = null;
let wasm: typeof import('../../wasm/idfoto_wasm') | null = null;

// ─── WASM initialization ───────────────────────────────────────────────────

async function initWasm(): Promise<typeof import('../../wasm/idfoto_wasm')> {
  if (wasm) return wasm;
  const mod = await import(/* webpackIgnore: true */ './idfoto_wasm.js');
  await mod.default();
  wasm = mod;
  setWasm(mod);
  return mod;
}

// ─── Config helpers ─────────────────────────────────────────────────────────

async function loadConfig(): Promise<{ config: VaultConfig; imageBase64: string } | null> {
  const data = await chrome.storage.local.get(['vaultConfig', 'imageBase64']);
  if (!data.vaultConfig || !data.imageBase64) return null;
  return { config: data.vaultConfig, imageBase64: data.imageBase64 };
}

async function saveConfig(config: VaultConfig, imageBase64: string): Promise<void> {
  await chrome.storage.local.set({ vaultConfig: config, imageBase64 });
}

function base64ToUint8Array(base64: string): Uint8Array {
  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

function isoNow(): string {
  return new Date().toISOString();
}

// ─── Message handler ────────────────────────────────────────────────────────

chrome.runtime.onMessage.addListener(
  (request: Request, _sender, sendResponse: (response: Response) => void) => {
    handleMessage(request)
      .then(sendResponse)
      .catch((err) => sendResponse({ error: String(err) }));
    return true; // async response
  }
);

async function handleMessage(req: Request): Promise<Response> {
  const w = await initWasm();

  switch (req.type) {
    case 'get_setup_state': {
      const stored = await loadConfig();
      const state: SetupState = {
        config: stored?.config ?? null,
        imageBase64: stored?.imageBase64 ?? null,
        isConfigured: stored !== null,
      };
      return { ok: true, state } as Response;
    }

    case 'save_setup': {
      await saveConfig(req.config, req.imageBase64);
      return { ok: true };
    }

    case 'is_unlocked': {
      return { ok: true, unlocked: masterKey !== null } as Response;
    }

    case 'unlock': {
      const stored = await loadConfig();
      if (!stored) return { error: 'Extension not configured. Run setup first.' };

      const imageBytes = base64ToUint8Array(stored.imageBase64);
      const imageSecret = w.extract_image_secret(imageBytes);

      gitHost = createGitHost(
        stored.config.hostType,
        stored.config.hostUrl,
        stored.config.repoPath,
        stored.config.apiToken
      );

      const { salt, paramsJson } = await fetchVaultMeta(gitHost);
      const key = w.derive_master_key(req.passphrase, imageSecret, salt, paramsJson);
      masterKey = new Uint8Array(key);

      manifest = await fetchAndDecryptManifest(gitHost, masterKey);
      return { ok: true };
    }

    case 'lock': {
      masterKey = null;
      manifest = null;
      return { ok: true };
    }

    case 'list_entries': {
      if (!manifest) return { error: 'Vault is locked' };
      const entries = listEntries(manifest, req.group);
      return { ok: true, entries } as Response;
    }

    case 'search_entries': {
      if (!manifest) return { error: 'Vault is locked' };
      const entries = searchEntries(manifest, req.query);
      return { ok: true, entries } as Response;
    }

    case 'get_entry': {
      if (!masterKey || !gitHost) return { error: 'Vault is locked' };
      const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
      return { ok: true, entry } as Response;
    }

    case 'add_entry': {
      if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
      const now = isoNow();
      const entry = { ...req.entry, created_at: now, updated_at: now };
      const id = w.generate_entry_id();

      await encryptAndWriteEntry(gitHost, masterKey, id, entry, `add entry '${entry.name}'`);

      manifest.entries[id] = {
        name: entry.name,
        url: entry.url,
        username: entry.username,
        group: entry.group,
        updated_at: now,
      };
      await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: add '${entry.name}'`);

      return { ok: true, id } as Response;
    }

    case 'update_entry': {
      if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
      const existing = manifest.entries[req.id];
      if (!existing) return { error: `Entry ${req.id} not found` };

      const now = isoNow();
      const oldEntry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
      const updated = { ...req.entry, created_at: oldEntry.created_at, updated_at: now };

      await encryptAndWriteEntry(gitHost, masterKey, req.id, updated, `edit entry '${updated.name}'`);

      manifest.entries[req.id] = {
        name: updated.name,
        url: updated.url,
        username: updated.username,
        group: updated.group,
        updated_at: now,
      };
      await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: edit '${updated.name}'`);

      return { ok: true };
    }

    case 'delete_entry': {
      if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' };
      const entry = manifest.entries[req.id];
      if (!entry) return { error: `Entry ${req.id} not found` };

      await gitHost.deleteFile(`entries/${req.id}.enc`, `remove entry '${entry.name}'`);
      delete manifest.entries[req.id];
      await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: remove '${entry.name}'`);

      return { ok: true };
    }

    case 'get_totp': {
      if (!masterKey || !gitHost) return { error: 'Vault is locked' };
      const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
      if (!entry.totp_secret) return { error: 'No TOTP secret for this entry' };

      const now = Math.floor(Date.now() / 1000);
      const code = w.generate_totp(entry.totp_secret, BigInt(now));
      const remaining = 30 - (now % 30);
      return { ok: true, code, remaining_seconds: remaining } as Response;
    }

    case 'get_autofill_candidates': {
      if (!manifest) return { error: 'Vault is locked' };
      const candidates = findByUrl(manifest, req.url);
      return { ok: true, candidates } as Response;
    }

    case 'get_credentials': {
      if (!masterKey || !gitHost) return { error: 'Vault is locked' };
      const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id);
      return { ok: true, username: entry.username ?? '', password: entry.password } as Response;
    }

    case 'sync': {
      if (!masterKey || !gitHost) return { error: 'Vault is locked' };
      manifest = await fetchAndDecryptManifest(gitHost, masterKey);
      return { ok: true };
    }

    case 'fill_credentials': {
      // This is handled by the content script directly, not the service worker
      return { ok: true };
    }

    default:
      return { error: `Unknown message type: ${(req as Request).type}` };
  }
}
  • Step 2: Commit
git add extension/src/service-worker/index.ts
git commit -m "feat: add service worker with WASM init, state management, and message router"

Task 8: Popup — Styles

Files:

  • Create: extension/src/popup/styles.css

  • Step 1: Create the terminal dark theme

Create extension/src/popup/styles.css:

/* ─── Reset & Base ───────────────────────────────────────────────────────── */

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  width: 360px;
  min-height: 200px;
  max-height: 500px;
  overflow-y: auto;
  background: #0d1117;
  color: #c9d1d9;
  font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', 'Menlo', monospace;
  font-size: 12px;
  line-height: 1.5;
}

#app {
  padding: 16px;
}

/* ─── Typography ─────────────────────────────────────────────────────────── */

.brand {
  color: #58a6ff;
  font-size: 14px;
  letter-spacing: 2px;
  font-weight: normal;
}

.label {
  color: #484f58;
  font-size: 10px;
  text-transform: uppercase;
  margin-bottom: 2px;
}

.secondary {
  color: #8b949e;
}

.muted {
  color: #484f58;
}

.error {
  color: #f85149;
  font-size: 11px;
}

/* ─── Inputs ─────────────────────────────────────────────────────────────── */

input, textarea {
  background: #161b22;
  border: 1px solid #30363d;
  color: #c9d1d9;
  font-family: inherit;
  font-size: 12px;
  padding: 6px 10px;
  border-radius: 2px;
  width: 100%;
  outline: none;
}

input:focus, textarea:focus {
  border-color: #58a6ff;
}

input::placeholder, textarea::placeholder {
  color: #484f58;
}

/* ─── Buttons ────────────────────────────────────────────────────────────── */

.btn {
  background: #21262d;
  border: 1px solid #30363d;
  color: #c9d1d9;
  font-family: inherit;
  font-size: 11px;
  padding: 4px 12px;
  border-radius: 2px;
  cursor: pointer;
}

.btn:hover {
  background: #30363d;
}

.btn-primary {
  background: #1f6feb;
  border-color: #1f6feb;
  color: #fff;
}

.btn-primary:hover {
  background: #388bfd;
}

.btn-danger {
  background: #da3633;
  border-color: #da3633;
  color: #fff;
}

.btn-danger:hover {
  background: #f85149;
}

/* ─── Header ─────────────────────────────────────────────────────────────── */

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.status {
  color: #484f58;
  font-size: 10px;
}

/* ─── Search ─────────────────────────────────────────────────────────────── */

.search-bar {
  margin-bottom: 8px;
}

/* ─── Group Tabs ─────────────────────────────────────────────────────────── */

.group-tabs {
  display: flex;
  gap: 6px;
  margin-bottom: 10px;
  flex-wrap: wrap;
}

.group-tab {
  background: #21262d;
  border: 1px solid #30363d;
  padding: 2px 8px;
  border-radius: 2px;
  font-size: 10px;
  color: #8b949e;
  cursor: pointer;
  font-family: inherit;
}

.group-tab:hover {
  background: #30363d;
}

.group-tab.active {
  background: #1f6feb;
  border-color: #1f6feb;
  color: #fff;
}

/* ─── Entry List ─────────────────────────────────────────────────────────── */

.entry-list {
  max-height: 300px;
  overflow-y: auto;
}

.entry-row {
  padding: 6px 10px;
  border-left: 2px solid transparent;
  cursor: pointer;
}

.entry-row:hover {
  background: #161b22;
}

.entry-row.selected {
  border-left-color: #58a6ff;
  background: #161b22;
}

.entry-name {
  color: #c9d1d9;
  font-size: 12px;
}

.entry-meta {
  color: #484f58;
  font-size: 10px;
}

/* ─── Entry Detail ───────────────────────────────────────────────────────── */

.detail-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.detail-title {
  color: #58a6ff;
  font-size: 14px;
}

.field {
  margin-bottom: 8px;
}

.field-value {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.field-value .hint {
  color: #484f58;
  font-size: 10px;
}

/* ─── TOTP ───────────────────────────────────────────────────────────────── */

.totp-code {
  color: #3fb950;
  font-size: 16px;
  letter-spacing: 4px;
}

.totp-bar {
  height: 2px;
  background: #21262d;
  margin-top: 4px;
  border-radius: 1px;
}

.totp-bar-fill {
  height: 2px;
  background: #3fb950;
  border-radius: 1px;
  transition: width 1s linear;
}

/* ─── Footer / Keybinding Hints ──────────────────────────────────────────── */

.keyhints {
  color: #484f58;
  font-size: 10px;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid #21262d;
}

/* ─── Setup Wizard ───────────────────────────────────────────────────────── */

.wizard-step {
  color: #484f58;
  font-size: 10px;
  margin-bottom: 4px;
}

.progress-bar {
  height: 2px;
  background: #21262d;
  margin-bottom: 16px;
  border-radius: 1px;
}

.progress-bar-fill {
  height: 2px;
  background: #1f6feb;
  border-radius: 1px;
  transition: width 0.3s;
}

.form-group {
  margin-bottom: 12px;
}

.host-toggle {
  display: flex;
  gap: 8px;
  margin-bottom: 12px;
}

/* ─── Spinner ────────────────────────────────────────────────────────────── */

.spinner {
  display: inline-block;
  width: 12px;
  height: 12px;
  border: 2px solid #30363d;
  border-top-color: #58a6ff;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* ─── Actions Bar ────────────────────────────────────────────────────────── */

.actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
  margin-top: 12px;
}

/* ─── Confirm Dialog ─────────────────────────────────────────────────────── */

.confirm-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}

.confirm-box {
  background: #161b22;
  border: 1px solid #30363d;
  padding: 16px;
  border-radius: 4px;
  max-width: 300px;
}

/* ─── Empty State ────────────────────────────────────────────────────────── */

.empty {
  color: #484f58;
  text-align: center;
  padding: 24px 0;
  font-size: 11px;
}

/* ─── Scrollbar ──────────────────────────────────────────────────────────── */

::-webkit-scrollbar {
  width: 4px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: #30363d;
  border-radius: 2px;
}
  • Step 2: Commit
git add extension/src/popup/styles.css
git commit -m "feat: add terminal dark theme for popup"

Task 9: Popup — State Machine and Components

Files:

  • Create: extension/src/popup/popup.ts
  • Create: extension/src/popup/components/unlock.ts
  • Create: extension/src/popup/components/entry-list.ts
  • Create: extension/src/popup/components/entry-detail.ts
  • Create: extension/src/popup/components/entry-form.ts
  • Create: extension/src/popup/components/setup-wizard.ts

This is the largest task. Each component is a function that renders into a container element and attaches its own event listeners. The popup state machine orchestrates transitions.

  • Step 1: Create the popup state machine

Create extension/src/popup/popup.ts:

import type { Request, Response } from '../shared/messages';
import { renderUnlock } from './components/unlock';
import { renderEntryList } from './components/entry-list';
import { renderEntryDetail } from './components/entry-detail';
import { renderEntryForm } from './components/entry-form';
import { renderSetupWizard } from './components/setup-wizard';

export type PopupState =
  | { view: 'setup' }
  | { view: 'locked' }
  | { view: 'list' }
  | { view: 'detail'; entryId: string }
  | { view: 'add' }
  | { view: 'edit'; entryId: string };

let currentState: PopupState = { view: 'locked' };

export function sendMessage(request: Request): Promise<Response> {
  return chrome.runtime.sendMessage(request);
}

export function navigate(state: PopupState) {
  currentState = state;
  render();
}

function render() {
  const app = document.getElementById('app')!;
  app.innerHTML = '';

  switch (currentState.view) {
    case 'setup':
      renderSetupWizard(app);
      break;
    case 'locked':
      renderUnlock(app);
      break;
    case 'list':
      renderEntryList(app);
      break;
    case 'detail':
      renderEntryDetail(app, currentState.entryId);
      break;
    case 'add':
      renderEntryForm(app, null);
      break;
    case 'edit':
      renderEntryForm(app, currentState.entryId);
      break;
  }
}

// ─── Init ───────────────────────────────────────────────────────────────────

async function init() {
  const resp = await sendMessage({ type: 'get_setup_state' });
  if ('error' in resp) {
    navigate({ view: 'setup' });
    return;
  }

  const state = (resp as { ok: true; state: import('../shared/types').SetupState }).state;
  if (!state.isConfigured) {
    navigate({ view: 'setup' });
    return;
  }

  const unlockResp = await sendMessage({ type: 'is_unlocked' });
  if ('error' in unlockResp) {
    navigate({ view: 'locked' });
    return;
  }

  const unlocked = (unlockResp as { ok: true; unlocked: boolean }).unlocked;
  navigate(unlocked ? { view: 'list' } : { view: 'locked' });
}

document.addEventListener('DOMContentLoaded', init);
  • Step 2: Create unlock component

Create extension/src/popup/components/unlock.ts:

import { sendMessage, navigate } from '../popup';

export function renderUnlock(container: HTMLElement) {
  container.innerHTML = `
    <div class="brand">idfoto</div>
    <div style="margin-top: 16px">
      <div class="label">PASSPHRASE</div>
      <input type="password" id="passphrase" placeholder="Enter passphrase..." autofocus>
      <div id="unlock-error" class="error" style="margin-top: 4px"></div>
      <div id="unlock-spinner" style="margin-top: 8px; display: none">
        <span class="spinner"></span> <span class="secondary">Deriving key...</span>
      </div>
    </div>
    <div class="actions" style="margin-top: 16px">
      <button class="btn muted" id="btn-settings">settings</button>
      <button class="btn-primary btn" id="btn-unlock">ENTER unlock</button>
    </div>
  `;

  const input = container.querySelector('#passphrase') as HTMLInputElement;
  const errorEl = container.querySelector('#unlock-error') as HTMLElement;
  const spinnerEl = container.querySelector('#unlock-spinner') as HTMLElement;
  const btnUnlock = container.querySelector('#btn-unlock') as HTMLButtonElement;
  const btnSettings = container.querySelector('#btn-settings') as HTMLButtonElement;

  async function doUnlock() {
    const passphrase = input.value;
    if (!passphrase) return;

    errorEl.textContent = '';
    spinnerEl.style.display = 'block';
    btnUnlock.disabled = true;

    const resp = await sendMessage({ type: 'unlock', passphrase });
    spinnerEl.style.display = 'none';
    btnUnlock.disabled = false;

    if ('error' in resp) {
      errorEl.textContent = resp.error;
      input.value = '';
      input.focus();
    } else {
      navigate({ view: 'list' });
    }
  }

  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') doUnlock();
    if (e.key === 'Escape') window.close();
  });

  btnUnlock.addEventListener('click', doUnlock);
  btnSettings.addEventListener('click', () => navigate({ view: 'setup' }));
}
  • Step 3: Create entry list component

Create extension/src/popup/components/entry-list.ts:

import { sendMessage, navigate } from '../popup';
import type { ManifestEntry } from '../../shared/types';

export async function renderEntryList(container: HTMLElement) {
  container.innerHTML = `
    <div class="header">
      <div class="brand">idfoto</div>
      <div class="status">🔓 unlocked</div>
    </div>
    <div class="search-bar">
      <input type="text" id="search" placeholder="/ search...">
    </div>
    <div class="group-tabs" id="group-tabs"></div>
    <div class="entry-list" id="entry-list">
      <div class="empty">Loading...</div>
    </div>
    <div class="keyhints">↑↓ navigate · ENTER open · / search · + add</div>
  `;

  const searchInput = container.querySelector('#search') as HTMLInputElement;
  const groupTabsEl = container.querySelector('#group-tabs') as HTMLElement;
  const listEl = container.querySelector('#entry-list') as HTMLElement;

  let entries: Array<{ id: string } & ManifestEntry> = [];
  let selectedIndex = 0;
  let activeGroup: string | undefined;

  async function loadEntries() {
    const resp = await sendMessage({ type: 'list_entries', group: activeGroup });
    if ('error' in resp) return;
    entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
    entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
    selectedIndex = 0;
    renderList();
    renderGroupTabs();
  }

  function renderGroupTabs() {
    // Get all groups from entries (fetch all, not filtered)
    sendMessage({ type: 'list_entries' }).then((resp) => {
      if ('error' in resp) return;
      const allEntries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
      const groups = new Set<string>();
      allEntries.forEach((e) => { if (e.group) groups.add(e.group); });

      let html = `<button class="group-tab ${!activeGroup ? 'active' : ''}" data-group="">all</button>`;
      for (const g of Array.from(groups).sort()) {
        html += `<button class="group-tab ${activeGroup === g ? 'active' : ''}" data-group="${g}">${g}</button>`;
      }
      groupTabsEl.innerHTML = html;

      groupTabsEl.querySelectorAll('.group-tab').forEach((btn) => {
        btn.addEventListener('click', () => {
          const g = (btn as HTMLElement).dataset.group;
          activeGroup = g || undefined;
          loadEntries();
        });
      });
    });
  }

  function renderList() {
    if (entries.length === 0) {
      listEl.innerHTML = '<div class="empty">No entries found.</div>';
      return;
    }

    listEl.innerHTML = entries
      .map((e, i) => {
        const domain = e.url ? extractDomain(e.url) : '';
        const meta = [e.username, domain].filter(Boolean).join(' · ');
        return `<div class="entry-row ${i === selectedIndex ? 'selected' : ''}" data-index="${i}" data-id="${e.id}">
          <div class="entry-name">${escapeHtml(e.name)}</div>
          <div class="entry-meta">${escapeHtml(meta)}</div>
        </div>`;
      })
      .join('');

    listEl.querySelectorAll('.entry-row').forEach((row) => {
      row.addEventListener('click', () => {
        const id = (row as HTMLElement).dataset.id!;
        navigate({ view: 'detail', entryId: id });
      });
    });
  }

  function handleSearch() {
    const query = searchInput.value.trim();
    if (!query) {
      loadEntries();
      return;
    }
    sendMessage({ type: 'search_entries', query }).then((resp) => {
      if ('error' in resp) return;
      entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries;
      entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
      selectedIndex = 0;
      renderList();
    });
  }

  searchInput.addEventListener('input', handleSearch);

  document.addEventListener('keydown', function handler(e) {
    // Clean up when we navigate away
    if (!container.isConnected) {
      document.removeEventListener('keydown', handler);
      return;
    }

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      if (selectedIndex < entries.length - 1) {
        selectedIndex++;
        renderList();
      }
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      if (selectedIndex > 0) {
        selectedIndex--;
        renderList();
      }
    } else if (e.key === 'Enter' && document.activeElement !== searchInput) {
      if (entries[selectedIndex]) {
        navigate({ view: 'detail', entryId: entries[selectedIndex].id });
      }
    } else if (e.key === '/' && document.activeElement !== searchInput) {
      e.preventDefault();
      searchInput.focus();
    } else if (e.key === '+' && document.activeElement !== searchInput) {
      e.preventDefault();
      navigate({ view: 'add' });
    } else if (e.key === 'Escape') {
      if (document.activeElement === searchInput) {
        searchInput.value = '';
        searchInput.blur();
        loadEntries();
      } else {
        window.close();
      }
    }
  });

  await loadEntries();
}

function extractDomain(url: string): string {
  try {
    return new URL(url).hostname;
  } catch {
    return url;
  }
}

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}
  • Step 4: Create entry detail component

Create extension/src/popup/components/entry-detail.ts:

import { sendMessage, navigate } from '../popup';
import type { Entry } from '../../shared/types';

export async function renderEntryDetail(container: HTMLElement, entryId: string) {
  container.innerHTML = `
    <div class="detail-header">
      <span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
      <span class="muted" id="entry-group"></span>
    </div>
    <div class="detail-title" id="entry-name">Loading...</div>
    <div id="entry-fields" style="margin-top: 12px"></div>
    <div class="keyhints">f fill · e edit · d delete</div>
  `;

  const backBtn = container.querySelector('#back') as HTMLElement;
  const nameEl = container.querySelector('#entry-name') as HTMLElement;
  const groupEl = container.querySelector('#entry-group') as HTMLElement;
  const fieldsEl = container.querySelector('#entry-fields') as HTMLElement;

  backBtn.addEventListener('click', () => navigate({ view: 'list' }));

  const resp = await sendMessage({ type: 'get_entry', id: entryId });
  if ('error' in resp) {
    nameEl.textContent = 'Error loading entry';
    return;
  }

  const entry = (resp as { ok: true; entry: Entry }).entry;
  nameEl.textContent = entry.name;
  groupEl.textContent = entry.group ?? '';

  let fieldsHtml = '';

  if (entry.url) {
    fieldsHtml += `
      <div class="field">
        <div class="label">URL</div>
        <div class="secondary">${escapeHtml(entry.url)}</div>
      </div>`;
  }

  if (entry.username) {
    fieldsHtml += `
      <div class="field">
        <div class="label">USERNAME</div>
        <div class="field-value">
          <span>${escapeHtml(entry.username)}</span>
          <span class="hint" data-copy="username">c copy</span>
        </div>
      </div>`;
  }

  fieldsHtml += `
    <div class="field">
      <div class="label">PASSWORD</div>
      <div class="field-value">
        <span id="pw-display">••••••••••</span>
        <span class="hint" data-copy="password">p copy</span>
      </div>
    </div>`;

  if (entry.totp_secret) {
    fieldsHtml += `
      <div class="field">
        <div class="label">TOTP</div>
        <div class="field-value">
          <span class="totp-code" id="totp-code">------</span>
          <span class="hint"><span id="totp-remaining">--</span>s · <span data-copy="totp">t copy</span></span>
        </div>
        <div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill"></div></div>
      </div>`;
  }

  if (entry.notes) {
    fieldsHtml += `
      <div class="field">
        <div class="label">NOTES</div>
        <div class="secondary" style="white-space: pre-wrap">${escapeHtml(entry.notes)}</div>
      </div>`;
  }

  fieldsEl.innerHTML = fieldsHtml;

  // ─── TOTP countdown ────────────────────────────────────────────────────

  let totpInterval: ReturnType<typeof setInterval> | null = null;

  if (entry.totp_secret) {
    async function updateTotp() {
      const totpResp = await sendMessage({ type: 'get_totp', id: entryId });
      if ('error' in totpResp) return;
      const { code, remaining_seconds } = totpResp as { ok: true; code: string; remaining_seconds: number };

      const codeEl = container.querySelector('#totp-code');
      const remainEl = container.querySelector('#totp-remaining');
      const barEl = container.querySelector('#totp-bar-fill') as HTMLElement | null;

      if (codeEl) codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3);
      if (remainEl) remainEl.textContent = String(remaining_seconds);
      if (barEl) barEl.style.width = `${(remaining_seconds / 30) * 100}%`;
    }

    await updateTotp();
    totpInterval = setInterval(updateTotp, 1000);
  }

  // ─── Copy handlers ────────────────────────────────────────────────────

  async function copyToClipboard(text: string) {
    await navigator.clipboard.writeText(text);
  }

  container.querySelectorAll('[data-copy]').forEach((el) => {
    el.addEventListener('click', async () => {
      const field = (el as HTMLElement).dataset.copy;
      if (field === 'username' && entry.username) {
        await copyToClipboard(entry.username);
      } else if (field === 'password') {
        await copyToClipboard(entry.password);
      } else if (field === 'totp') {
        const totpResp = await sendMessage({ type: 'get_totp', id: entryId });
        if (!('error' in totpResp)) {
          await copyToClipboard((totpResp as { ok: true; code: string }).code);
        }
      }
    });
  });

  // ─── Keyboard shortcuts ───────────────────────────────────────────────

  document.addEventListener('keydown', function handler(e) {
    if (!container.isConnected) {
      if (totpInterval) clearInterval(totpInterval);
      document.removeEventListener('keydown', handler);
      return;
    }

    if (e.key === 'Escape') {
      navigate({ view: 'list' });
    } else if (e.key === 'c') {
      if (entry.username) copyToClipboard(entry.username);
    } else if (e.key === 'p') {
      copyToClipboard(entry.password);
    } else if (e.key === 't' && entry.totp_secret) {
      sendMessage({ type: 'get_totp', id: entryId }).then((resp) => {
        if (!('error' in resp)) {
          copyToClipboard((resp as { ok: true; code: string }).code);
        }
      });
    } else if (e.key === 'e') {
      navigate({ view: 'edit', entryId });
    } else if (e.key === 'f') {
      // Autofill active tab
      chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
        if (!tabs[0]?.id) return;
        const creds = await sendMessage({ type: 'get_credentials', id: entryId });
        if ('error' in creds) return;
        const { username, password } = creds as { ok: true; username: string; password: string };
        chrome.tabs.sendMessage(tabs[0].id, { type: 'fill_credentials', username, password });
      });
    } else if (e.key === 'd') {
      showDeleteConfirm(container, entryId, entry.name);
    }
  });
}

function showDeleteConfirm(container: HTMLElement, entryId: string, name: string) {
  const overlay = document.createElement('div');
  overlay.className = 'confirm-overlay';
  overlay.innerHTML = `
    <div class="confirm-box">
      <p>Delete <strong>${escapeHtml(name)}</strong>?</p>
      <p class="secondary" style="margin-top: 4px; font-size: 11px">This commits a removal to the vault.</p>
      <div class="actions" style="margin-top: 12px">
        <button class="btn" id="confirm-no">No</button>
        <button class="btn btn-danger" id="confirm-yes">Yes, delete</button>
      </div>
    </div>
  `;
  container.appendChild(overlay);

  overlay.querySelector('#confirm-no')!.addEventListener('click', () => overlay.remove());
  overlay.querySelector('#confirm-yes')!.addEventListener('click', async () => {
    await sendMessage({ type: 'delete_entry', id: entryId });
    navigate({ view: 'list' });
  });
}

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}
  • Step 5: Create entry form component (add/edit)

Create extension/src/popup/components/entry-form.ts:

import { sendMessage, navigate } from '../popup';
import type { Entry } from '../../shared/types';

export async function renderEntryForm(container: HTMLElement, entryId: string | null) {
  const isEdit = entryId !== null;
  let existing: Entry | null = null;

  if (isEdit) {
    const resp = await sendMessage({ type: 'get_entry', id: entryId });
    if ('error' in resp) {
      container.innerHTML = '<div class="error">Failed to load entry</div>';
      return;
    }
    existing = (resp as { ok: true; entry: Entry }).entry;
  }

  container.innerHTML = `
    <div class="detail-header">
      <span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
      <span class="brand">${isEdit ? 'edit' : 'add'}</span>
    </div>
    <div class="form-group">
      <div class="label">NAME *</div>
      <input type="text" id="f-name" value="${escapeAttr(existing?.name ?? '')}" autofocus>
    </div>
    <div class="form-group">
      <div class="label">URL</div>
      <input type="text" id="f-url" value="${escapeAttr(existing?.url ?? '')}" placeholder="https://...">
    </div>
    <div class="form-group">
      <div class="label">USERNAME</div>
      <input type="text" id="f-username" value="${escapeAttr(existing?.username ?? '')}">
    </div>
    <div class="form-group">
      <div class="label">PASSWORD</div>
      <div style="display:flex;gap:4px">
        <input type="text" id="f-password" value="${escapeAttr(existing?.password ?? '')}" style="flex:1">
        <button class="btn" id="btn-generate" title="Generate">gen</button>
      </div>
    </div>
    <div class="form-group">
      <div class="label">TOTP SECRET</div>
      <input type="text" id="f-totp" value="${escapeAttr(existing?.totp_secret ?? '')}" placeholder="Base32 encoded">
    </div>
    <div class="form-group">
      <div class="label">GROUP</div>
      <input type="text" id="f-group" value="${escapeAttr(existing?.group ?? '')}" placeholder="personal, work, ...">
    </div>
    <div class="form-group">
      <div class="label">NOTES</div>
      <textarea id="f-notes" rows="3">${escapeHtml(existing?.notes ?? '')}</textarea>
    </div>
    <div id="form-error" class="error"></div>
    <div class="actions">
      <button class="btn" id="btn-cancel">Cancel</button>
      <button class="btn btn-primary" id="btn-save">Save</button>
    </div>
  `;

  const backBtn = container.querySelector('#back') as HTMLElement;
  const btnCancel = container.querySelector('#btn-cancel') as HTMLButtonElement;
  const btnSave = container.querySelector('#btn-save') as HTMLButtonElement;
  const btnGenerate = container.querySelector('#btn-generate') as HTMLButtonElement;
  const errorEl = container.querySelector('#form-error') as HTMLElement;

  function goBack() {
    if (isEdit) {
      navigate({ view: 'detail', entryId: entryId! });
    } else {
      navigate({ view: 'list' });
    }
  }

  backBtn.addEventListener('click', goBack);
  btnCancel.addEventListener('click', goBack);

  btnGenerate.addEventListener('click', async () => {
    const resp = await sendMessage({ type: 'is_unlocked' }); // Just to trigger WASM load
    // Generate locally via service worker — but we can just use crypto.getRandomValues
    const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+';
    const array = new Uint8Array(24);
    crypto.getRandomValues(array);
    const password = Array.from(array, (b) => chars[b % chars.length]).join('');
    (container.querySelector('#f-password') as HTMLInputElement).value = password;
  });

  btnSave.addEventListener('click', async () => {
    const name = (container.querySelector('#f-name') as HTMLInputElement).value.trim();
    if (!name) {
      errorEl.textContent = 'Name is required';
      return;
    }

    const entry = {
      name,
      url: (container.querySelector('#f-url') as HTMLInputElement).value.trim() || undefined,
      username: (container.querySelector('#f-username') as HTMLInputElement).value.trim() || undefined,
      password: (container.querySelector('#f-password') as HTMLInputElement).value,
      totp_secret: (container.querySelector('#f-totp') as HTMLInputElement).value.trim() || undefined,
      group: (container.querySelector('#f-group') as HTMLInputElement).value.trim() || undefined,
      notes: (container.querySelector('#f-notes') as HTMLTextAreaElement).value.trim() || undefined,
    };

    btnSave.disabled = true;
    errorEl.textContent = '';

    let resp;
    if (isEdit) {
      resp = await sendMessage({ type: 'update_entry', id: entryId!, entry });
    } else {
      resp = await sendMessage({ type: 'add_entry', entry });
    }

    if ('error' in resp) {
      errorEl.textContent = resp.error;
      btnSave.disabled = false;
      return;
    }

    if (isEdit) {
      navigate({ view: 'detail', entryId: entryId! });
    } else {
      navigate({ view: 'list' });
    }
  });

  document.addEventListener('keydown', function handler(e) {
    if (!container.isConnected) {
      document.removeEventListener('keydown', handler);
      return;
    }
    if (e.key === 'Escape') goBack();
  });
}

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}

function escapeAttr(s: string): string {
  return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
  • Step 6: Create setup wizard component

Create extension/src/popup/components/setup-wizard.ts:

import { sendMessage, navigate } from '../popup';
import type { VaultConfig } from '../../shared/types';

export function renderSetupWizard(container: HTMLElement) {
  let step = 1;
  let config: Partial<VaultConfig> = { hostType: 'gitea' };
  let imageBase64: string | null = null;

  function render() {
    container.innerHTML = `
      <div class="brand">idfoto setup</div>
      <div class="wizard-step">step ${step} of 3 — ${['repository', 'reference image', 'test unlock'][step - 1]}</div>
      <div class="progress-bar"><div class="progress-bar-fill" style="width: ${(step / 3) * 100}%"></div></div>
      <div id="wizard-content"></div>
      <div id="wizard-error" class="error" style="margin-top: 8px"></div>
      <div class="actions" id="wizard-actions"></div>
    `;
    const content = container.querySelector('#wizard-content') as HTMLElement;
    const actions = container.querySelector('#wizard-actions') as HTMLElement;

    if (step === 1) renderStep1(content, actions);
    else if (step === 2) renderStep2(content, actions);
    else if (step === 3) renderStep3(content, actions);
  }

  function renderStep1(content: HTMLElement, actions: HTMLElement) {
    content.innerHTML = `
      <div class="form-group">
        <div class="label">HOST TYPE</div>
        <div class="host-toggle">
          <button class="group-tab ${config.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">gitea</button>
          <button class="group-tab ${config.hostType === 'github' ? 'active' : ''}" data-host="github">github</button>
        </div>
      </div>
      <div class="form-group">
        <div class="label">HOST URL</div>
        <input type="text" id="w-host" value="${config.hostUrl ?? ''}" placeholder="https://git.example.com">
      </div>
      <div class="form-group">
        <div class="label">REPO PATH</div>
        <input type="text" id="w-repo" value="${config.repoPath ?? ''}" placeholder="owner/repo">
      </div>
      <div class="form-group">
        <div class="label">API TOKEN</div>
        <input type="password" id="w-token" value="${config.apiToken ?? ''}" placeholder="Paste token here">
      </div>
    `;
    actions.innerHTML = `<button class="btn btn-primary" id="btn-next">Next →</button>`;

    content.querySelectorAll('[data-host]').forEach((btn) => {
      btn.addEventListener('click', () => {
        config.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
        render();
      });
    });

    actions.querySelector('#btn-next')!.addEventListener('click', () => {
      config.hostUrl = (content.querySelector('#w-host') as HTMLInputElement).value.trim();
      config.repoPath = (content.querySelector('#w-repo') as HTMLInputElement).value.trim();
      config.apiToken = (content.querySelector('#w-token') as HTMLInputElement).value.trim();

      const errorEl = container.querySelector('#wizard-error') as HTMLElement;
      if (!config.hostUrl || !config.repoPath || !config.apiToken) {
        errorEl.textContent = 'All fields are required';
        return;
      }
      step = 2;
      render();
    });
  }

  function renderStep2(content: HTMLElement, actions: HTMLElement) {
    content.innerHTML = `
      <div class="form-group">
        <div class="label">REFERENCE IMAGE</div>
        <p class="secondary" style="margin-bottom: 8px; font-size: 11px">
          Upload the JPEG with your embedded secret. Stored locally, never sent to the server.
        </p>
        <input type="file" id="w-image" accept="image/jpeg">
        <div id="image-status" class="secondary" style="margin-top: 4px; font-size: 11px">
          ${imageBase64 ? '✓ Image loaded' : 'No image selected'}
        </div>
      </div>
    `;
    actions.innerHTML = `
      <button class="btn" id="btn-back">← Back</button>
      <button class="btn btn-primary" id="btn-next">Next →</button>
    `;

    const fileInput = content.querySelector('#w-image') as HTMLInputElement;
    const statusEl = content.querySelector('#image-status') as HTMLElement;

    fileInput.addEventListener('change', () => {
      const file = fileInput.files?.[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = () => {
        const dataUrl = reader.result as string;
        // Strip the data:image/jpeg;base64, prefix
        imageBase64 = dataUrl.split(',')[1];
        statusEl.textContent = `✓ ${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
      };
      reader.readAsDataURL(file);
    });

    actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 1; render(); });
    actions.querySelector('#btn-next')!.addEventListener('click', () => {
      const errorEl = container.querySelector('#wizard-error') as HTMLElement;
      if (!imageBase64) {
        errorEl.textContent = 'Please select a reference image';
        return;
      }
      step = 3;
      render();
    });
  }

  function renderStep3(content: HTMLElement, actions: HTMLElement) {
    content.innerHTML = `
      <div class="form-group">
        <div class="label">TEST UNLOCK</div>
        <p class="secondary" style="margin-bottom: 8px; font-size: 11px">
          Enter your passphrase to verify everything works.
        </p>
        <input type="password" id="w-passphrase" placeholder="Passphrase" autofocus>
        <div id="test-spinner" style="margin-top: 8px; display: none">
          <span class="spinner"></span> <span class="secondary">Testing...</span>
        </div>
      </div>
    `;
    actions.innerHTML = `
      <button class="btn" id="btn-back">← Back</button>
      <button class="btn btn-primary" id="btn-finish">Test & Save</button>
    `;

    actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 2; render(); });
    actions.querySelector('#btn-finish')!.addEventListener('click', async () => {
      const passphrase = (content.querySelector('#w-passphrase') as HTMLInputElement).value;
      const errorEl = container.querySelector('#wizard-error') as HTMLElement;
      const spinnerEl = content.querySelector('#test-spinner') as HTMLElement;

      if (!passphrase) {
        errorEl.textContent = 'Enter your passphrase';
        return;
      }

      errorEl.textContent = '';
      spinnerEl.style.display = 'block';

      // Save config first
      await sendMessage({
        type: 'save_setup',
        config: config as VaultConfig,
        imageBase64: imageBase64!,
      });

      // Try to unlock
      const resp = await sendMessage({ type: 'unlock', passphrase });
      spinnerEl.style.display = 'none';

      if ('error' in resp) {
        errorEl.textContent = `Test failed: ${resp.error}`;
        return;
      }

      navigate({ view: 'list' });
    });
  }

  render();
}
  • Step 7: Install npm dependencies
cd extension && npm install

Expected: node_modules/ created with TypeScript, webpack, etc.

  • Step 8: Build the extension
cd extension && npm run build

Expected: dist/ directory with service-worker.js, popup.js, content.js, popup.html, styles.css, manifest.json, WASM files, and icons.

If build fails, fix any TypeScript errors and retry.

  • Step 9: Commit
git add extension/src/popup/ extension/src/service-worker/index.ts
git commit -m "feat: add popup UI with state machine, all components, and setup wizard"

Task 10: Content Script — Form Detection and Autofill

Files:

  • Create: extension/src/content/detector.ts

  • Create: extension/src/content/fill.ts

  • Create: extension/src/content/icon.ts

  • Step 1: Create the form detector and content script entry point

Create extension/src/content/detector.ts:

import { injectFieldIcons } from './icon';
import { setupFillListener } from './fill';

function detectLoginForms(): { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] {
  const passwordFields = document.querySelectorAll<HTMLInputElement>('input[type="password"]');
  const results: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] = [];

  for (const pwField of passwordFields) {
    if (!pwField.offsetParent) continue; // skip hidden fields

    const usernameField = findUsernameField(pwField);
    results.push({ passwordField: pwField, usernameField });
  }

  return results;
}

function findUsernameField(passwordField: HTMLInputElement): HTMLInputElement | null {
  const form = passwordField.closest('form');
  const scope = form ?? document;

  // Priority 1: autocomplete="username"
  const byAutocompleteUser = scope.querySelector<HTMLInputElement>('input[autocomplete="username"]');
  if (byAutocompleteUser) return byAutocompleteUser;

  // Priority 2: autocomplete="email"
  const byAutocompleteEmail = scope.querySelector<HTMLInputElement>('input[autocomplete="email"]');
  if (byAutocompleteEmail) return byAutocompleteEmail;

  // Priority 3: type="email"
  const byTypeEmail = scope.querySelector<HTMLInputElement>('input[type="email"]');
  if (byTypeEmail) return byTypeEmail;

  // Priority 4: name matching pattern
  const byNamePattern = scope.querySelector<HTMLInputElement>(
    'input[name*="user" i], input[name*="email" i], input[name*="login" i], input[name*="account" i]'
  );
  if (byNamePattern && byNamePattern !== passwordField) return byNamePattern;

  // Priority 5: nearest preceding text input in same form
  if (form) {
    const inputs = Array.from(form.querySelectorAll<HTMLInputElement>('input[type="text"], input[type="email"], input:not([type])'));
    const pwIndex = Array.from(form.querySelectorAll('input')).indexOf(passwordField);
    for (let i = inputs.length - 1; i >= 0; i--) {
      const inp = inputs[i];
      const inpIndex = Array.from(form.querySelectorAll('input')).indexOf(inp);
      if (inpIndex < pwIndex) return inp;
    }
  }

  return null;
}

// ─── Init ───────────────────────────────────────────────────────────────────

function init() {
  const forms = detectLoginForms();
  if (forms.length > 0) {
    injectFieldIcons(forms);
  }
  setupFillListener();
}

// Run detection after DOM is ready
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

// Re-detect on DOM changes (for SPAs that load forms dynamically)
const observer = new MutationObserver(() => {
  const forms = detectLoginForms();
  if (forms.length > 0) {
    injectFieldIcons(forms);
  }
});
observer.observe(document.body, { childList: true, subtree: true });
  • Step 2: Create the fill module

Create extension/src/content/fill.ts:

export function setupFillListener() {
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
    if (message.type === 'fill_credentials') {
      fillFields(message.username, message.password);
      sendResponse({ ok: true });
    }
    return false;
  });
}

export function fillFields(username: string, password: string) {
  const passwordField = document.querySelector<HTMLInputElement>('input[type="password"]');
  if (!passwordField) return;

  // Fill password
  setInputValue(passwordField, password);

  // Find and fill username
  const form = passwordField.closest('form');
  const scope = form ?? document;

  const usernameField =
    scope.querySelector<HTMLInputElement>('input[autocomplete="username"]') ??
    scope.querySelector<HTMLInputElement>('input[autocomplete="email"]') ??
    scope.querySelector<HTMLInputElement>('input[type="email"]') ??
    scope.querySelector<HTMLInputElement>(
      'input[name*="user" i], input[name*="email" i], input[name*="login" i]'
    );

  if (usernameField && username) {
    setInputValue(usernameField, username);
  }

  // Focus submit button or password field
  const submitBtn = form?.querySelector<HTMLButtonElement>('button[type="submit"], input[type="submit"]');
  if (submitBtn) {
    submitBtn.focus();
  } else {
    passwordField.focus();
  }
}

function setInputValue(input: HTMLInputElement, value: string) {
  // Use native setter to bypass React/Vue/Angular controlled component checks
  const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
  if (nativeSetter) {
    nativeSetter.call(input, value);
  } else {
    input.value = value;
  }

  // Dispatch events that frameworks listen for
  input.dispatchEvent(new Event('input', { bubbles: true }));
  input.dispatchEvent(new Event('change', { bubbles: true }));
}
  • Step 3: Create the field icon module

Create extension/src/content/icon.ts:

import type { ManifestEntry } from '../shared/types';
import { fillFields } from './fill';

const ICON_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
  <rect x="1" y="1" width="14" height="14" rx="2" fill="#1f6feb" fill-opacity="0.9"/>
  <text x="8" y="11.5" text-anchor="middle" fill="white" font-size="9" font-family="monospace" font-weight="bold">id</text>
</svg>`;

const injectedFields = new WeakSet<HTMLInputElement>();

export function injectFieldIcons(
  forms: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[]
) {
  for (const { passwordField, usernameField } of forms) {
    if (injectedFields.has(passwordField)) continue;
    injectedFields.add(passwordField);

    const icon = document.createElement('div');
    icon.innerHTML = ICON_SVG;
    icon.style.cssText = `
      position: absolute;
      right: 8px;
      top: 50%;
      transform: translateY(-50%);
      cursor: pointer;
      z-index: 10000;
      opacity: 0.8;
      transition: opacity 0.2s;
    `;
    icon.addEventListener('mouseenter', () => { icon.style.opacity = '1'; });
    icon.addEventListener('mouseleave', () => { icon.style.opacity = '0.8'; });

    // Wrap the password field in a relative container if needed
    const parent = passwordField.parentElement;
    if (parent) {
      const computed = getComputedStyle(parent);
      if (computed.position === 'static') {
        parent.style.position = 'relative';
      }
      parent.appendChild(icon);
    }

    icon.addEventListener('click', async (e) => {
      e.preventDefault();
      e.stopPropagation();

      const url = window.location.href;
      const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', url });

      if ('error' in resp || !resp.candidates || resp.candidates.length === 0) {
        // No matches — open popup
        return;
      }

      const candidates = resp.candidates as Array<{ id: string } & ManifestEntry>;

      if (candidates.length === 1) {
        // Single match — fill immediately
        const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidates[0].id });
        if (!('error' in creds)) {
          fillFields(creds.username, creds.password);
        }
      } else {
        // Multiple matches — show picker
        showPicker(icon, candidates, usernameField, passwordField);
      }
    });
  }
}

function showPicker(
  anchor: HTMLElement,
  candidates: Array<{ id: string } & ManifestEntry>,
  usernameField: HTMLInputElement | null,
  passwordField: HTMLInputElement
) {
  // Remove any existing picker
  document.querySelectorAll('.idfoto-picker').forEach((el) => el.remove());

  const picker = document.createElement('div');
  picker.className = 'idfoto-picker';
  picker.style.cssText = `
    position: absolute;
    right: 0;
    top: 100%;
    margin-top: 4px;
    background: #161b22;
    border: 1px solid #30363d;
    border-radius: 4px;
    min-width: 200px;
    z-index: 10001;
    font-family: monospace;
    font-size: 12px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.4);
  `;

  for (const candidate of candidates) {
    const row = document.createElement('div');
    row.style.cssText = `
      padding: 6px 10px;
      cursor: pointer;
      color: #c9d1d9;
      border-bottom: 1px solid #21262d;
    `;
    row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
    row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
    row.innerHTML = `
      <div>${escapeHtml(candidate.name)}</div>
      <div style="color:#484f58;font-size:10px">${escapeHtml(candidate.username ?? '')}</div>
    `;
    row.addEventListener('click', async () => {
      const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidate.id });
      if (!('error' in creds)) {
        fillFields(creds.username, creds.password);
      }
      picker.remove();
    });
    picker.appendChild(row);
  }

  anchor.parentElement?.appendChild(picker);

  // Close picker on outside click
  document.addEventListener('click', function handler(e) {
    if (!picker.contains(e.target as Node) && e.target !== anchor) {
      picker.remove();
      document.removeEventListener('click', handler);
    }
  });
}

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}
  • Step 4: Update webpack config entry for content script

The webpack config should bundle all three content script files together. Update the content entry in extension/webpack.config.js:

The entry content: './src/content/detector.ts' already imports icon.ts and fill.ts, so webpack will bundle them together. No change needed.

  • Step 5: Build and verify
cd extension && npm run build

Expected: dist/content.js contains the bundled content script.

  • Step 6: Commit
git add extension/src/content/
git commit -m "feat: add content script with form detection, field icon, and autofill"

Task 11: Build Integration and Manual Test

Files:

  • Modify: extension/webpack.config.js (if needed)

  • Create: Makefile (optional convenience)

  • Step 1: Full build from scratch

# Build WASM
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm

# Install deps and build extension
cd extension && npm install && npm run build

Expected: extension/dist/ contains all files needed to load as an unpacked Chrome extension.

  • Step 2: Note the WASM binary size
ls -lh extension/wasm/idfoto_wasm_bg.wasm
ls -lh extension/dist/idfoto_wasm_bg.wasm

Record the size for reference. If >2 MB uncompressed, consider optimizing later.

  • Step 3: Load in Chrome

Open chrome://extensions/, enable Developer Mode, click "Load unpacked", select extension/dist/. Verify:

  • Extension appears in the toolbar

  • Clicking the icon opens the popup

  • Setup wizard renders correctly

  • No console errors in the service worker (inspect via "Service Worker" link on the extensions page)

  • Step 4: Test the full flow manually

  1. Setup wizard: enter Gitea/GitHub host, repo, token, upload reference image
  2. Test unlock: enter passphrase, verify spinner + successful unlock
  3. Entry list: verify entries load from remote vault
  4. Add entry: create a new entry with a group
  5. Entry detail: verify TOTP countdown (if entry has TOTP), copy shortcuts
  6. Autofill: navigate to a login page, verify field icon appears, click to fill
  7. Lock/re-unlock: close popup, wait, reopen — verify re-unlock is required
  • Step 5: Fix any issues found during manual testing

Address any bugs or UX issues discovered during manual testing. Each fix gets its own commit.

  • Step 6: Final commit
git add -A
git commit -m "feat: complete WASM + Chrome MV3 extension build"

Task Summary

Task Description Dependencies
1 Add group field to core data model None
2 Create idfoto-wasm crate Task 1
3 Extension scaffolding Task 2
4 Shared types and messages Task 3
5 Git API layer Task 4
6 Service worker — vault operations Task 4, 5
7 Service worker — main entry point Task 5, 6
8 Popup styles Task 3
9 Popup state machine and components Task 4, 7, 8
10 Content script Task 4
11 Build integration and manual test All

Tasks 3-4, 5, 8 can be parallelized. Tasks 6-7 are sequential. Task 9 depends on 7+8. Task 10 is independent of 9. Task 11 is the final integration.