Apply the Kestrove design system to the desktop UI

- src/ui: tokens.css (generated from the design system's tokens.json), kv.css components, fonts.css with
  the bundled Inter and JetBrains Mono woff2, kv.ts DOM builders (icons, tiles, item rows, secrets,
  codes, TOTP ring, pills, banners). The browser extension uses the same module.
- Layout: custom title bar in bg (macOS overlay title bar, traffic lights kept), left rail, list and detail.
- Item detail: field rows with 10-dot masking, coloured digits/symbols on reveal (re-masks after 30 s),
  copy turns into a check for 1.5 s, TOTP in code-lg with the ring.
- Add-a-device QR card, Use my phone QR with countdown and confirmation code, approve sheet.
- Status pills, banners and the integrity alert; Light / Dark / System in Settings (also sets the window theme).
- totp command returns the period for the ring.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 16:35:58 +09:00
co-authored by Claude Opus 5.5
parent f406bf76ec
commit 19c4ce1899
13 changed files with 1225 additions and 427 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<title>Kestrove</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<body class="kv-root">
<main id="app"></main>
<div id="overlay" hidden></div>
<div id="modal" hidden></div>
+2 -2
View File
@@ -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"]
}
+2 -1
View File
@@ -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<Option<Totp>> {
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.
+1 -1
View File
@@ -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"
}
+520 -350
View File
File diff suppressed because it is too large Load Diff
+101 -58
View File
@@ -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); } }
+15
View File
@@ -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");
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+158
View File
@@ -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; }
}
+287
View File
@@ -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<string, string>];
// 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<string, Shape[]>;
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<K extends keyof HTMLElementTagNameMap>(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<unknown>): HTMLButtonElement {
const b = iconButton("copy", label);
let timer: ReturnType<typeof setTimeout> | 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<HTMLInputElement> = {}, 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<PillKind, IconName> = { 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<BannerKind, IconName> = { 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.");
}
+124
View File
@@ -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;
}