diff --git a/index.html b/index.html index 6720207..4c62ebc 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ Kestrove - +
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4ab388b..a66a395 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Main window: core IPC only. No fs, shell or http plugins.", + "description": "Main window: core IPC only, plus dragging the custom title bar and following the theme setting. No fs, shell or http plugins.", "windows": ["main"], - "permissions": ["core:default"] + "permissions": ["core:default", "core:window:allow-start-dragging", "core:window:allow-set-theme"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 026e94e..f9eec50 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -93,6 +93,7 @@ struct NewLogin { struct Totp { code: String, seconds_remaining: u64, + period: u64, } #[derive(Serialize)] @@ -268,7 +269,7 @@ fn totp(app: State<'_, App>, id: String) -> CmdResult> { let Some(secret) = app.core.get_item(id).map_err(err)?.totp else { return Ok(None) }; let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); let c = totp_code(secret, now).map_err(err)?; - Ok(Some(Totp { code: c.code, seconds_remaining: c.seconds_remaining })) + Ok(Some(Totp { code: c.code, seconds_remaining: c.seconds_remaining, period: c.period })) } /// Copies from Rust so the secret never round-trips through JS, then clears it after 90 s. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8e58b2b..7ee6d8d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,7 +10,7 @@ "frontendDist": "../dist" }, "app": { - "windows": [{ "title": "Kestrove", "width": 960, "height": 640, "minWidth": 720, "minHeight": 480 }], + "windows": [{ "title": "Kestrove", "width": 1040, "height": 680, "minWidth": 760, "minHeight": 480, "titleBarStyle": "Overlay", "hiddenTitle": true }], "security": { "csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost" } diff --git a/src/main.ts b/src/main.ts index b769caa..18b4cfd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,12 @@ -// Desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders. +// Desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders, with the +// Kestrove components in ./ui (the same ones the browser extension uses). import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { + banner, button, codeText, copyButton, dots, field, h, icon, iconButton, input, integrityBanner, itemRow, lockup, + pill, ring, secret, tile, type IconName, +} from "./ui/kv"; interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number } interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean } @@ -23,20 +29,20 @@ interface JoinConfirmView { code: string; device_name: string; platform: string; const app = document.getElementById("app")!; const overlay = document.getElementById("overlay")!; const modal = document.getElementById("modal")!; - -function el(tag: K, props: Partial & Record = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] { - const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props); - e.append(...children); - return e; -} +const isMac = navigator.userAgent.includes("Macintosh"); +document.documentElement.classList.toggle("mac", isMac); const errText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /// "ab12cd34…" → "ab12 cd34 …" so two screens are easy to compare. const groups = (hex: string) => hex.replace(/(.{4})/g, "$1 ").trim(); +const errBox = () => h("div", "kv-error"); +const setErr = (box: HTMLElement, e: unknown) => box.replaceChildren(icon("octagon", "sm"), errText(e)); +const p = (cls: string, ...children: (Node | string)[]) => h("p", cls, ...children); +const stack = (cls = "", ...children: (Node | string | null | false)[]) => h("div", `stack ${cls}`.trim(), ...children); function countdown(expiresAt: number, onExpire: () => void): HTMLElement { - const span = el("span", { className: "muted" }); + const span = h("span", "kv-caption kv-muted"); const tick = () => { if (!span.isConnected && span.dataset.started) return; span.dataset.started = "1"; @@ -57,6 +63,53 @@ function ago(unix: number): string { return new Date(unix * 1000).toLocaleString(); } +// ------------------------------------------------------------------ theme (Light, Dark, System) + +type Theme = "system" | "light" | "dark"; + +function storedTheme(): Theme { + try { + const t = localStorage.getItem("theme"); + return t === "light" || t === "dark" ? t : "system"; + } catch { + return "system"; + } +} + +function applyTheme(t: Theme) { + if (t === "system") delete document.documentElement.dataset.theme; + else document.documentElement.dataset.theme = t; + // The native title bar and window controls follow too. + void getCurrentWindow().setTheme(t === "system" ? null : t).catch(() => undefined); +} + +function setTheme(t: Theme) { + try { + localStorage.setItem("theme", t); + } catch { + // Not persisted; still applied for this run. + } + applyTheme(t); +} + +applyTheme(storedTheme()); + +// ------------------------------------------------------------------ chrome + +/// The custom title bar in `bg`: native window controls (macOS traffic lights sit over its left edge). +function titleBar(...trailing: Node[]): HTMLElement { + const bar = h("header", "titlebar", lockup(20, "kv-word sm"), h("span", "grow"), ...trailing); + bar.dataset.tauriDragRegion = ""; + bar.querySelectorAll("span").forEach((s) => (s.dataset.tauriDragRegion = "")); + return bar; +} + +/// Centered single-card screens: onboarding, unlock, the Secret Key. +function centered(card: HTMLElement) { + app.className = "auth"; + app.replaceChildren(titleBar(), h("div", "center", card)); +} + /// Bumped on every full render so polling loops of a screen that is gone stop. let screen = 0; let lastChanges = -1; @@ -77,21 +130,23 @@ type Tab = "phone" | "kit" | "paste" | "create"; async function renderOnboarding(tab: Tab = "phone") { const status = await invoke("sync_status"); - const body = el("div", { className: "grid" }); + const body = stack(); const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New account"]]; - const nav = el("div", { className: "tabs" }, ...tabs.map(([t, label]) => el("button", { - textContent: label, - className: t === tab ? "tab sel" : "tab", - onclick: async () => { + const nav = h("div", "seg", ...tabs.map(([t, label]) => { + const b = button(label, "quiet", async () => { await invoke("join_cancel"); app.replaceChildren(); screen++; await renderOnboarding(t); - }, - }))); - const card = el("div", { className: "card wide" }, el("h2", { textContent: "Set up Kestrove on this computer" }), nav, body); - if (status.key_warning) card.append(el("p", { className: "warn", textContent: status.key_warning })); - app.append(el("div", { className: "center" }, card)); + }); + b.setAttribute("aria-pressed", String(t === tab)); + return b; + })); + nav.setAttribute("role", "group"); + nav.setAttribute("aria-label", "How to set up"); + const card = h("div", "kv-card auth-card wide", h("h1", "kv-title-1", "Set up Kestrove on this computer"), nav, body); + if (status.key_warning) card.append(banner("warn", "Your device key is stored in a file", status.key_warning)); + centered(card); if (tab === "phone") phonePanel(body, status); else if (tab === "kit") kitPanel(body); else if (tab === "paste") pastePanel(body); @@ -99,26 +154,26 @@ async function renderOnboarding(tab: Tab = "phone") { } function serverInput(status: SyncStatus): HTMLInputElement { - return el("input", { type: "url", placeholder: "https://kestrove.example.com", value: status.server_url ?? "", spellcheck: false }); + return input({ type: "url", placeholder: "https://kestrove.example.com", value: status.server_url ?? "", spellcheck: false }); } function phonePanel(body: HTMLElement, status: SyncStatus) { const url = serverInput(status); - const err = el("div", { className: "err" }); - const start = el("button", { textContent: "Show QR code", className: "primary" }); + const err = errBox(); + const start = button("Show QR code", "primary"); body.append( - el("p", { className: "muted", textContent: "Your phone sends this computer everything it needs. You'll only type your master password." }), - el("label", { className: "grid" }, "Your Kestrove server", url), - start, err, + p("kv-muted", "Your phone sends this computer everything it needs. You'll only type your master password."), + field("Your Kestrove server", url, "The address you open in a browser."), + h("div", "actions", start), err, ); start.onclick = async () => { start.disabled = true; - err.textContent = ""; + err.replaceChildren(); try { const join = await invoke("join_start", { serverUrl: url.value }); showJoin(body, join); } catch (e) { - err.textContent = errText(e); + setErr(err, e); start.disabled = false; } }; @@ -129,52 +184,56 @@ function phonePanel(body: HTMLElement, status: SyncStatus) { if (!url.value) url.focus(); } +function qrCard(src: string, alt: string): HTMLElement { + const img = h("img"); + img.src = src; + img.alt = alt; + return h("div", "kv-qr", img); +} + +function steps(...items: (string | Node)[]): HTMLElement { + return h("ol", "steps kv-small kv-muted", ...items.map((i) => h("li", "", i))); +} + function showJoin(body: HTMLElement, join: JoinView) { const mine = screen; - const codeBox = el("div", { className: "code muted", textContent: "Waiting for your phone…" }); - const err = el("div", { className: "err" }); - const text = el("textarea", { readOnly: true, className: "mono small", rows: 3, value: join.qr_text }); - const copy = el("button", { textContent: "Copy", onclick: () => copyText(text) }); - const cancel = el("button", { - textContent: "Cancel", - onclick: async () => { - await invoke("join_cancel"); - await render(); - }, + const codeBox = h("div", "confirm", h("span", "kv-hint", "Waiting for your phone…")); + const err = errBox(); + const text = h("textarea", "kv-field mono kv-small"); + text.readOnly = true; + text.rows = 3; + text.value = join.qr_text; + const cancel = button("Cancel", "quiet", async () => { + await invoke("join_cancel"); + await render(); }); - body.replaceChildren( - el("ol", { className: "steps" }, - el("li", { textContent: "On your phone, open Kestrove → Settings → Add a computer." }), - el("li", { textContent: "Scan this code." }), - el("li", { textContent: "Check that both screens show the same 6 digits, then approve on the phone." })), - el("img", { src: join.qr_svg, alt: "QR code for your phone", className: "qr" }), - el("div", { className: "row" }, countdown(join.expires_at, () => { - if (mine === screen) err.textContent = "The code expired. Cancel and start again."; - })), + body.replaceChildren(h("div", "qr-sheet", + steps("Open Kestrove on your phone.", h("span", "", "Go to ", h("b", "", "Settings → Devices"), "."), h("span", "", "Choose ", h("b", "", "Scan a computer's code"), ".")), + qrCard(join.qr_svg, "QR code for your phone"), + countdown(join.expires_at, () => { + if (mine === screen) setErr(err, "The code expired. Cancel and start again."); + }), codeBox, - el("details", {}, - el("summary", { textContent: "No phone? Approve from another computer" }), - el("p", { className: "muted", textContent: "On a computer that is already signed in, open Settings → Devices → Approve a computer, and paste this join code:" }), - text, el("div", { className: "row" }, copy)), - err, - el("div", { className: "row" }, cancel), + cancel, + err), + h("details", "", + h("summary", "kv-small", "No phone? Approve from another computer"), + p("kv-small kv-muted", "On a computer that is already signed in, open Settings → Devices → Approve a computer, and paste this join code:"), + text, h("div", "actions", button("Copy join code", "secondary", () => copyText(text)))), ); void (async () => { while (mine === screen) { await sleep(2000); if (mine !== screen) return; try { - const p = await invoke("join_poll"); - if (p.code) { - codeBox.className = "code"; - codeBox.replaceChildren(el("div", { className: "muted", textContent: "Your phone should show:" }), el("div", { className: "digits", textContent: p.code })); - } - if (p.setup) { - if (mine === screen) finishSetup(body, p.setup); + const poll = await invoke("join_poll"); + if (poll.code) codeBox.replaceChildren(h("span", "kv-hint", "Your phone should show:"), codeText(poll.code, true)); + if (poll.setup) { + if (mine === screen) finishSetup(body, poll.setup); return; } } catch (e) { - err.textContent = errText(e); + setErr(err, e); return; } } @@ -182,14 +241,16 @@ function showJoin(body: HTMLElement, join: JoinView) { } function kitPanel(body: HTMLElement) { - const file = el("input", { type: "file", accept: ".png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf" }); - const err = el("div", { className: "err" }); - const open = el("button", { textContent: "Open Emergency Kit…", className: "primary", onclick: () => file.click() }); + const file = h("input"); + file.type = "file"; + file.accept = ".png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf"; + const err = errBox(); + const open = button("Open Emergency Kit…", "primary", () => file.click()); file.hidden = true; file.onchange = async () => { const f = file.files?.[0]; if (!f) return; - err.textContent = ""; + err.replaceChildren(); open.disabled = true; open.textContent = "Reading…"; try { @@ -197,7 +258,7 @@ function kitPanel(body: HTMLElement) { const view = await invoke("setup_from_file", bytes); finishSetup(body, view); } catch (e) { - err.textContent = errText(e); + setErr(err, e); } finally { open.disabled = false; open.textContent = "Open Emergency Kit…"; @@ -205,38 +266,41 @@ function kitPanel(body: HTMLElement) { } }; body.append( - el("p", { className: "muted", textContent: "Choose your Emergency Kit PDF, or a photo or screenshot of its QR code (PNG or JPEG). Another device will need to approve this computer." }), - file, open, err, + p("kv-muted", "Choose your Emergency Kit PDF, or a photo or screenshot of its QR code (PNG or JPEG). Another device will need to approve this computer."), + file, h("div", "actions", open), err, ); } function pastePanel(body: HTMLElement) { - const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://setup/v1?…", spellcheck: false }); - const err = el("div", { className: "err" }); - const next = el("button", { textContent: "Continue", className: "primary" }); + const text = h("textarea", "kv-field mono kv-small"); + text.rows = 4; + text.placeholder = "kestrove://setup/v1?…"; + text.spellcheck = false; + const err = errBox(); + const next = button("Continue", "primary"); next.onclick = async () => { - err.textContent = ""; + err.replaceChildren(); try { const view = await invoke("setup_from_text", { text: text.value }); text.value = ""; finishSetup(body, view); } catch (e) { - err.textContent = errText(e); + setErr(err, e); } }; - body.append(el("p", { className: "muted", textContent: "Paste the setup code from your Emergency Kit or from “Add a device” on another device." }), text, next, err); + body.append(field("Setup code", text, "From your Emergency Kit, or from “Add a device” on another device."), h("div", "actions", next), err); text.focus(); } /// The last step of every way in: only the master password. function finishSetup(body: HTMLElement, view: SetupView) { - const pw = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" }); - const err = el("div", { className: "err" }); - const btn = el("button", { textContent: "Sign in", className: "primary" }); - const waiting = el("div"); + const pw = input({ type: "password", autocomplete: "current-password" }); + const err = errBox(); + const btn = button("Sign in", "primary"); + const waiting = h("div"); const submit = async () => { btn.disabled = true; - err.textContent = ""; + err.replaceChildren(); btn.textContent = "Signing in…"; try { const r = await invoke<"ok" | "pending_approval">("finish_setup", { masterPassword: pw.value }); @@ -246,13 +310,12 @@ function finishSetup(body: HTMLElement, view: SetupView) { return; } const status = await invoke("sync_status"); - waiting.replaceChildren(el("div", { className: "notice" }, - el("p", { textContent: "Almost done: approve this computer on a device that is already signed in (Settings → Devices). Check that it shows this fingerprint:" }), - el("p", { className: "mono", textContent: groups(status.device_fingerprint ?? "") }), - el("p", { className: "muted", textContent: "Then come back and choose “Check again”." }))); + waiting.replaceChildren(banner("warn", "Almost done: approve this computer", + h("div", "", p("", "On a device that is already signed in, open Settings → Devices and check that it shows this fingerprint:"), + p("fingerprint", groups(status.device_fingerprint ?? "")), p("kv-muted", "Then come back and choose “Check again”.")))); btn.textContent = "Check again"; } catch (e) { - err.textContent = errText(e); + setErr(err, e); btn.textContent = "Sign in"; } btn.disabled = false; @@ -262,28 +325,30 @@ function finishSetup(body: HTMLElement, view: SetupView) { if (e.key === "Enter") void submit(); }; body.replaceChildren( - el("p", {}, "Signing in to ", el("strong", { textContent: view.server_host }), "."), - el("p", { className: "muted", textContent: view.preapproved ? "This computer is pre-approved." : "Another device will need to approve this computer." }), - pw, btn, waiting, err, - el("button", { textContent: "Start over", className: "link", onclick: async () => { await invoke("clear_setup"); await render(); } }), + p("", "Signing in to ", h("strong", "", view.server_host), "."), + p("kv-muted", view.preapproved ? "This computer is pre-approved." : "Another device will need to approve this computer."), + field("Master password", pw), h("div", "actions", btn), waiting, err, + button("Start over", "text", async () => { await invoke("clear_setup"); await render(); }), ); pw.focus(); } function createPanel(body: HTMLElement, status: SyncStatus) { const url = serverInput(status); - const code = el("input", { placeholder: "Setup code (KV_SETUP_CODE in the server's .env)", spellcheck: false }); - const pins = el("input", { placeholder: "SPKI pins, base64, comma-separated (optional)", className: "mono small", spellcheck: false }); - const pw = el("input", { type: "password", placeholder: "Master password (10+ characters)", autocomplete: "new-password" }); - const pw2 = el("input", { type: "password", placeholder: "Repeat master password", autocomplete: "new-password" }); - const err = el("div", { className: "err" }); - const btn = el("button", { textContent: "Create account", className: "primary" }); + const code = input({ spellcheck: false }); + const pins = input({ placeholder: "base64, comma-separated", spellcheck: false }, true); + const pw = input({ type: "password", autocomplete: "new-password" }); + const pw2 = input({ type: "password", autocomplete: "new-password" }); + const err = errBox(); + const btn = button("Create account", "primary"); btn.onclick = async () => { - err.textContent = ""; + err.replaceChildren(); if (pw.value !== pw2.value) { - err.textContent = "The passwords don't match."; + setErr(err, "The passwords don't match."); + pw2.classList.add("invalid"); return; } + pw2.classList.remove("invalid"); btn.disabled = true; btn.textContent = "Creating…"; try { @@ -291,55 +356,60 @@ function createPanel(body: HTMLElement, status: SyncStatus) { pw.value = pw2.value = ""; await showSecretKey(secretKey); } catch (e) { - err.textContent = errText(e); + setErr(err, e); btn.disabled = false; btn.textContent = "Create account"; // A vault created here whose server registration failed: continue to the vault (Settings → Sync retries). if ((await invoke("state")).has_account) { - body.append(el("button", { textContent: "Continue to the vault", onclick: render })); + body.append(button("Continue to the vault", "secondary", render)); } } }; body.append( - el("p", { className: "muted", textContent: "For the first device on a new server. You'll get a Secret Key: keep it with your Emergency Kit." }), - el("label", { className: "grid" }, "Server", url), - el("label", { className: "grid" }, "Setup code", code), - el("details", {}, el("summary", { textContent: "Certificate pins (advanced)" }), pins), - pw, pw2, btn, err, + p("kv-muted", "For the first device on a new server. You'll get a Secret Key: keep it with your Emergency Kit."), + field("Your Kestrove server", url), + field("Setup code", code, "KV_SETUP_CODE in your server's .env."), + h("details", "", h("summary", "kv-small", "Certificate pins (advanced)"), field("SPKI pins", pins)), + field("Master password", pw, "10 or more characters."), + field("Repeat master password", pw2), + h("div", "actions", btn), err, ); } async function showSecretKey(secretKey: string) { const kit = await invoke("kit_qr").catch(() => null); - const card = el("div", { className: "card wide" }, - el("h2", { textContent: "Your Secret Key" }), - el("p", { className: "mono big", textContent: secretKey }), - el("p", { className: "muted", textContent: "Write it down, or print this screen, and keep it with your Emergency Kit. Every new device needs it with your master password. This computer remembers it." })); + const card = h("div", "kv-card auth-card wide", + h("h1", "kv-title-1", "Your Secret Key"), + h("div", "secret-key", secret(secretKey)), + p("kv-muted", "Write it down, or print this screen, and keep it with your Emergency Kit. Every new device needs it with your master password. This computer remembers it.")); if (kit) { card.append( - el("p", { textContent: "Setup code for your Emergency Kit (server, account and Secret Key; keep it as safe as the Secret Key):" }), - el("img", { src: kit, alt: "Emergency Kit setup code", className: "qr" })); + p("", "Setup code for your Emergency Kit (server, account and Secret Key; keep it as safe as the Secret Key):"), + h("div", "center-row", qrCard(kit, "Emergency Kit setup code"))); } - card.append(el("button", { textContent: "Continue", className: "primary", onclick: render })); - app.replaceChildren(el("div", { className: "center" }, card)); + card.append(h("div", "actions", button("Continue", "primary", render))); + centered(card); } // ------------------------------------------------------------------ unlock function renderUnlock(state: AppState) { - const pw = el("input", { type: "password", placeholder: "Master password" }); - const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" }); - const err = el("div", { className: "err" }); - const btn = el("button", { textContent: "Unlock", className: "primary" }); + const pw = input({ type: "password", autocomplete: "current-password" }); + const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true); + const err = errBox(); + const btn = button("Unlock", "primary", undefined, "block"); const submit = async () => { btn.disabled = true; + err.replaceChildren(); + pw.classList.remove("invalid"); try { await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value || null }); pw.value = ""; sk.value = ""; await render(); } catch (e) { - err.textContent = errText(e); + setErr(err, e); + pw.classList.add("invalid"); btn.disabled = false; } }; @@ -347,10 +417,10 @@ function renderUnlock(state: AppState) { pw.onkeydown = (e) => { if (e.key === "Enter") void submit(); }; - const card = el("div", { className: "card" }, el("h2", { textContent: "Kestrove is locked" }), pw); - if (!state.remembers_secret_key) card.append(sk); - card.append(btn, err); - app.append(el("div", { className: "center" }, card)); + const card = h("div", "kv-card auth-card", h("div", "unlock-mark", lockup(32)), h("h1", "kv-title-1", "Unlock your vault"), field("Master password", pw)); + if (!state.remembers_secret_key) card.append(field("Secret Key", sk, "It's on your Emergency Kit. Dashes are optional.")); + card.append(err, btn); + centered(card); pw.focus(); } @@ -358,133 +428,214 @@ function renderUnlock(state: AppState) { let refreshList: (() => Promise) | null = null; let syncBox: HTMLElement | null = null; +let syncPill: HTMLElement | null = null; +let alarmBox: HTMLElement | null = null; +type Category = "all" | "favorites"; +let category: Category = "all"; +let selectedId: string | null = null; async function renderVault(state: AppState) { - const detail = el("section"); - const list = el("ul"); - const fill = async () => { - const items = await invoke("list_items"); - const selected = list.querySelector("li.sel")?.getAttribute("data-id"); + const detail = h("section", "detail"); + const list = h("div", "kv-list"); + list.setAttribute("role", "listbox"); + list.setAttribute("aria-label", "Items"); + const search = input({ type: "search", placeholder: "Search", spellcheck: false }); + search.setAttribute("aria-label", "Search items"); + let items: ItemSummary[] = []; + const draw = () => { + const q = search.value.trim().toLowerCase(); + const shown = items.filter((it) => (category === "all" || it.favorite) && (!q || it.title.toLowerCase().includes(q) || (it.username ?? "").toLowerCase().includes(q))); list.replaceChildren(); - for (const it of items) { - const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" })); - li.dataset.id = it.id; - if (it.id === selected) li.classList.add("sel"); - li.onclick = async () => { - list.querySelectorAll("li").forEach((n) => n.classList.remove("sel")); - li.classList.add("sel"); + for (const it of shown) { + const row = itemRow(it.title, it.username ?? "", { favorite: it.favorite, selected: it.id === selectedId }); + row.setAttribute("role", "option"); + row.setAttribute("aria-selected", String(it.id === selectedId)); + row.onclick = async () => { + selectedId = it.id; + list.querySelectorAll(".kv-item").forEach((n) => { n.classList.remove("sel"); n.setAttribute("aria-selected", "false"); }); + row.classList.add("sel"); + row.setAttribute("aria-selected", "true"); await renderDetail(detail, it.id); }; - list.append(li); + list.append(row); + } + if (!shown.length) { + list.append(h("div", "empty kv-small kv-muted", items.length ? "Nothing matches." : "No items yet. Choose “New item” to add a login.")); } - if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." })); }; + const fill = async () => { + items = await invoke("list_items"); + if (selectedId && !items.some((i) => i.id === selectedId)) selectedId = null; + draw(); + }; + search.oninput = draw; await fill(); refreshList = fill; - const add = el("button", { textContent: "+ New login", onclick: () => renderEditor(detail) }); - const settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) }); - const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } }); - syncBox = el("div", { className: "sync" }); - const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list, syncBox); - if (state.ipc_error) aside.insertBefore(el("p", { className: "err pad", textContent: state.ipc_error }), list); - app.append(aside, detail); + + const cat = (c: Category, label: string, ic: IconName) => { + const b = h("button", "rail-item", icon(ic, "sm"), h("span", "grow", label)); + b.type = "button"; + if (c === category) b.setAttribute("aria-current", "page"); + b.onclick = () => { + category = c; + rail.querySelectorAll(".rail-item").forEach((n) => n.removeAttribute("aria-current")); + b.setAttribute("aria-current", "page"); + draw(); + }; + return b; + }; + const settings = h("button", "rail-item", icon("settings", "sm"), h("span", "grow", "Settings")); + settings.type = "button"; + settings.onclick = () => { + rail.querySelectorAll(".rail-item").forEach((n) => n.removeAttribute("aria-current")); + settings.setAttribute("aria-current", "page"); + void renderSettings(detail, state); + }; + syncBox = h("div", "rail-foot"); + const rail = h("nav", "rail", h("div", "rail-h kv-label", "Vault"), cat("all", "All items", "key"), cat("favorites", "Favourites", "star"), + h("div", "rail-h kv-label", "Manage"), settings, h("span", "grow"), syncBox); + rail.setAttribute("aria-label", "Categories"); + if (state.ipc_error) rail.insertBefore(banner("bad", "Browser connection unavailable", state.ipc_error), syncBox); + + const add = button("New item", "quiet", () => { + selectedId = null; + draw(); + renderEditor(detail); + }); + add.prepend(icon("plus", "sm")); + const listPane = h("div", "listpane", h("div", "list-head", h("div", "search", icon("search", "sm"), search), add), list); + syncPill = h("span"); + const lock = iconButton("lock", "Lock", async () => { await invoke("lock"); await render(); }); + alarmBox = h("div", "alarm-slot"); + app.className = "shell"; + app.replaceChildren(titleBar(syncPill, lock), alarmBox, h("div", "cols", rail, listPane, detail)); + if (selectedId) await renderDetail(detail, selectedId); + else detail.append(emptyDetail()); const status = await invoke("sync_status"); lastChanges = status.changes; showSync(status, detail, state); } -/// Sync footer and banners in the sidebar; updated on every `sync-status` event. +function emptyDetail(): HTMLElement { + return h("div", "empty-detail", lockup(32), p("kv-muted", "Choose an item to see it here.")); +} + +function syncLine(s: SyncStatus): [ReturnType, string] { + if (s.integrity_alarm) return [pill("bad", "Sync stopped"), "Kestrove stopped syncing this vault."]; + if (!s.server_url) return [pill("off", "Not syncing"), "Not connected to your server."]; + if (s.pending_approval) return [pill("wait", "Waiting for approval"), "Approve this computer on another device."]; + if (s.syncing) return [pill("ok", "Syncing…"), "Syncing…"]; + if (s.last_error) return [pill("off", "Offline"), s.last_error]; + if (s.last_sync_at) return [pill("ok", "Synced"), `Synced ${ago(s.last_sync_at)}`]; + return [pill("off", "Not synced yet"), "Not synced yet"]; +} + +/// Sync status: the title-bar pill, the rail footer and the banners; updated on every `sync-status` event. function showSync(s: SyncStatus, detail?: HTMLElement, state?: AppState) { if (!syncBox?.isConnected) return; const openSettings = () => { - const d = detail ?? (document.querySelector("main section") as HTMLElement | null); + const d = detail ?? (document.querySelector("section.detail") as HTMLElement | null); if (d && state) void renderSettings(d, state); else if (d) void invoke("state").then((st) => renderSettings(d, st)); }; + const [statusPill, line] = syncLine(s); + syncPill?.replaceChildren(statusPill); + alarmBox?.replaceChildren(...(s.integrity_alarm ? [integrityBanner()] : [])); const parts: Node[] = []; - if (s.integrity_alarm) { - parts.push(el("div", { className: "alarm" }, - el("strong", { textContent: "Sync stopped: integrity alarm" }), - el("p", { textContent: s.integrity_alarm }), - el("p", { textContent: "The server (or someone controlling it) sent data that doesn't match what your devices signed. Don't trust this server until you've checked it." }))); - } if (s.pending_devices > 0) { - parts.push(el("div", { className: "notice" }, `${s.pending_devices} device${s.pending_devices > 1 ? "s" : ""} waiting for approval. `, - el("button", { textContent: "Review", className: "link", onclick: openSettings }))); + parts.push(banner("warn", `${s.pending_devices} device${s.pending_devices > 1 ? "s" : ""} waiting for approval`, undefined, button("Review", "text", openSettings))); } if (s.pending_approval) { - parts.push(el("div", { className: "notice", textContent: `This computer waits for approval on another device. Fingerprint: ${groups(s.device_fingerprint ?? "")}` })); + parts.push(banner("info", "This computer is waiting for approval", h("span", "", "Fingerprint ", h("span", "fingerprint", groups(s.device_fingerprint ?? ""))))); } if (s.restored_items > 0) { - parts.push(el("div", { className: "notice", textContent: `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} deleted on another device came back because they were edited here.` })); + parts.push(banner("info", `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} came back`, "They were deleted on another device but edited here.")); } - let line: string; - if (!s.server_url) line = "Not connected to a sync server"; - else if (s.syncing) line = "Syncing…"; - else if (s.last_error) line = s.last_error; - else if (s.last_sync_at) line = `Synced ${ago(s.last_sync_at)}`; - else line = "Not synced yet"; - const now = el("button", { - textContent: "Sync now", - disabled: s.syncing || !s.server_url, - onclick: async () => { - now.disabled = true; - await invoke("sync_now").catch(() => undefined); - }, + const now = iconButton("sync", "Sync now", async () => { + now.disabled = true; + await invoke("sync_now").catch(() => undefined); }); - parts.push(el("div", { className: "row between" }, - el("span", { className: s.last_error && !s.syncing ? "err small" : "muted small", textContent: line, title: s.server_url ?? "" }), - s.server_url ? now : el("button", { textContent: "Set up", onclick: openSettings }))); + now.disabled = s.syncing || !s.server_url; + const lineEl = h("span", `kv-caption grow ${s.last_error && !s.syncing ? "kv-error-text" : "kv-muted"}`, line); + lineEl.title = s.server_url ?? ""; + parts.push(h("div", "sync-line", lineEl, s.server_url ? now : button("Set up", "text", openSettings))); syncBox.replaceChildren(...parts); } +let remask: ReturnType | undefined; + +function fieldRow(label: string, value: Node, ...actions: Node[]): HTMLElement { + return h("div", "kv-frow", h("div", "val", h("span", "kv-label", label), value), ...actions); +} + async function renderDetail(target: HTMLElement, id: string) { const item = await invoke("get_item", { id }); - const pwSpan = el("span", { className: "mono", textContent: "••••••••" }); - const rows: Node[] = [ - el("h2", { textContent: item.title }), - el("p", {}, "Username: ", el("span", { className: "mono", textContent: item.username ?? "" }), " ", - el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "username" }) })), - el("p", {}, "Password: ", pwSpan, " ", - el("button", { textContent: "Reveal", onclick: () => (pwSpan.textContent = item.password ?? "") }), " ", - el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "password" }) })), - ]; - if (item.totp) { - const code = el("span", { className: "mono" }); - const tick = async () => { - if (!code.isConnected) return; - const t = await invoke<{ code: string; seconds_remaining: number } | null>("totp", { id }).catch(() => null); - code.textContent = t ? `${t.code} (${t.seconds_remaining}s)` : "—"; - setTimeout(tick, 1000); - }; - rows.push(el("p", {}, "One-time code: ", code, " ", el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id, field: "totp" }) }))); - queueMicrotask(tick); + const rows: HTMLElement[] = []; + if (item.username) { + rows.push(fieldRow("Username", h("span", "", item.username), copyButton("Copy username", () => invoke("copy_secret", { id, field: "username" })))); } - rows.push( - el("p", { className: "muted", textContent: item.urls.join(", ") }), - el("button", { - textContent: "Delete", - className: "danger", - onclick: async () => { - if (confirm(`Delete “${item.title}”?`)) { - await invoke("delete_item", { id }); - await render(); - } - }, - }), + if (item.password) { + const value = h("span", "", dots("Password")); + let shown = false; + const reveal = iconButton("eye", "Reveal password"); + const mask = () => { + shown = false; + clearTimeout(remask); + value.replaceChildren(dots("Password")); + reveal.replaceChildren(icon("eye")); + reveal.setAttribute("aria-label", "Reveal password"); + }; + reveal.onclick = () => { + if (shown) return mask(); + shown = true; + value.replaceChildren(secret(item.password ?? "")); + reveal.replaceChildren(icon("eyeOff")); + reveal.setAttribute("aria-label", "Hide password"); + // Re-mask after 30 seconds. + clearTimeout(remask); + remask = setTimeout(() => { if (value.isConnected) mask(); }, 30_000); + }; + rows.push(fieldRow("Password", value, reveal, copyButton("Copy password", () => invoke("copy_secret", { id, field: "password" })))); + } + if (item.totp) { + let current: HTMLElement = h("span", "kv-code", "––– –––"); + const value = h("span", "", current); + const secs = h("span", "kv-hint"); + const r = ring(); + const draw = async () => { + if (!current.isConnected) return; + const t = await invoke<{ code: string; seconds_remaining: number; period: number } | null>("totp", { id }).catch(() => null); + if (t) { + const next = codeText(t.code); + current.replaceWith(next); + current = next; + secs.textContent = `${t.seconds_remaining}s`; + r.update(t.seconds_remaining, t.period || 30); + } + setTimeout(draw, 1000); + }; + rows.push(fieldRow("One-time code", value, secs, r.el, copyButton("Copy one-time code", () => invoke("copy_secret", { id, field: "totp" })))); + queueMicrotask(draw); + } + for (const u of item.urls) rows.push(fieldRow("Website", h("span", "kv-link", u))); + if (item.notes) rows.push(fieldRow("Notes", h("span", "notes", item.notes))); + const del = button("Delete", "danger", () => confirmModal(`Delete “${item.title}”?`, [p("kv-muted", "It's removed from every device that syncs with your server.")], "Delete", + async () => { await invoke("delete_item", { id }); selectedId = null; await render(); }, true)); + target.replaceChildren( + h("div", "detail-head", tile(item.title), h("h2", "kv-title-2 grow", item.title), del), + h("div", "kv-card fields", ...rows), ); - target.replaceChildren(...rows); } function renderEditor(target: HTMLElement) { - const title = el("input", { placeholder: "Title" }); - const user = el("input", { placeholder: "Username" }); - const pass = el("input", { placeholder: "Password", className: "mono" }); - const url = el("input", { placeholder: "https://example.com" }); - const totp = el("input", { placeholder: "TOTP secret or otpauth:// URI (optional)", className: "mono" }); - const gen = el("button", { textContent: "Generate", onclick: async () => (pass.value = await invoke("generate_password", { length: 24 })) }); - const err = el("div", { className: "err" }); - const save = el("button", { textContent: "Save", className: "primary" }); + const title = input({ autocomplete: "off" }); + const user = input({ autocomplete: "off", spellcheck: false }); + const pass = input({ autocomplete: "off" }, true); + const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false }); + const totp = input({ placeholder: "otpauth://totp/… or base32 secret" }, true); + const gen = button("Generate", "quiet", async () => { pass.value = await invoke("generate_password", { length: 24 }); }); + const err = errBox(); + const save = button("Save", "primary"); save.onclick = async () => { try { await invoke("save_item", { @@ -492,77 +643,92 @@ function renderEditor(target: HTMLElement) { }); await render(); } catch (e) { - err.textContent = errText(e); + setErr(err, e); } }; - target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, el("div", { className: "row" }, pass, gen), url, totp, save, err)); + target.replaceChildren(h("div", "editor stack", + h("h2", "kv-title-2", "New login"), + field("Title", title), field("Username", user), + h("label", "kv-fieldset", h("span", "kv-label", "Password"), h("div", "row", pass, gen)), + field("Website", url), field("One-time code", totp, "Optional."), + err, h("div", "actions", save, button("Cancel", "secondary", () => target.replaceChildren(emptyDetail()))))); + title.focus(); } // ------------------------------------------------------------------ settings +function section(title: string, ...children: (Node | string | null)[]): HTMLElement { + return h("section", "settings-section", h("h3", "kv-title-3", title), ...children); +} + async function renderSettings(target: HTMLElement, state: AppState) { const pairings = await invoke("list_pairings"); - const idle = el("select", {}, ...[1, 5, 10, 15, 30, 60, 120].map((m) => el("option", { value: String(m), textContent: `${m} min`, selected: m === state.idle_minutes }))); + const idle = h("select", "kv-field", ...[1, 5, 10, 15, 30, 60, 120].map((m) => Object.assign(h("option", "", `${m} min`), { value: String(m), selected: m === state.idle_minutes }))); idle.onchange = () => invoke("set_idle_minutes", { minutes: Number(idle.value) }); - const list = el("ul", { className: "plain" }); - for (const p of pairings) { - list.append( - el("li", {}, - el("div", { textContent: `${p.name}${p.caller ? ` · ${p.caller}` : ""}` }), - el("div", { className: "muted mono", textContent: p.words.join(" ") }), - el("div", { className: "muted", textContent: `Paired ${new Date(p.created_at * 1000).toLocaleString()}` }), - el("button", { - textContent: "Unpair", - onclick: async () => { - await invoke("revoke_pairing", { id: p.id }); - await renderSettings(target, state); - }, - })), - ); + idle.setAttribute("aria-label", "Lock after idle for"); + const theme = h("div", "seg", ...(["system", "light", "dark"] as Theme[]).map((t) => { + const b = button(t === "system" ? "System" : t === "light" ? "Light" : "Dark", "quiet"); + b.setAttribute("aria-pressed", String(storedTheme() === t)); + b.onclick = () => { + setTheme(t); + theme.querySelectorAll("button").forEach((x) => x.setAttribute("aria-pressed", String(x === b))); + }; + return b; + })); + theme.setAttribute("role", "group"); + theme.setAttribute("aria-label", "Appearance"); + const list = h("div", "kv-card rows"); + for (const pr of pairings) { + list.append(h("div", "kv-frow", + h("div", "val", h("strong", "", `${pr.name}${pr.caller ? ` · ${pr.caller}` : ""}`), h("span", "kv-small kv-muted words-line", pr.words.join(" ")), + h("span", "kv-hint", `Paired ${new Date(pr.created_at * 1000).toLocaleString()}`)), + button("Unpair", "danger", async () => { + await invoke("revoke_pairing", { id: pr.id }); + await renderSettings(target, state); + }))); } - if (!pairings.length) list.append(el("li", { className: "muted", textContent: "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”." })); - const syncSection = el("div"); - const devicesSection = el("div"); - target.replaceChildren( - el("h2", { textContent: "Settings" }), - el("label", { className: "row" }, "Lock after idle for ", idle), - el("p", { className: "muted", textContent: "Kestrove also locks on screen lock and sleep." }), - el("h3", { textContent: "Sync" }), - syncSection, - el("h3", { textContent: "Devices" }), - devicesSection, - el("h3", { textContent: "Emergency Kit" }), - kitSection(), - el("h3", { textContent: "Paired browsers" }), - list, - ); + if (!pairings.length) list.append(h("div", "kv-frow", p("kv-muted", "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”."))); + const syncSection = h("div", "stack"); + const devicesSection = h("div", "stack"); + target.replaceChildren(h("div", "settings stack", + h("h2", "kv-title-2", "Settings"), + section("General", + h("div", "kv-card rows", + h("div", "kv-frow", h("div", "val", h("span", "", "Appearance")), theme), + h("div", "kv-frow", h("div", "val", h("span", "", "Lock after idle for"), h("span", "kv-hint", "Kestrove also locks on screen lock and sleep.")), idle))), + section("Sync", syncSection), + section("Devices", devicesSection), + section("Emergency Kit", kitSection()), + section("Paired browsers", list), + )); const status = await invoke("sync_status"); renderSyncSettings(syncSection, status, () => renderSettings(target, state)); if (status.server_url && status.logged_in) await renderDevices(devicesSection); - else devicesSection.append(el("p", { className: "muted", textContent: "Connect to a sync server first." })); + else devicesSection.append(p("kv-muted", "Connect to your server first.")); } function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promise) { - const facts = el("dl", { className: "facts" }, - el("dt", { textContent: "Server" }), el("dd", { className: "mono", textContent: s.server_url ?? "—" }), - el("dt", { textContent: "Pins" }), el("dd", { className: "mono small", textContent: s.pins.length ? s.pins.join(", ") : "none (system certificates)" }), - el("dt", { textContent: "Last sync" }), el("dd", { textContent: s.last_sync_at ? ago(s.last_sync_at) : "never" }), - el("dt", { textContent: "This device" }), el("dd", { className: "mono", textContent: groups(s.device_fingerprint ?? "—") }), - el("dt", { textContent: "Device key" }), el("dd", { textContent: s.key_storage ?? "unavailable" })); + const fact = (k: string, v: Node | string) => h("div", "kv-frow", h("div", "val", h("span", "kv-label", k), v)); + const facts = h("div", "kv-card rows", + fact("Server", h("span", "", s.server_url ?? "—")), + fact("Certificate pins", h("span", "kv-mono kv-small", s.pins.length ? s.pins.join(", ") : "None (system certificates)")), + fact("Last sync", s.last_sync_at ? ago(s.last_sync_at) : "Never"), + fact("This device", h("span", "fingerprint", groups(s.device_fingerprint ?? "—"))), + fact("Device key", s.key_storage ?? "Unavailable")); box.replaceChildren(facts); - if (s.key_warning) box.append(el("p", { className: "warn", textContent: s.key_warning })); - if (s.last_error) box.append(el("p", { className: "err", textContent: s.last_error })); + if (s.key_warning) box.append(banner("warn", "Your device key is stored in a file", s.key_warning)); + if (s.last_error) box.append(banner("bad", "Couldn't sync", s.last_error)); if (s.server_url && s.logged_in) return; // A vault from before sync (or whose registration failed): connect it. - const url = el("input", { type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" }); - const pins = el("input", { placeholder: "SPKI pins (optional)", className: "mono small", value: s.pins.join(", ") }); - const code = el("input", { placeholder: "Setup code (only for a new server)" }); - const err = el("div", { className: "err" }); - const btn = el("button", { textContent: "Connect", className: "primary" }); + const url = input({ type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" }); + const pins = input({ value: s.pins.join(", ") }, true); + const code = input({}); + const err = errBox(); + const btn = button("Connect", "primary"); btn.onclick = async () => { btn.disabled = true; - err.textContent = ""; + err.replaceChildren(); try { await invoke("configure_server", { serverUrl: url.value, pins: pins.value }); if (await invoke("server_setup_open")) { @@ -573,76 +739,67 @@ function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promi } await reload(); } catch (e) { - err.textContent = errText(e); + setErr(err, e); btn.disabled = false; } }; - box.append(el("div", { className: "card" }, url, pins, code, btn, err)); + box.append(h("div", "kv-card pad stack", field("Your Kestrove server", url), field("SPKI pins", pins, "Optional."), + field("Setup code", code, "Only for a new server."), err, h("div", "actions", btn))); } async function renderDevices(box: HTMLElement) { - const err = el("div", { className: "err" }); - const list = el("ul", { className: "plain" }); + const err = errBox(); + const list = h("div", "kv-card rows"); const reload = () => renderDevices(box); let devices: DeviceView[] = []; try { devices = await invoke("list_devices"); } catch (e) { - err.textContent = errText(e); + setErr(err, e); } for (const d of devices.filter((d) => !d.revoked)) { - const badges = [d.current ? "this device" : "", d.approved ? "" : "waiting for approval"].filter(Boolean).join(" · "); - const li = el("li", {}, - el("div", {}, el("strong", { textContent: d.name }), ` ${d.platform}`, badges ? el("span", { className: d.approved ? "muted" : "warn", textContent: ` · ${badges}` }) : ""), - el("div", { className: "muted mono small", textContent: groups(d.key_fingerprint) }), - el("div", { className: "muted small", textContent: `Added ${new Date(d.created_at).toLocaleString()}` })); - const actions = el("div", { className: "row" }); + const badges = h("span", "badges", d.current ? pill("off", "This device") : null, d.approved ? null : pill("wait", "Waiting for approval")); + const actions = h("div", "row"); if (!d.approved) { - actions.append(el("button", { - textContent: "Approve…", - className: "primary", - onclick: () => confirmModal( - `Approve “${d.name}”?`, - [el("p", { textContent: "Only approve it if the new device shows exactly this fingerprint:" }), el("p", { className: "mono big", textContent: groups(d.key_fingerprint) })], - "The fingerprints match: approve", - async () => { await invoke("approve_device", { deviceId: d.device_id }); await reload(); }), - })); + actions.append(button("Approve…", "primary", () => confirmModal( + `Approve “${d.name}”?`, + [p("", "Only approve it if the new device shows exactly this fingerprint:"), p("fingerprint lg", groups(d.key_fingerprint))], + "The fingerprints match: approve", + async () => { await invoke("approve_device", { deviceId: d.device_id }); await reload(); }))); } if (!d.current) { - actions.append(el("button", { - textContent: d.approved ? "Remove" : "Reject", - className: "danger", - onclick: () => confirmModal(`Remove “${d.name}”?`, [el("p", { textContent: "It will be signed out and can't sync again. Its local copy stays on it." })], "Remove device", - async () => { await invoke("revoke_device", { deviceId: d.device_id }); await reload(); }), - })); + actions.append(button(d.approved ? "Remove" : "Reject", "danger", () => confirmModal(`Remove “${d.name}”?`, + [p("kv-muted", "It will be signed out and can't sync again. Its local copy stays on it.")], "Remove device", + async () => { await invoke("revoke_device", { deviceId: d.device_id }); await reload(); }, true))); } - li.append(actions); - list.append(li); + list.append(h("div", "kv-frow", + h("span", "device-ico", icon(/ios|android|phone|tablet/i.test(d.platform) ? "key" : "laptop")), + h("div", "val", h("span", "row", h("strong", "", d.name), h("span", "kv-small kv-muted", d.platform), badges), + h("span", "fingerprint kv-small kv-muted", groups(d.key_fingerprint)), + h("span", "kv-hint", `Added ${new Date(d.created_at).toLocaleString()}`)), + actions)); } box.replaceChildren( - el("div", { className: "row" }, - el("button", { textContent: "Add a phone or tablet…", onclick: addDeviceModal }), - el("button", { textContent: "Approve a computer…", onclick: approveComputerModal })), + h("div", "row", button("Add a device…", "secondary", addDeviceModal), button("Approve a computer…", "secondary", approveComputerModal)), list, err); } function kitSection(): HTMLElement { - const box = el("div"); - const show = el("button", { - textContent: "Show Emergency Kit setup code", - onclick: async () => { - try { - const qr = await invoke("kit_qr"); - box.replaceChildren( - el("p", { className: "warn", textContent: "This code contains your Secret Key. Keep it as safe as your Emergency Kit." }), - el("img", { src: qr, alt: "Emergency Kit setup code", className: "qr" }), - el("button", { textContent: "Hide", onclick: () => box.replaceChildren(show) })); - } catch (e) { - box.append(el("p", { className: "err", textContent: errText(e) })); - } - }, + const box = h("div", "stack"); + const show = button("Show Emergency Kit setup code", "secondary", async () => { + try { + const qr = await invoke("kit_qr"); + box.replaceChildren( + banner("warn", "This code contains your Secret Key", "Keep it as safe as your Emergency Kit."), + h("div", "center-row", qrCard(qr, "Emergency Kit setup code")), + h("div", "actions", button("Hide", "quiet", () => box.replaceChildren(show)))); + } catch (e) { + const err = errBox(); + setErr(err, e); + box.append(err); + } }); - box.append(show); + box.append(h("div", "actions", show)); return box; } @@ -654,93 +811,104 @@ function closeModal() { } function openModal(...children: Node[]) { - modal.replaceChildren(el("div", { className: "prompt" }, ...children)); + const sheet = h("div", "kv-sheet prompt", ...children); + sheet.setAttribute("role", "dialog"); + sheet.setAttribute("aria-modal", "true"); + modal.replaceChildren(sheet); modal.hidden = false; + sheet.querySelector("button.primary, textarea, input")?.focus(); } -function confirmModal(title: string, body: Node[], yes: string, action: () => Promise) { - const err = el("div", { className: "err" }); - const ok = el("button", { textContent: yes, className: "primary" }); +function confirmModal(title: string, body: Node[], yes: string, action: () => Promise, destructive = false) { + const err = errBox(); + const ok = button(yes, destructive ? "danger-fill" : "primary"); ok.onclick = async () => { ok.disabled = true; try { await action(); closeModal(); } catch (e) { - err.textContent = errText(e); + setErr(err, e); ok.disabled = false; } }; - openModal(el("h3", { textContent: title }), ...body, el("div", { className: "row" }, ok, el("button", { textContent: "Cancel", onclick: closeModal })), err); + openModal(h("h2", "kv-title-2", title), ...body, err, h("div", "actions end", button("Cancel", "secondary", closeModal), ok)); } -/// "Add a device": a single-use, 10-minute QR. Cancel or Done revokes the ticket if it wasn't used. +/// "Add a device": a single-use, 10-minute QR. Cancel revokes the ticket if it wasn't used. async function addDeviceModal() { let view: AddDeviceView; try { view = await invoke("add_device"); } catch (e) { - openModal(el("h3", { textContent: "Add a device" }), el("p", { className: "err", textContent: errText(e) }), el("button", { textContent: "Close", onclick: closeModal })); + const err = errBox(); + setErr(err, e); + openModal(h("h2", "kv-title-2", "Add a device"), err, h("div", "actions end", button("Close", "secondary", closeModal))); return; } const done = async () => { closeModal(); await invoke("cancel_add_device"); }; - openModal( - el("h3", { textContent: "Add a phone or tablet" }), - el("p", { textContent: "In Kestrove on the new device, choose “Scan setup code” and scan this. Then it only asks for your master password." }), - el("img", { src: view.qr_svg, alt: "Setup code for a new device", className: "qr" }), - el("p", { className: "warn small", textContent: "It contains your Secret Key and works once. Don't show it to anyone else." }), - el("div", { className: "row between" }, countdown(view.expires_at, () => { if (!modal.hidden) void done(); }), el("button", { textContent: "Done", className: "primary", onclick: done })), - ); + openModal(h("div", "qr-sheet", + h("h2", "kv-title-2", "Add a device"), + steps("Open Kestrove on the new device.", h("span", "", "Choose ", h("b", "", "Scan setup QR"), "."), "Type your master password there."), + qrCard(view.qr_svg, "Setup code for a new device"), + countdown(view.expires_at, () => { if (!modal.hidden) void done(); }), + p("kv-hint", "It contains your Secret Key and works once. Don't show it to anyone else."), + button("Cancel", "quiet", done))); } /// Desktops usually have no camera: approve a new computer from its pasted join code. function approveComputerModal() { - const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://join/v1?…", spellcheck: false }); - const err = el("div", { className: "err" }); - const next = el("button", { textContent: "Continue", className: "primary" }); + const text = h("textarea", "kv-field mono kv-small"); + text.rows = 4; + text.placeholder = "kestrove://join/v1?…"; + text.spellcheck = false; + const err = errBox(); + const next = button("Continue", "primary"); const cancel = async () => { await invoke("join_cancel"); closeModal(); }; next.onclick = async () => { next.disabled = true; - err.textContent = ""; + err.replaceChildren(); try { const c = await invoke("approve_join", { joinCode: text.value }); - const approve = el("button", { textContent: "The codes match: approve", className: "primary" }); - const err2 = el("div", { className: "err" }); + const approve = button("Approve", "primary", undefined, "block"); + const err2 = errBox(); approve.onclick = async () => { approve.disabled = true; try { await invoke("send_join"); - openModal(el("h3", { textContent: "Approved" }), el("p", { textContent: `${c.device_name} will finish setting up and ask for your master password.` }), el("button", { textContent: "Close", className: "primary", onclick: closeModal })); + openModal(h("h2", "kv-title-2", "Approved"), p("", `${c.device_name} will finish setting up and ask for your master password.`), + h("div", "actions end", button("Close", "primary", closeModal))); } catch (e) { - err2.textContent = errText(e); + setErr(err2, e); approve.disabled = false; } }; - openModal( - el("h3", { textContent: `Add “${c.device_name}” (${c.platform})?` }), - el("p", { textContent: "Check that the new computer shows exactly:" }), - el("div", { className: "digits", textContent: c.code }), - el("p", { className: "muted small", textContent: `Device key ${groups(c.key_fingerprint)}` }), - el("div", { className: "row" }, approve, el("button", { textContent: "Cancel", onclick: cancel })), + openModal(h("div", "qr-sheet", + h("h2", "kv-title-2", "Approve this computer?"), + p("kv-small kv-muted", `${c.device_name} · ${c.platform}`, h("br"), "Check that the computer shows the same code."), + h("span", "kv-hint", "The computer should show:"), + codeText(c.code, true), + p("kv-hint", `Device key ${groups(c.key_fingerprint)}`), err2, - ); + approve, + button("The codes don't match", "secondary", cancel, "block"))); } catch (e) { - err.textContent = errText(e); + setErr(err, e); next.disabled = false; } }; openModal( - el("h3", { textContent: "Approve a computer" }), - el("p", { textContent: "On the new computer choose “Use my phone”, open “No phone? Approve from another computer” and copy the join code. Paste it here:" }), - text, - el("div", { className: "row" }, next, el("button", { textContent: "Cancel", onclick: cancel })), + h("h2", "kv-title-2", "Approve a computer"), + p("kv-muted", "On the new computer choose “Use my phone”, open “No phone? Approve from another computer” and copy the join code. Paste it here:"), + field("Join code", text), err, + h("div", "actions end", button("Cancel", "secondary", cancel), next), ); text.focus(); } @@ -760,55 +928,59 @@ async function renderPrompts(state: AppState) { if (state.has_account && !state.locked) { for (const s of await invoke("pending_saves")) overlay.append(saveCard(s)); } else if (state.has_account && (await invoke("pending_saves")).length) { - overlay.append(el("div", { className: "prompt" }, el("p", { textContent: "Unlock Kestrove to review logins your browser asked to save." }))); + overlay.append(h("div", "kv-sheet prompt", banner("info", "Your browser asked to save a login", "Unlock Kestrove to review it."))); } overlay.hidden = !overlay.childElementCount; } -function pairingCard(p: PairingView, state: AppState): HTMLElement { - const err = el("div", { className: "err" }); - const words = el("div", { className: "words" }, ...p.words.map((w) => el("span", { textContent: w }))); - const accept = el("button", { textContent: "The words match: pair", className: "primary" }); - const reject = el("button", { textContent: "Don't pair" }); +function words(ws: string[]): HTMLElement { + return h("div", "words", ...ws.map((w) => h("span", "", w))); +} + +function pairingCard(pr: PairingView, state: AppState): HTMLElement { + const err = errBox(); + const accept = button("The words match: pair", "primary"); + const reject = button("Don't pair", "secondary"); const resolve = async (yes: boolean) => { try { - await invoke("resolve_pairing", { id: p.id, accept: yes }); + await invoke("resolve_pairing", { id: pr.id, accept: yes }); await render(); } catch (e) { - err.textContent = errText(e); + setErr(err, e); } }; accept.onclick = () => resolve(true); reject.onclick = () => resolve(false); accept.disabled = state.locked; - return el("div", { className: "prompt" }, - el("h3", { textContent: "Pair a browser?" }), - el("p", { textContent: `${p.name}${p.caller ? ` (${p.caller})` : ""} wants to use Kestrove. Check that the extension shows exactly these words:` }), - words, - state.locked ? el("p", { className: "muted", textContent: "Unlock Kestrove first, then confirm." }) : "", - el("div", { className: "row" }, accept, reject), - err); + return h("div", "kv-sheet prompt", + h("h2", "kv-title-2", "Pair a browser?"), + p("", `${pr.name}${pr.caller ? ` (${pr.caller})` : ""} wants to use Kestrove. Check that the extension shows exactly these words:`), + words(pr.words), + state.locked ? p("kv-muted", "Unlock Kestrove first, then confirm.") : null, + err, + h("div", "actions end", reject, accept)); } function saveCard(s: SaveView): HTMLElement { - const err = el("div", { className: "err" }); - const yes = el("button", { textContent: s.update ? "Update password" : "Save", className: "primary" }); - const no = el("button", { textContent: "Not now" }); + const err = errBox(); + const yes = button(s.update ? "Update password" : "Save", "primary"); + const no = button("Not now", "secondary"); const resolve = async (accept: boolean) => { try { await invoke("resolve_save", { id: s.id, accept }); await render(); } catch (e) { - err.textContent = errText(e); + setErr(err, e); } }; yes.onclick = () => resolve(true); no.onclick = () => resolve(false); - return el("div", { className: "prompt" }, - el("h3", { textContent: s.update ? "Update saved password?" : "Save this login?" }), - el("p", {}, el("span", { className: "mono", textContent: s.username || "(no username)" }), " on ", el("span", { className: "mono", textContent: s.origin })), - el("div", { className: "row" }, yes, no), - err); + return h("div", "kv-sheet prompt", + h("h2", "kv-title-2", s.update ? "Update saved password?" : "Save this login?"), + h("div", "kv-card rows", h("div", "kv-frow", tile(s.origin.replace(/^https?:\/\/(www\.)?/, "")), + h("div", "val", h("strong", "", s.username || "No username"), h("span", "kv-small kv-muted", s.origin)))), + err, + h("div", "actions end", no, yes)); } // ------------------------------------------------------------------ wiring @@ -827,9 +999,7 @@ void listen("sync-status", (e) => { lastChanges = s.changes; if (s.integrity_alarm && !alarmShown) { alarmShown = true; - openModal(el("h3", { textContent: "Sync stopped: integrity alarm" }), el("p", { textContent: s.integrity_alarm }), - el("p", { textContent: "Your local data is unchanged and stays usable. Don't trust the server until you've checked it." }), - el("button", { textContent: "OK", className: "primary", onclick: closeModal })); + openModal(integrityBanner(), h("div", "actions end", button("OK", "primary", closeModal))); } }); // Keep "Synced 3 min ago" fresh. diff --git a/src/style.css b/src/style.css index 429f843..8a8b08d 100644 --- a/src/style.css +++ b/src/style.css @@ -1,58 +1,101 @@ -:root { color-scheme: light dark; font: 14px system-ui, sans-serif; } -body { margin: 0; } -main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; overflow: auto; } -.center { display: grid; place-items: center; min-height: 100vh; padding: 16px 0; box-sizing: border-box; grid-column: 1 / -1; } -.card { display: grid; gap: 8px; width: 340px; } -input, button { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; } -button { cursor: pointer; } -aside { border-right: 1px solid #8884; overflow: auto; } -aside li { list-style: none; padding: 8px 12px; cursor: pointer; } -aside li:hover, aside li.sel { background: #8882; } -aside ul { margin: 0; padding: 0; } -section { padding: 16px; } -.muted { opacity: .7; } -.err { color: #d33; } -.mono { font-family: ui-monospace, monospace; } -.toolbar { display: flex; gap: 8px; padding: 8px; } -h3 { margin: 16px 0 8px; } -select { font: inherit; padding: 6px; border-radius: 6px; } -.row { display: flex; gap: 8px; align-items: center; } -.pad { padding: 0 12px; } -.primary { background: #2c6e86; color: white; border-color: transparent; } -.danger { color: #d33; } -ul.plain { padding: 0; } -ul.plain li { list-style: none; padding: 8px 0; border-bottom: 1px solid #8883; display: grid; gap: 4px; justify-items: start; } -#overlay { position: fixed; inset: 0; background: #0006; display: grid; place-content: center; gap: 12px; } -#overlay[hidden] { display: none; } -.prompt { background: Canvas; color: CanvasText; border-radius: 10px; padding: 16px 20px; width: 420px; box-shadow: 0 10px 40px #0005; } -.words { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 12px; } -.words span { font: 600 15px ui-monospace, monospace; padding: 4px 8px; border-radius: 6px; background: #8882; } -#modal { position: fixed; inset: 0; background: #0006; display: grid; place-content: center; z-index: 2; } -#modal[hidden] { display: none; } -#modal .prompt { display: grid; gap: 8px; max-height: 90vh; overflow: auto; } -aside { display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; } -main > section { height: 100vh; overflow: auto; box-sizing: border-box; } -aside > ul { flex: 1; } -.sync { border-top: 1px solid #8884; padding: 8px 12px; display: grid; gap: 8px; } -.between { justify-content: space-between; } -.small { font-size: 12px; } -.big { font-size: 18px; } -.grid { display: grid; gap: 8px; } -.card.wide { width: 440px; } -.tabs { display: flex; gap: 4px; flex-wrap: wrap; } -.tab { border-color: transparent; background: none; } -.tab.sel { background: #8883; font-weight: 600; } -textarea { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; resize: vertical; width: 100%; box-sizing: border-box; } -.qr { width: 240px; height: 240px; justify-self: center; image-rendering: pixelated; border-radius: 6px; } -.code { text-align: center; padding: 8px; border-radius: 8px; background: #8881; } -.digits { font: 700 32px ui-monospace, monospace; letter-spacing: 2px; text-align: center; } -.steps { margin: 0; padding-left: 20px; display: grid; gap: 4px; } -.notice { background: #2c6e8622; border-radius: 8px; padding: 8px 10px; } -.alarm { background: #d3333322; border: 1px solid #d33; border-radius: 8px; padding: 8px 10px; } -.alarm p { margin: 4px 0 0; } -.warn { color: #b26b00; } -.link { border: none; background: none; color: #2c6e86; padding: 0; text-decoration: underline; justify-self: start; } -.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0 0 8px; } -.facts dd { margin: 0; overflow-wrap: anywhere; } -details { display: grid; gap: 6px; } -summary { cursor: pointer; } +/* Desktop layout. Colours, type and components come from the design system (./ui). */ +@import "./ui/fonts.css"; +@import "./ui/tokens.css"; +@import "./ui/kv.css"; + +html, body { margin: 0; height: 100%; } +body { overflow: hidden; } +#app { height: 100vh; display: flex; flex-direction: column; } +p { margin: 0; } +h1, h2, h3 { margin: 0; } +.grow { flex: 1 1 auto; min-width: 0; } +.row { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } +.stack { display: flex; flex-direction: column; gap: var(--space-4); min-width: 0; } +.actions { display: flex; gap: var(--space-2); align-items: center; } +.actions.end { justify-content: flex-end; } +.center-row { display: flex; justify-content: center; } +.fingerprint { font-family: var(--font-mono); font-variant-ligatures: none; letter-spacing: 0.04em; word-spacing: 0.2em; } +.fingerprint.lg { font-size: 17px; line-height: 26px; } +.kv-error-text { color: var(--danger); } +details { display: grid; gap: var(--space-2); } +summary { cursor: pointer; color: var(--text-2); } + +/* Custom title bar in bg with the native window controls. */ +.titlebar { + height: 44px; flex: none; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3) 0 var(--space-4); + background: var(--bg); border-bottom: 1px solid var(--border); user-select: none; -webkit-user-select: none; +} +.mac .titlebar { padding-left: 84px; } +.kv-word.sm { font-size: 15px; line-height: 20px; } + +/* Segmented control: border-control outline (WCAG 1.4.11). */ +.seg { display: inline-flex; flex-wrap: wrap; gap: 2px; padding: 2px; border: 1px solid var(--border-control); border-radius: var(--radius); background: var(--surface-2); align-self: flex-start; } +.seg .kv-btn { height: 32px; background: transparent; color: var(--text-2); border-radius: 8px; font-weight: 600; padding: 0 var(--space-3); } +.seg .kv-btn[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: var(--shadow-sm); } + +/* Unlock and onboarding */ +#app.auth { background: var(--bg); } +.center { flex: 1; overflow: auto; display: grid; place-items: center; padding: var(--space-12) var(--space-4); } +.auth-card { width: 400px; max-width: 100%; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-5); box-shadow: var(--shadow-sm); } +.auth-card.wide { width: 580px; } +.unlock-mark { display: flex; justify-content: center; } +.auth-card .kv-title-1 { text-align: left; } +.secret-key { padding: var(--space-3) var(--space-4); border-radius: var(--radius); background: var(--surface-2); } +.secret-key .kv-secret { font-size: 17px; line-height: 26px; } + +/* QR card and confirmation code (AddDevice) */ +.qr-sheet { display: flex; flex-direction: column; gap: var(--space-3); align-items: center; text-align: center; } +.qr-sheet .steps { align-self: stretch; text-align: left; } +.steps { margin: 0; padding-left: var(--space-5); display: grid; gap: var(--space-1); } +.confirm { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); min-height: 54px; justify-content: center; } +.words { display: flex; flex-wrap: wrap; gap: var(--space-1\.5); } +.words span { font: 600 15px/22px var(--font-mono); padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); background: var(--surface-2); } + +/* Vault: left rail, list, detail */ +#app.shell { background: var(--bg); } +.alarm-slot:not(:empty) { padding: var(--space-3) var(--space-4) 0; } +.cols { flex: 1; min-height: 0; display: grid; grid-template-columns: 200px minmax(260px, 340px) 1fr; } +.rail { background: var(--surface-2); padding: var(--space-3) var(--space-2); display: flex; flex-direction: column; gap: 2px; overflow: auto; border-right: 1px solid var(--border); } +.rail-h { padding: var(--space-3) var(--space-2\.5) var(--space-1); } +.rail-item { + display: flex; align-items: center; gap: var(--space-2\.5); padding: var(--space-1\.5) var(--space-2\.5); border-radius: var(--radius-sm); + border: 0; background: transparent; color: var(--text-2); font: 400 14px/20px var(--font-sans); cursor: pointer; text-align: left; +} +.rail-item:hover { background: var(--surface-3); color: var(--text); } +.rail-item[aria-current="page"] { background: var(--primary-soft); color: var(--text); font-weight: 600; } +.rail-foot { display: flex; flex-direction: column; gap: var(--space-2); padding-top: var(--space-2); } +.rail-foot .kv-banner { padding: var(--space-2\.5) var(--space-3); font-size: 13px; line-height: 18px; gap: var(--space-2); } +.sync-line { display: flex; align-items: center; gap: var(--space-1); padding: 0 var(--space-1) 0 var(--space-2\.5); } +.listpane { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; } +.list-head { display: flex; gap: var(--space-2); padding: var(--space-3); border-bottom: 1px solid var(--border); } +.search { flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-2\.5); height: var(--control); border-radius: var(--radius); background: var(--surface-2); color: var(--text-3); } +.search:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; } +.search input { flex: 1; min-width: 0; border: 0; background: transparent; color: var(--text); font: 400 14px/20px var(--font-sans); height: 100%; outline: none; } +.search input::placeholder { color: var(--text-3); } +.listpane .kv-list { flex: 1; overflow: auto; } +.empty { padding: var(--space-6) var(--space-4); text-align: center; } +.detail { background: var(--surface); overflow: auto; padding: var(--space-5) var(--space-6) var(--space-12); min-width: 0; } +.empty-detail { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-3); opacity: 0.9; } +.detail-head { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-5); } +.fields { overflow: hidden; } +.notes { white-space: pre-wrap; } +.editor { max-width: 520px; } +.editor .row .kv-field { flex: 1; } + +/* Settings */ +.settings { max-width: 640px; gap: var(--space-6); } +.settings-section { display: flex; flex-direction: column; gap: var(--space-2\.5); } +.rows { overflow: hidden; } +.pad { padding: var(--space-4); } +.badges { display: inline-flex; gap: var(--space-1); } +.device-ico { color: var(--text-2); display: grid; } + +/* Dialogs and browser prompts */ +#overlay, #modal { position: fixed; inset: 0; background: rgb(6 14 11 / 0.5); display: grid; place-content: center; gap: var(--space-3); padding: var(--space-4); } +#modal { z-index: 2; } +#overlay[hidden], #modal[hidden] { display: none; } +.prompt { width: 440px; max-width: calc(100vw - 32px); max-height: calc(100vh - 48px); overflow: auto; padding: var(--space-6); display: flex; flex-direction: column; gap: var(--space-4); } +@media (prefers-reduced-motion: no-preference) { + .prompt { animation: pop 160ms var(--ease); } +} +@keyframes pop { from { opacity: 0; transform: translateY(6px) scale(0.985); } } diff --git a/src/ui/fonts.css b/src/ui/fonts.css new file mode 100644 index 0000000..281d207 --- /dev/null +++ b/src/ui/fonts.css @@ -0,0 +1,15 @@ +/* Bundled fonts (SIL OFL 1.1): Inter and JetBrains Mono, variable, Latin + Latin Extended. */ +@font-face { + font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 100 900; + src: url("./fonts/Inter-Latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 100 900; + src: url("./fonts/Inter-LatinExt.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 100 800; + src: url("./fonts/JetBrainsMono-Latin.woff2") format("woff2"); +} diff --git a/src/ui/fonts/Inter-Latin.woff2 b/src/ui/fonts/Inter-Latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/src/ui/fonts/Inter-Latin.woff2 differ diff --git a/src/ui/fonts/Inter-LatinExt.woff2 b/src/ui/fonts/Inter-LatinExt.woff2 new file mode 100644 index 0000000..479d010 Binary files /dev/null and b/src/ui/fonts/Inter-LatinExt.woff2 differ diff --git a/src/ui/fonts/JetBrainsMono-Latin.woff2 b/src/ui/fonts/JetBrainsMono-Latin.woff2 new file mode 100644 index 0000000..cd5102a Binary files /dev/null and b/src/ui/fonts/JetBrainsMono-Latin.woff2 differ diff --git a/src/ui/kv.css b/src/ui/kv.css new file mode 100644 index 0000000..7cd7a6b --- /dev/null +++ b/src/ui/kv.css @@ -0,0 +1,158 @@ +/* Kestrove components (design system "components"): the same classes in the desktop app and the + browser extension. Every value comes from tokens.css. */ + +.kv-root { + background: var(--bg); + color: var(--text); + font: 400 15px/22px var(--font-sans); + font-feature-settings: "cv11", "ss01"; + -webkit-font-smoothing: antialiased; +} +.kv-root *, .kv-root *::before, .kv-root *::after { box-sizing: border-box; } + +/* Type scale */ +.kv-title-1 { font: 650 28px/34px var(--font-sans); letter-spacing: -0.025em; margin: 0; } +.kv-title-2 { font: 650 20px/24px var(--font-sans); letter-spacing: -0.015em; margin: 0; } +.kv-title-3 { font: 650 16px/20px var(--font-sans); letter-spacing: -0.015em; margin: 0; } +.kv-body { font: 400 15px/22px var(--font-sans); } +.kv-small { font: 400 14px/20px var(--font-sans); } +.kv-caption { font: 500 13px/18px var(--font-sans); } +.kv-label { font: 600 12px/16px var(--font-sans); letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-2); } +.kv-muted { color: var(--text-2); } +.kv-hint { color: var(--text-3); font: 500 13px/18px var(--font-sans); } +.kv-mono { font-family: var(--font-mono); font-variant-ligatures: none; } +.kv-link { color: var(--primary); } + +/* Buttons */ +.kv-btn { + display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); + height: var(--control); padding: 0 var(--space-3\.5); border-radius: var(--radius); border: 1px solid transparent; + font: 600 14px/20px var(--font-sans); cursor: pointer; white-space: nowrap; text-decoration: none; + background: var(--surface-2); color: var(--text); + transition: background 120ms var(--ease), border-color 120ms var(--ease), color 120ms var(--ease); +} +.kv-btn.primary { background: var(--primary); color: var(--on-primary); box-shadow: var(--shadow-sm); } +.kv-btn.primary:hover { background: var(--primary-hover); } +.kv-btn.secondary { background: var(--surface); color: var(--text); border-color: var(--border-control); } +.kv-btn.secondary:hover { background: var(--surface-2); } +.kv-btn.quiet { background: var(--surface-2); color: var(--text); } +.kv-btn.quiet:hover { background: var(--surface-3); } +.kv-btn.danger { background: var(--surface); color: var(--danger); border-color: var(--danger); } +.kv-btn.danger:hover { background: var(--danger-soft); } +.kv-btn.danger-fill { background: var(--danger); color: var(--surface); } +.kv-btn.lg { height: var(--control-lg); padding: 0 var(--space-5); font-size: 15px; } +.kv-btn.block { width: 100%; } +.kv-btn.icon { + width: var(--control-sm); height: var(--control-sm); padding: 0; background: transparent; color: var(--text-2); + border-radius: var(--radius-sm); flex: none; +} +.kv-btn.icon:hover { background: var(--surface-2); color: var(--text); } +.kv-btn.icon.ok { color: var(--success); } +.kv-btn.text { background: transparent; color: var(--primary); padding: 0 var(--space-1); height: auto; } +.kv-btn.text:hover { color: var(--primary-hover); text-decoration: underline; } +.kv-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.kv-root :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.kv-ico { width: 18px; height: 18px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; } +.kv-ico.sm { width: 16px; height: 16px; } +.kv-ico.xs { width: 14px; height: 14px; } + +/* Fields */ +.kv-fieldset { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; } +.kv-field { + width: 100%; height: var(--control); padding: 0 var(--space-3); border-radius: var(--radius); + border: 1px solid var(--border-control); background: var(--surface); color: var(--text); + font: 400 15px/22px var(--font-sans); min-width: 0; +} +textarea.kv-field { height: auto; padding: var(--space-2) var(--space-3); resize: vertical; } +select.kv-field { width: auto; padding-right: var(--space-6); } +.kv-field::placeholder { color: var(--text-3); } +.kv-field:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.kv-field.mono { font-family: var(--font-mono); letter-spacing: 0.02em; font-variant-ligatures: none; } +.kv-field.invalid { border-color: var(--danger); } +.kv-error { color: var(--danger); font: 500 13px/18px var(--font-sans); display: flex; gap: var(--space-1); align-items: center; } +.kv-error:empty { display: none; } + +/* Surfaces */ +.kv-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); } +.kv-sheet { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); } +.kv-menu { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow); } + +/* Item icon */ +.kv-tile { + display: inline-grid; place-items: center; width: var(--icon-tile); height: var(--icon-tile); border-radius: var(--radius-icon); + color: var(--on-tile); font: 700 16px/1 var(--font-sans); flex: none; +} +.kv-tile.sm { width: 28px; height: 28px; font-size: 13px; } +.t1 { background: var(--tile-1); } .t2 { background: var(--tile-2); } .t3 { background: var(--tile-3); } .t4 { background: var(--tile-4); } +.t5 { background: var(--tile-5); } .t6 { background: var(--tile-6); } .t7 { background: var(--tile-7); } .t8 { background: var(--tile-8); } + +/* List rows */ +.kv-list { background: var(--surface); margin: 0; padding: 0; list-style: none; } +.kv-item { + display: flex; align-items: center; gap: var(--space-3); min-height: var(--row); padding: var(--space-2\.5) var(--space-4); + border-bottom: 1px solid var(--border); cursor: pointer; color: var(--text); background: transparent; + width: 100%; text-align: left; font: inherit; border-top: 0; border-left: 0; border-right: 0; +} +.kv-item:hover { background: var(--surface-2); } +.kv-item.sel, .kv-item[aria-selected="true"] { background: var(--primary-soft); } +.kv-item .txt { display: flex; flex-direction: column; gap: var(--space-0\.5); min-width: 0; flex: 1; } +.kv-item .t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kv-item .u { font-size: 14px; line-height: 20px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.kv-item.compact { min-height: var(--control); padding: var(--space-1\.5) var(--space-3); border-bottom: 0; border-radius: var(--radius); } +.kv-item.compact .u { font-size: 13px; line-height: 18px; } +.kv-star { color: var(--accent); fill: currentColor; stroke: none; } + +/* Secrets */ +.kv-secret { font: 500 15px/22px var(--font-mono); font-variant-numeric: tabular-nums; font-variant-ligatures: none; letter-spacing: 0.01em; color: var(--text); word-break: break-all; } +.kv-secret .d { color: var(--secret-digit); } +.kv-secret .s { color: var(--secret-symbol); } +.kv-dots { letter-spacing: 0.18em; color: var(--text); font: 700 15px/22px var(--font-mono); } +.kv-code { font: 600 22px/28px var(--font-mono); letter-spacing: 0.06em; color: var(--primary); font-variant-numeric: tabular-nums; } +.kv-code.xl { font-size: 30px; line-height: 36px; } +.kv-frow { display: flex; align-items: center; gap: var(--space-3); min-height: var(--row); padding: var(--space-2\.5) var(--space-4); border-top: 1px solid var(--border); } +.kv-frow:first-child { border-top: 0; } +.kv-frow .val { display: flex; flex-direction: column; gap: var(--space-0\.5); flex: 1; min-width: 0; overflow-wrap: anywhere; } +.kv-ring { width: 28px; height: 28px; transform: rotate(-90deg); flex: none; } +.kv-ring circle { fill: none; stroke-width: 2.5; } +.kv-ring .track { stroke: var(--surface-3); } +.kv-ring .arc { stroke: var(--primary); stroke-linecap: round; transition: stroke-dashoffset 1s linear; } +.kv-ring.low .arc { stroke: var(--accent); } + +/* Status */ +.kv-pill { + display: inline-flex; align-items: center; gap: var(--space-1); padding: 2px var(--space-2\.5); border-radius: var(--radius-pill); + font: 600 12px/16px var(--font-sans); letter-spacing: 0.02em; white-space: nowrap; +} +.kv-pill.ok { background: var(--primary-soft); color: var(--success); } +.kv-pill.wait { background: var(--warning-soft); color: var(--warning); } +.kv-pill.bad { background: var(--danger-soft); color: var(--danger); } +.kv-pill.off { background: var(--surface-2); color: var(--text-2); } +.kv-banner { display: flex; gap: var(--space-3); align-items: flex-start; padding: var(--space-3) var(--space-4); border-radius: var(--radius); font-size: 14px; line-height: 20px; color: var(--text); } +.kv-banner > .kv-ico { margin-top: 1px; } +.kv-banner b { display: block; font-weight: 600; } +.kv-banner p { margin: 0; } +.kv-banner.info { background: var(--primary-soft); } +.kv-banner.info > .kv-ico { color: var(--primary); } +.kv-banner.warn { background: var(--warning-soft); } +.kv-banner.warn > .kv-ico { color: var(--warning); } +.kv-banner.bad { background: var(--danger-soft); } +.kv-banner.bad > .kv-ico { color: var(--danger); } + +/* QR: always dark modules on white, in both themes */ +.kv-qr { background: #ffffff; padding: var(--space-3); border-radius: var(--radius); border: 1px solid var(--border); display: grid; place-items: center; } +.kv-qr img { width: 232px; height: 232px; image-rendering: pixelated; display: block; } + +/* Mark */ +.kv-mark .tile { fill: var(--primary); } +.kv-mark .wing, .kv-mark .key { fill: var(--on-primary); } +.kv-mark .eye { fill: var(--accent); } +.kv-lockup { display: inline-flex; align-items: center; gap: var(--space-2\.5); color: var(--text); } +.kv-word { font: 700 20px/24px var(--font-sans); letter-spacing: -0.035em; } +/* In dark the eye keeps the light Kestrel so it stays visible on the light-green tile. */ +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .kv-mark .eye { fill: #b4481f; } } +:root[data-theme="dark"] .kv-mark .eye { fill: #b4481f; } + +@media (prefers-reduced-motion: reduce) { + .kv-root *, .kv-root *::before, .kv-root *::after { transition: none !important; animation: none !important; } + .kv-ring { display: none; } +} diff --git a/src/ui/kv.ts b/src/ui/kv.ts new file mode 100644 index 0000000..82697cd --- /dev/null +++ b/src/ui/kv.ts @@ -0,0 +1,287 @@ +// Kestrove components as DOM builders (design system "components"), shared by the desktop app and the +// browser extension. Builds nodes with createElement only (no innerHTML), so it is safe in a page's +// content script and under a strict CSP. + +const SVG = "http://www.w3.org/2000/svg"; + +type Shape = [tag: string, attrs: Record]; + +// Lucide icons (ISC licence), 24×24, drawn with currentColor at a 1.75 stroke. +const ICONS = { + copy: [["rect", { width: "14", height: "14", x: "8", y: "8", rx: "2" }], ["path", { d: "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }]], + eye: [["path", { d: "M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" }], ["circle", { cx: "12", cy: "12", r: "3" }]], + eyeOff: [ + ["path", { d: "M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" }], + ["path", { d: "M14.084 14.158a3 3 0 0 1-4.242-4.242" }], + ["path", { d: "M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" }], + ["path", { d: "m2 2 20 20" }], + ], + check: [["path", { d: "M20 6 9 17l-5-5" }]], + star: [["path", { d: "M12 2.5l2.9 6 6.6.9-4.8 4.6 1.2 6.5L12 17.4l-5.9 3.1 1.2-6.5L2.5 9.4l6.6-.9z" }]], + search: [["circle", { cx: "11", cy: "11", r: "8" }], ["path", { d: "m21 21-4.3-4.3" }]], + plus: [["path", { d: "M5 12h14" }], ["path", { d: "M12 5v14" }]], + lock: [["rect", { width: "18", height: "11", x: "3", y: "11", rx: "2" }], ["path", { d: "M7 11V7a5 5 0 0 1 10 0v4" }]], + sync: [ + ["path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }], ["path", { d: "M21 3v5h-5" }], + ["path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }], ["path", { d: "M8 16H3v5" }], + ], + octagon: [ + ["path", { d: "M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z" }], + ["path", { d: "M12 8v4" }], ["path", { d: "M12 16h.01" }], + ], + triangle: [["path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }], ["path", { d: "M12 9v4" }], ["path", { d: "M12 17h.01" }]], + info: [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "M12 16v-4" }], ["path", { d: "M12 8h.01" }]], + clock: [["circle", { cx: "12", cy: "12", r: "10" }], ["path", { d: "M12 6v6l4 2" }]], + offline: [["path", { d: "M12 20h.01" }], ["path", { d: "M8.5 16.429a5 5 0 0 1 7 0" }], ["path", { d: "M5 12.859a10 10 0 0 1 5.17-2.69" }], ["path", { d: "M19 12.859a10 10 0 0 0-2.007-1.523" }], ["path", { d: "M2 8.82a15 15 0 0 1 4.177-2.643" }], ["path", { d: "M22 8.82a15 15 0 0 0-11.288-3.764" }], ["path", { d: "m2 2 20 20" }]], + trash: [["path", { d: "M3 6h18" }], ["path", { d: "M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" }], ["path", { d: "M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" }]], + key: [["path", { d: "M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" }], ["circle", { cx: "16.5", cy: "7.5", r: ".5" }]], + settings: [ + ["path", { d: "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" }], + ["circle", { cx: "12", cy: "12", r: "3" }], + ], + laptop: [["path", { d: "M20 16V7a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v9m16 0H4m16 0 1.28 2.55a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45L4 16" }]], + link: [["path", { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }], ["path", { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }]], + list: [["path", { d: "M3 12h.01" }], ["path", { d: "M3 18h.01" }], ["path", { d: "M3 6h.01" }], ["path", { d: "M8 12h13" }], ["path", { d: "M8 18h13" }], ["path", { d: "M8 6h13" }]], +} satisfies Record; + +export type IconName = keyof typeof ICONS; + +export function icon(name: IconName, size: "" | "sm" | "xs" = "", label?: string): SVGSVGElement { + const svg = document.createElementNS(SVG, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("class", size ? `kv-ico ${size}` : "kv-ico"); + if (label) { + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", label); + } else svg.setAttribute("aria-hidden", "true"); + for (const [tag, attrs] of ICONS[name] as Shape[]) { + const s = document.createElementNS(SVG, tag); + for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v); + svg.append(s); + } + return svg; +} + +export function h(tag: K, className = "", ...children: (Node | string | null | false)[]): HTMLElementTagNameMap[K] { + const e = document.createElement(tag); + if (className) e.className = className; + for (const c of children) if (c !== null && c !== false) e.append(c); + return e; +} + +// ------------------------------------------------------------------ mark + +export function mark(size = 32): SVGSVGElement { + const svg = document.createElementNS(SVG, "svg"); + svg.setAttribute("class", "kv-mark"); + svg.setAttribute("width", String(size)); + svg.setAttribute("height", String(size)); + svg.setAttribute("viewBox", "0 0 48 48"); + svg.setAttribute("aria-hidden", "true"); + const parts: Shape[] = [ + ["rect", { class: "tile", width: "48", height: "48", rx: "12" }], + ["path", { class: "wing", d: "M14 34 C 18 22, 26 14, 38 11 C 33 16, 29 21, 27 27 L 34 37 L 27 37 L 22 30 Z" }], + ["circle", { class: "eye", cx: "18.5", cy: "17.5", r: "3.5" }], + ["rect", { class: "key", x: "16.5", y: "21", width: "4", height: "13", rx: "2" }], + ]; + for (const [tag, attrs] of parts) { + const s = document.createElementNS(SVG, tag); + for (const [k, v] of Object.entries(attrs)) s.setAttribute(k, v); + svg.append(s); + } + return svg; +} + +export function lockup(markSize = 32, wordClass = "kv-word"): HTMLElement { + return h("span", "kv-lockup", mark(markSize), h("span", wordClass, "kestrove")); +} + +// ------------------------------------------------------------------ item tile + +/** Tile 1…8 from a stable hash of the title: the same on every Kestrove app, so an item keeps its colour. */ +export function tileIndex(title: string): number { + let a = 7; + for (const ch of title) a = (Math.imul(a, 31) + ch.charCodeAt(0)) >>> 0; + return (a % 8) + 1; +} + +export function tile(title: string, small = false): HTMLElement { + const t = h("span", `kv-tile t${tileIndex(title)}${small ? " sm" : ""}`, (title.trim()[0] ?? "?").toUpperCase()); + t.setAttribute("aria-hidden", "true"); + return t; +} + +export function itemRow(title: string, subtitle: string, opts: { favorite?: boolean; compact?: boolean; selected?: boolean } = {}): HTMLButtonElement { + const b = h("button", `kv-item${opts.compact ? " compact" : ""}${opts.selected ? " sel" : ""}`, + tile(title, opts.compact), + h("span", "txt", h("span", "t", title), subtitle ? h("span", "u", subtitle) : null)); + b.type = "button"; + if (opts.favorite) { + const star = icon("star", "sm", "Favourite"); + star.classList.add("kv-star"); + b.append(star); + } + return b; +} + +// ------------------------------------------------------------------ secrets + +/** A masked secret is always 10 dots, whatever its real length. */ +export const MASK = "••••••••••"; + +export function dots(label: string): HTMLElement { + const d = h("span", "kv-dots", MASK); + d.setAttribute("aria-label", `${label} hidden`); + return d; +} + +/** A revealed secret: letters in text, digits in secret-digit, symbols in secret-symbol. */ +export function secret(value: string): HTMLElement { + const out = h("span", "kv-secret"); + let run = ""; + let kind = ""; + const flush = () => { + if (!run) return; + out.append(kind === "l" ? run : h("span", kind, run)); + run = ""; + }; + for (const ch of value) { + const k = /\p{Nd}/u.test(ch) ? "d" : /[\p{L}\p{M}\s]/u.test(ch) ? "l" : "s"; + if (k !== kind) flush(); + kind = k; + run += ch; + } + flush(); + return out; +} + +const THIN = " "; + +/** "482913" → "482 913" with a thin space. */ +export function groupCode(code: string): string { + if (!/^\d{6,8}$/.test(code)) return code; + const half = Math.floor(code.length / 2); + return code.slice(0, half) + THIN + code.slice(half); +} + +/** Read digit by digit: "4 8 2, 9 1 3". */ +export function spokenCode(code: string): string { + return groupCode(code).split(THIN).map((g) => g.split("").join(" ")).join(", "); +} + +export function codeText(code: string, xl = false): HTMLElement { + const c = h("span", xl ? "kv-code xl" : "kv-code", groupCode(code)); + c.setAttribute("aria-label", spokenCode(code)); + return c; +} + +const RING_R = 11; +const RING_C = 2 * Math.PI * RING_R; + +/** The 28px TOTP countdown ring; call update(remaining, period) every second. */ +export function ring(): { el: SVGSVGElement; update: (remaining: number, period: number) => void } { + const svg = document.createElementNS(SVG, "svg"); + svg.setAttribute("class", "kv-ring"); + svg.setAttribute("viewBox", "0 0 28 28"); + svg.setAttribute("aria-hidden", "true"); + const circle = (cls: string) => { + const c = document.createElementNS(SVG, "circle"); + c.setAttribute("class", cls); + c.setAttribute("cx", "14"); + c.setAttribute("cy", "14"); + c.setAttribute("r", String(RING_R)); + svg.append(c); + return c; + }; + circle("track"); + const arc = circle("arc"); + arc.setAttribute("stroke-dasharray", RING_C.toFixed(1)); + return { + el: svg, + update(remaining, period) { + arc.setAttribute("stroke-dashoffset", (RING_C * (1 - remaining / period)).toFixed(1)); + svg.classList.toggle("low", remaining <= 5); + }, + }; +} + +// ------------------------------------------------------------------ buttons, fields, status + +export type ButtonKind = "primary" | "secondary" | "quiet" | "danger" | "danger-fill" | "text"; + +export function button(label: string, kind: ButtonKind = "secondary", onClick?: (e: MouseEvent) => void, extra = ""): HTMLButtonElement { + const b = h("button", `kv-btn ${kind}${extra ? ` ${extra}` : ""}`, label); + b.type = "button"; + if (onClick) b.addEventListener("click", onClick); + return b; +} + +export function iconButton(name: IconName, label: string, onClick?: (e: MouseEvent) => void): HTMLButtonElement { + const b = h("button", "kv-btn icon", icon(name)); + b.type = "button"; + b.setAttribute("aria-label", label); + b.title = label; + if (onClick) b.addEventListener("click", onClick); + return b; +} + +/** A copy button whose icon turns into a success check for 1.5 seconds. */ +export function copyButton(label: string, copy: () => unknown | Promise): HTMLButtonElement { + const b = iconButton("copy", label); + let timer: ReturnType | undefined; + b.addEventListener("click", async () => { + try { + await copy(); + } catch { + return; + } + clearTimeout(timer); + b.replaceChildren(icon("check")); + b.classList.add("ok"); + b.setAttribute("aria-label", "Copied"); + timer = setTimeout(() => { + b.replaceChildren(icon("copy")); + b.classList.remove("ok"); + b.setAttribute("aria-label", label); + }, 1500); + }); + return b; +} + +export function field(label: string, control: HTMLElement, hint?: string): HTMLLabelElement { + return h("label", "kv-fieldset", h("span", "kv-label", label), control, hint ? h("span", "kv-hint", hint) : null); +} + +export function input(props: Partial = {}, mono = false): HTMLInputElement { + const i: HTMLInputElement = h("input", mono ? "kv-field mono" : "kv-field"); + Object.assign(i, props); + if (mono || i.type === "password") { + i.spellcheck = false; + i.autocapitalize = "off"; + i.setAttribute("autocorrect", "off"); + } + return i; +} + +export type PillKind = "ok" | "wait" | "off" | "bad"; +const PILL_ICON: Record = { ok: "check", wait: "clock", off: "offline", bad: "octagon" }; + +export function pill(kind: PillKind, text: string): HTMLElement { + return h("span", `kv-pill ${kind}`, icon(PILL_ICON[kind], "xs"), text); +} + +export type BannerKind = "info" | "warn" | "bad"; +const BANNER_ICON: Record = { info: "info", warn: "triangle", bad: "octagon" }; + +export function banner(kind: BannerKind, title: string, body?: string | Node, ...actions: Node[]): HTMLElement { + const b = h("div", `kv-banner ${kind}`, icon(BANNER_ICON[kind]), + h("div", "", h("b", "", title), body ?? null, actions.length ? h("div", "kv-banner-actions", ...actions) : null)); + if (kind === "bad") b.setAttribute("role", "alert"); + return b; +} + +/** The integrity alert: calm, direct, and it never offers to sync anyway. */ +export function integrityBanner(): HTMLElement { + return banner("bad", "Kestrove stopped syncing this vault", + "Your server sent an older or altered copy of it. Your data on this device is unchanged. Check your server before syncing again."); +} diff --git a/src/ui/tokens.css b/src/ui/tokens.css new file mode 100644 index 0000000..65633a4 --- /dev/null +++ b/src/ui/tokens.css @@ -0,0 +1,124 @@ +/* Kestrove design tokens (design system v1, tokens.json). Generated: change the design system, not this file. + Light on :root; dark follows the OS unless data-theme says otherwise. Half steps escape the dot: var(--space-2\.5). */ +:root { + color-scheme: light; + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --ease: cubic-bezier(0.2, 0.7, 0.2, 1); + --bg: #f4f6f4; + --surface: #ffffff; + --surface-2: #eef2ef; + --surface-3: #e3e9e5; + --border: #d5ddd8; + --border-strong: #b4c0b9; + --border-control: #7a8a83; + --text: #13201b; + --text-2: #4a5852; + --text-3: #5f6d67; + --primary: #19604f; + --primary-hover: #134d3f; + --primary-soft: #dcefe8; + --on-primary: #ffffff; + --accent: #b4481f; + --accent-soft: #fbe7dd; + --danger: #b42318; + --danger-soft: #fde8e6; + --warning: #8a5a00; + --warning-soft: #fff3d6; + --success: #1c6b3a; + --focus: #2f7dd1; + --secret-digit: var(--accent); + --secret-symbol: var(--primary); + --on-tile: #ffffff; + --tile-1: #19604f; + --tile-2: #2f5d8a; + --tile-3: #8a3f7a; + --tile-4: #a24a1f; + --tile-5: #5b5b9e; + --tile-6: #2d6b6b; + --tile-7: #7a5a1e; + --tile-8: #9c3546; + --shadow-sm: 0 1px 2px #10201a0f; + --shadow: 0 1px 2px #10201a0d, 0 8px 24px -8px #10201a1f; + --shadow-lg: 0 24px 64px -16px #10201a47; + --space-0\.5: 2px; + --space-1: 4px; + --space-1\.5: 6px; + --space-2: 8px; + --space-2\.5: 10px; + --space-3: 12px; + --space-3\.5: 14px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-12: 48px; + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 16px; + --radius-icon: 25%; + --radius-pill: 999px; + --radius-round: 50%; + --control-sm: 32px; + --control: 40px; + --control-lg: 48px; + --row: 60px; + --icon-tile: 36px; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --bg: #0b1210; + --surface: #121b18; + --surface-2: #18231f; + --surface-3: #213029; + --border: #26352f; + --border-strong: #3b4d45; + --border-control: #6b7e76; + --text: #e4ebe7; + --text-2: #a9b7b0; + --text-3: #8e9d96; + --primary: #5ec6a3; + --primary-hover: #7fd5b8; + --primary-soft: #173a30; + --on-primary: #05201a; + --accent: #f08b5f; + --accent-soft: #3a2117; + --danger: #ff8a7f; + --danger-soft: #3b1714; + --warning: #f2c14e; + --warning-soft: #33280e; + --success: #6fd49a; + --focus: #7cb8ff; + --shadow-sm: 0 1px 2px #00000066; + --shadow: 0 1px 2px #00000059, 0 8px 24px -8px #00000080; + --shadow-lg: 0 24px 64px -16px #000000b3; + } +} +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #0b1210; + --surface: #121b18; + --surface-2: #18231f; + --surface-3: #213029; + --border: #26352f; + --border-strong: #3b4d45; + --border-control: #6b7e76; + --text: #e4ebe7; + --text-2: #a9b7b0; + --text-3: #8e9d96; + --primary: #5ec6a3; + --primary-hover: #7fd5b8; + --primary-soft: #173a30; + --on-primary: #05201a; + --accent: #f08b5f; + --accent-soft: #3a2117; + --danger: #ff8a7f; + --danger-soft: #3b1714; + --warning: #f2c14e; + --warning-soft: #33280e; + --success: #6fd49a; + --focus: #7cb8ff; + --shadow-sm: 0 1px 2px #00000066; + --shadow: 0 1px 2px #00000059, 0 8px 24px -8px #00000080; + --shadow-lg: 0 24px 64px -16px #000000b3; +}