This repository has been archived on 2026-10-18. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
OpenMFA/crates/webui/src/app.css
T
Miles WardandClaude Fable 5.1 04a86ead95 v0.2: covered codes, service marks, and an audit log
Codes are covered until revealed. A reveal is a server-side, per-session
window (60 s, two periods): POST /api/accounts/{id}/reveal opens it and
returns the code, GET /api/codes only ever returns codes for accounts in
that window, and /hide closes it early. The browser therefore never holds
a code it has not asked for by name, which is what makes the log honest.

Settings -> Log is an audit trail in the style of Daito: one line per
event with who, how long ago and from which address. It records what the
server observed - sign-ins and failed attempts (under the name tried),
reveals, accounts added / renamed / removed, delivery and credential
changes, test messages. Entries are JSON lines in audit.jsonl (owner-only,
newest 5,000 kept, compacted at twice that). The address comes from
X-Forwarded-For / X-Real-IP when parseable, else the TCP peer via
ConnectInfo; in Docker bridge networking that peer is the gateway, which
the README and template now say.

The account list shows each service's mark instead of an initial. The
pack (crates/webui/src/icons.js, generated by scripts/fetch-icons.py) is
~175 marks from Simple Icons (CC0) plus Font Awesome Free brands (CC BY
4.0) for Amazon, AWS, Microsoft, LinkedIn, Slack, Xbox, Salesforce and
Yahoo, which Simple Icons no longer carries. Matching is by issuer name,
then shorter word prefixes, then the label's mail domain; unmatched
accounts keep the initial tile. Marks that would vanish against a theme's
tile fall back to ink for that theme only.

The setup and Delivery platform pickers are plain text; the coloured dots
are gone.

Router tests cover the reveal window, session scoping, 401s, and the
forwarded address landing in the log.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-09 02:08:47 -04:00

876 lines
32 KiB
CSS

/* OpenMFA — web UI.
*
* Visual language follows Beautiful UI (https://www.beautifului.dev/):
* OKLCH ramps, hairline-ring shadows, inset fields / raised buttons,
* Inter + JetBrains Mono. Tokens below were taken from that system and
* extended with the few this app needs (code display, countdown ring).
*
* Fully offline: no CDN, no webfonts fetched at runtime. The font stack
* falls back to the platform UI face, which on every OS Miles runs is
* already a well-tuned grotesque.
*/
/* ── Tokens ───────────────────────────────────────────────────────────── */
:root {
color-scheme: light;
--page: oklch(98.5% .001 286.376);
--canvas: oklch(96.1% .002 247.84);
--surface: oklch(100% 0 0);
--inset: oklch(97.9% .002 247.839);
--hover: oklch(97% .002 247.839);
--hover-2: oklch(93.3% .003 247.86);
--ink: oklch(24.7% .006 258.361);
--ink-2: oklch(50.6% .01 264.477);
--ink-3: oklch(69.5% .009 264.505);
--line: oklch(94.6% .003 264.542);
--line-strong: oklch(91.2% .005 258.326);
--line-soft: oklch(96.6% .002 264.542);
--field: oklch(96.1% .001 286.375);
--stripe: oklch(40.5% 0 0/.075);
--stripe-bg: oklch(97% 0 0);
/* Monochrome accent. The primary colour is ink, not blue: a deep
near-black with a whisper of cool hue in light mode (the Daito /
Beautiful UI treatment), inverted to near-white in dark mode. Status
colours below stay saturated because they carry meaning. */
--accent: oklch(21% .012 262);
--accent-ink: oklch(31% .012 262);
--accent-tint: oklch(0% 0 0 / .07);
--accent-edge: oklch(10% .012 262);
--on-accent: oklch(100% 0 0);
--focus-edge: oklch(50.6% .01 264.477);
--glow: oklch(0% 0 0 / .1);
--selection: oklch(0% 0 0 / .14);
--shadow-fab: 0 6px 20px oklch(0% 0 0 / .18), 0 2px 6px oklch(0% 0 0 / .12);
--green: oklch(60.3% .155 150.883);
--green-tint: oklch(60.3% .155 150.883/.12);
--orange: oklch(68.9% .179 49.902);
--orange-tint: oklch(68.9% .179 49.902/.12);
--red: oklch(62.1% .192 23.042);
--red-tint: oklch(62.1% .192 23.042/.12);
/* Beautiful UI's signature: hairline ring + very soft graduated drop. */
--shadow-card: 0 0 0 1px oklch(94.6% .003 264.542),
0 18px 47px 0 #00000008, 0 7.5px 19px 0 #00000005,
0 4px 10.5px 0 #00000005, 0 2.3px 5.8px 0 #00000003,
0 1.2px 3.1px 0 #00000003, 0 .5px 1.3px 0 #00000003;
--shadow-btn: 0 0 0 1px oklch(91.2% .005 258.326), 0 0 4px 0 #0000000a;
--shadow-raised: 0 0 0 1px oklch(91.2% .005 258.326), 0 2px 10px #0000000f;
--shadow-overlay: 0 0 0 1px oklch(91.2% .005 258.326), 0 16px 48px #00000021;
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.12);
--ring: 0 0 0 4px var(--glow);
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--topbar-h: 60px;
--sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
--ease: cubic-bezier(.22, 1, .36, 1);
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: oklch(20.9% .004 264.477);
--canvas: oklch(23.1% .004 264.487);
--surface: oklch(26% .006 271.191);
--inset: oklch(24.3% .004 264.492);
--hover: oklch(28.9% .006 271.22);
--hover-2: oklch(31.8% .007 274.747);
--ink: oklch(96.4% .002 247.839);
--ink-2: oklch(73.1% .008 260.731);
--ink-3: oklch(54.1% .01 264.484);
--line: oklch(30.8% .006 258.354);
--line-strong: oklch(35.6% .007 264.474);
--line-soft: oklch(27.8% .006 258.354);
--field: oklch(29.3% .006 271.223);
--stripe: oklch(100% 0 0/.055);
--stripe-bg: oklch(22.6% .004 264.485);
--accent: oklch(96.4% .002 247.839);
--accent-ink: oklch(89% .003 248);
--accent-tint: oklch(100% 0 0 / .1);
--accent-edge: oklch(100% 0 0 / .22);
--on-accent: oklch(20.9% .004 264.477);
--focus-edge: oklch(73.1% .008 260.731);
--glow: oklch(100% 0 0 / .15);
--selection: oklch(100% 0 0 / .2);
--shadow-fab: 0 6px 20px oklch(0% 0 0 / .5), 0 2px 6px oklch(0% 0 0 / .35);
--green: oklch(70.5% .154 153.814);
--green-tint: oklch(70.5% .154 153.814/.14);
--orange: oklch(74.6% .156 55.642);
--orange-tint: oklch(74.6% .156 55.642/.14);
--red: oklch(66.6% .18 21.433);
--red-tint: oklch(66.6% .18 21.433/.14);
--shadow-card: 0 0 0 1px oklch(100% 0 0/.11), 0 1px 2px oklch(0% 0 0/.2),
0 2px 6px oklch(0% 0 0/.2);
--shadow-btn: 0 0 0 1px oklch(100% 0 0/.1), 0 1px 2px oklch(0% 0 0/.3);
--shadow-raised: 0 0 0 1px oklch(100% 0 0/.13), 0 2px 10px oklch(0% 0 0/.22);
--shadow-overlay: 0 0 0 1px oklch(100% 0 0/.15), 0 8px 28px oklch(0% 0 0/.34);
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.4);
}
/* ── Base ─────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
background: var(--page);
color: var(--ink);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.02em; }
h1 { font-size: 21px; }
h2 { font-size: 17px; }
h3 { font-size: 14px; }
p { margin: 0; }
a { color: var(--ink); text-decoration: underline;
text-decoration-color: var(--ink-3); text-underline-offset: 2px; }
a:hover { text-decoration-color: var(--ink); }
::selection { background: var(--selection); }
:focus-visible {
outline: none;
box-shadow: var(--ring), var(--shadow-btn);
}
/* Diagonal-stripe backdrop — the Beautiful UI panel treatment. Used
behind the pre-auth screens so setup/login feel like a product, not
a bare form. */
.striped {
background-color: var(--stripe-bg);
background-image: repeating-linear-gradient(
-45deg,
var(--stripe) 0 1px,
transparent 1px 9px
);
}
/* ── Shell ────────────────────────────────────────────────────────────── */
.shell { display: flex; flex-direction: column; min-height: 100vh; }
/* Three columns: the search occupies the middle one, so it lands on the same
centre line as the account tiles below no matter how wide the brand or the
action cluster get. The outer columns are equal-width and allowed to
shrink, which keeps the centring exact rather than approximate. */
.topbar {
position: sticky; top: 0; z-index: 30;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 420px) minmax(0, 1fr);
align-items: center; gap: 12px;
height: var(--topbar-h);
padding: 0 16px;
background: color-mix(in oklab, var(--page) 82%, transparent);
backdrop-filter: saturate(1.6) blur(12px);
border-bottom: 1px solid var(--line);
}
.topbar .brand { justify-self: start; min-width: 0; }
.topbar .brand strong { overflow: hidden; text-overflow: ellipsis; }
.topbar .actions {
justify-self: end;
display: flex; align-items: center; gap: 2px;
}
/* True centring needs room for all three: the brand wordmark (~210px), the
420px search, the action cluster (~110px), plus gaps and padding — about
800px in total. Below that the grid starts clipping "OpenMFA",
so fall back to a plain row where the brand keeps its space and the search
takes whatever is left. */
@media (max-width: 820px) {
.topbar { display: flex; }
.topbar .brand { flex: 0 0 auto; }
.topbar .search { flex: 1 1 auto; justify-self: auto; }
.topbar .actions { margin-left: auto; flex: 0 0 auto; }
}
.brand { display: flex; align-items: center; gap: 9px; flex: none; }
.brand svg { width: 26px; height: 26px; display: block; }
.brand strong {
font-size: 14.5px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap;
}
@media (max-width: 560px) { .brand strong { display: none; } }
.topbar .spacer { flex: 1; }
.main {
flex: 1;
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 22px 16px 128px;
}
/* ── Search ───────────────────────────────────────────────────────────── */
.search {
position: relative;
width: 100%;
max-width: 420px;
justify-self: center;
}
.search svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 15px; height: 15px; color: var(--ink-3); pointer-events: none;
}
.search input {
width: 100%; height: 34px;
padding: 0 30px 0 31px;
font: inherit; font-size: 13.5px;
color: var(--ink);
background: var(--field);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-inset-field);
transition: box-shadow .15s var(--ease), border-color .15s var(--ease);
}
.search input::placeholder { color: var(--ink-3); }
.search input:focus {
outline: none;
border-color: var(--focus-edge);
box-shadow: var(--shadow-inset-field), var(--ring);
}
.search .clear {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
display: none; place-items: center;
width: 20px; height: 20px; padding: 0;
border: 0; border-radius: 5px;
background: transparent; color: var(--ink-3); cursor: pointer;
}
.search .clear:hover { background: var(--hover-2); color: var(--ink); }
.search.has-value .clear { display: grid; }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
height: 34px; padding: 0 13px;
font: inherit; font-size: 13.5px; font-weight: 500;
color: var(--ink);
background: var(--surface);
border: 0; border-radius: var(--radius);
box-shadow: var(--shadow-btn);
cursor: pointer;
white-space: nowrap;
transition: background .14s var(--ease), transform .14s var(--ease),
box-shadow .14s var(--ease);
}
.btn:hover { background: var(--hover); }
.btn:active { transform: translateY(0.5px); }
.btn[disabled] { opacity: .5; pointer-events: none; }
.btn svg { width: 15px; height: 15px; flex: none; }
.btn-primary {
background: var(--accent); color: var(--on-accent);
box-shadow: 0 0 0 1px var(--accent-edge), 0 1px 2px oklch(0% 0 0 / .15);
}
.btn-primary:hover { background: var(--accent-ink); }
.btn-danger { color: var(--red); }
.btn-danger:hover { background: var(--red-tint); }
.btn-ghost { background: transparent; box-shadow: none; color: var(--ink-2); }
.btn-ghost:hover { background: var(--hover-2); color: var(--ink); }
.btn-lg { height: 40px; padding: 0 18px; font-size: 14px; }
.btn-block { width: 100%; }
.icon-btn {
display: grid; place-items: center;
width: 32px; height: 32px; padding: 0;
border: 0; border-radius: var(--radius);
background: transparent; color: var(--ink-2);
cursor: pointer;
transition: background .14s var(--ease), color .14s var(--ease);
}
.icon-btn:hover { background: var(--hover-2); color: var(--ink); }
.icon-btn svg { width: 17px; height: 17px; }
/* Theme toggle shows one glyph per theme. */
.theme-toggle .t-moon { display: none; }
:root[data-theme="dark"] .theme-toggle .t-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .t-moon { display: block; }
/* ── Cards & panels ───────────────────────────────────────────────────── */
.card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.card + .card { margin-top: 14px; }
.card-head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.card-head h2 { flex: 1; }
.card-body { padding: 16px; }
.card-sub { font-size: 12.5px; color: var(--ink-2); margin-top: 3px; }
/* ── Forms ────────────────────────────────────────────────────────────── */
.field { margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
.field label {
display: block; margin-bottom: 6px;
font-size: 12.5px; font-weight: 500; color: var(--ink-2);
}
.field .hint { margin-top: 6px; font-size: 12px; color: var(--ink-3); }
.input, .select, textarea.input {
width: 100%; height: 38px;
padding: 0 11px;
font: inherit; font-size: 13.5px;
color: var(--ink);
background: var(--field);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-inset-field);
transition: border-color .15s var(--ease), box-shadow .15s var(--ease);
}
textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
resize: vertical; font-family: var(--mono); font-size: 12.5px;
line-height: 1.6; }
.input::placeholder { color: var(--ink-3); }
.input:focus, .select:focus, textarea.input:focus {
outline: none; border-color: var(--focus-edge);
box-shadow: var(--shadow-inset-field), var(--ring);
}
.input[aria-invalid="true"] { border-color: var(--red); }
.select { appearance: none; cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
background-position: calc(100% - 15px) 17px, calc(100% - 10px) 17px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-right: 30px;
}
.row { display: flex; gap: 10px; }
.row > * { flex: 1; min-width: 0; }
/* Switch */
.switch { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.switch input { position: absolute; opacity: 0; pointer-events: none; }
.switch .track {
position: relative; flex: none;
width: 34px; height: 20px;
background: var(--hover-2);
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line-strong);
transition: background .18s var(--ease);
}
.switch .track::after {
content: ""; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px;
background: var(--surface); border-radius: 50%;
box-shadow: 0 1px 2px #00000033;
transition: transform .18s var(--ease);
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(14px); }
.switch input:focus-visible + .track { box-shadow: inset 0 0 0 1px var(--line-strong), var(--ring); }
.switch .switch-label { font-size: 13.5px; }
/* ── Account list ─────────────────────────────────────────────────────── */
.accounts { display: flex; flex-direction: column; gap: 10px; }
.acct {
display: grid;
grid-template-columns: 38px 1fr auto;
align-items: center;
gap: 13px;
padding: 13px 15px;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
transition: background .14s var(--ease);
}
.acct:hover { background: var(--hover); }
/* Initials tile. Shared by the account list and the import preview. Neutral
rather than a per-issuer hue so the list stays monochrome; the rendered
chat tile keeps its issuer colour, where it helps tell accounts apart. */
.avatar {
display: grid; place-items: center;
width: 38px; height: 38px;
border-radius: 10px;
font-size: 15px; font-weight: 600;
color: var(--ink);
background: var(--hover-2);
box-shadow: inset 0 0 0 1px var(--line-strong);
user-select: none;
}
/* A recognised service shows its mark in the brand colour. Marks that are
near-black or near-white would vanish against one of the two tiles, so
those fall back to ink for that theme only. */
.avatar.brand { background: var(--surface); }
.avatar.brand svg { width: 20px; height: 20px; fill: var(--brand, currentColor); display: block; }
:root[data-theme="dark"] .avatar.brand[data-tone="dark"] svg { fill: currentColor; }
:root[data-theme="light"] .avatar.brand[data-tone="light"] svg { fill: currentColor; }
.import-row .avatar.brand svg { width: 16px; height: 16px; }
.acct .meta { min-width: 0; }
.acct .issuer {
font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct .label {
font-size: 12px; color: var(--ink-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct .right { display: flex; align-items: center; gap: 11px; }
/* The code itself — the thing people came for. Tabular figures so the
digits don't shimmy as they change, and a word-gap in the middle the
way every authenticator app renders it. */
.code {
font-family: var(--mono);
font-size: 25px;
font-weight: 500;
letter-spacing: 0.055em;
font-variant-numeric: tabular-nums;
color: var(--ink);
cursor: pointer;
border: 0; background: none; padding: 2px 4px;
border-radius: 6px;
transition: color .2s var(--ease), background .14s var(--ease);
line-height: 1.1;
}
.code:hover { background: var(--hover-2); }
.code .gap { display: inline-block; width: 0.42em; }
/* Covered: the same footprint as the digits, so rows don't jump on reveal. */
.code.covered {
display: inline-flex; align-items: center; gap: 9px;
color: var(--ink-3);
letter-spacing: 0.1em;
}
.code.covered svg { width: 17px; height: 17px; color: var(--ink-2); flex: none; }
.code.covered:hover { color: var(--ink-2); }
.code.covered:hover svg { color: var(--ink); }
/* Last 5 seconds: the code is about to roll. */
.acct.expiring .code { color: var(--orange); }
/* Countdown ring */
.ring { width: 26px; height: 26px; flex: none; transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 2.5; }
.ring .bg { stroke: var(--line-strong); }
.ring .fg {
stroke: var(--accent); stroke-linecap: round;
transition: stroke-dashoffset .95s linear, stroke .3s var(--ease);
}
.acct.expiring .ring .fg { stroke: var(--orange); }
/* Discord push status */
.push-dot {
width: 7px; height: 7px; border-radius: 50%; flex: none;
background: var(--ink-3);
}
.push-dot.live { background: var(--green); box-shadow: 0 0 0 3px var(--green-tint); }
.push-dot.error { background: var(--red); box-shadow: 0 0 0 3px var(--red-tint); }
.push-dot.paused { background: var(--ink-3); }
/* ── Chips ────────────────────────────────────────────────────────────── */
.chip {
display: inline-flex; align-items: center; gap: 5px;
height: 22px; padding: 0 8px;
font-size: 11.5px; font-weight: 500;
color: var(--ink-2);
background: var(--inset);
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line);
white-space: nowrap;
}
.chip.ok { color: var(--green); background: var(--green-tint); box-shadow: none; }
.chip.warn { color: var(--orange); background: var(--orange-tint); box-shadow: none; }
.chip.err { color: var(--red); background: var(--red-tint); box-shadow: none; }
/* ── Empty state ──────────────────────────────────────────────────────── */
.empty {
display: flex; flex-direction: column; align-items: center;
gap: 5px;
padding: 56px 24px 60px;
text-align: center;
border-radius: var(--radius-xl);
}
.empty .glyph {
display: grid; place-items: center;
width: 60px; height: 60px; margin-bottom: 14px;
border-radius: 18px;
color: var(--accent);
background: var(--accent-tint);
}
.empty .glyph svg { width: 28px; height: 28px; }
.empty h2 { font-size: 16px; }
.empty p { font-size: 13px; color: var(--ink-2); max-width: 330px; }
.empty .btn { margin-top: 16px; }
/* ── Floating action button ───────────────────────────────────────────── */
.fab {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 25;
display: inline-flex; align-items: center; gap: 8px;
height: 46px; padding: 0 19px;
font: inherit; font-size: 14px; font-weight: 500;
color: var(--on-accent);
background: var(--accent);
border: 0; border-radius: 999px;
box-shadow: 0 0 0 1px var(--accent-edge), var(--shadow-fab);
cursor: pointer;
transition: transform .16s var(--ease), background .16s var(--ease);
}
.fab:hover { background: var(--accent-ink); transform: translateY(-1px); }
.fab:active { transform: translateY(0); }
.fab svg { width: 17px; height: 17px; }
/* ── Modal ────────────────────────────────────────────────────────────── */
.scrim {
position: fixed; inset: 0; z-index: 40;
display: grid; place-items: center;
padding: 20px;
background: oklch(0% 0 0 / .45);
backdrop-filter: blur(3px);
animation: fade .16s var(--ease);
}
.modal {
width: 100%; max-width: 460px;
max-height: calc(100vh - 40px);
display: flex; flex-direction: column;
background: var(--surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-overlay);
animation: pop .2s var(--ease);
overflow: hidden;
}
.modal-head {
display: flex; align-items: center; gap: 10px;
padding: 15px 16px;
border-bottom: 1px solid var(--line);
}
.modal-head h2 { flex: 1; }
.modal-body { padding: 16px; overflow-y: auto; }
.modal-foot {
display: flex; justify-content: flex-end; gap: 8px;
padding: 13px 16px;
border-top: 1px solid var(--line);
background: var(--inset);
}
@keyframes fade { from { opacity: 0 } }
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(.98) } }
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.tabs {
display: flex; gap: 3px;
padding: 3px;
background: var(--inset);
border-radius: 10px;
box-shadow: inset 0 0 0 1px var(--line);
margin-bottom: 16px;
}
.tabs button {
flex: 1;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 32px; padding: 0 8px;
font: inherit; font-size: 12.5px; font-weight: 500;
color: var(--ink-2);
background: transparent; border: 0; border-radius: 7px;
cursor: pointer;
transition: background .14s var(--ease), color .14s var(--ease);
}
.tabs button svg { width: 14px; height: 14px; }
.tabs button:hover { color: var(--ink); }
.tabs button.active {
color: var(--ink);
background: var(--surface);
box-shadow: var(--shadow-btn);
}
/* ── Dropzone (QR upload) ─────────────────────────────────────────────── */
.dropzone {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 34px 20px;
text-align: center;
color: var(--ink-2);
background: var(--inset);
border: 1.5px dashed var(--line-strong);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color .16s var(--ease), background .16s var(--ease);
}
.dropzone:hover, .dropzone.over {
border-color: var(--focus-edge);
background: var(--accent-tint);
}
.dropzone svg { width: 26px; height: 26px; color: var(--ink-3); margin-bottom: 8px; }
.dropzone.over svg { color: var(--accent); }
.dropzone strong { font-size: 13.5px; color: var(--ink); font-weight: 500; }
.dropzone span { font-size: 12px; }
/* Camera scanner */
.scanner {
position: relative;
aspect-ratio: 1;
width: 100%;
border-radius: var(--radius-lg);
overflow: hidden;
background: oklch(15% 0 0);
box-shadow: inset 0 0 0 1px var(--line);
}
.scanner video { width: 100%; height: 100%; object-fit: cover; display: block; }
.scanner .reticle {
position: absolute; inset: 16%;
border-radius: 14px;
box-shadow: 0 0 0 3px oklch(100% 0 0 / .85),
0 0 0 9999px oklch(0% 0 0 / .45);
}
/* ── Segmented provider picker ──────────────────────────────────────── */
.segmented {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px;
padding: 3px;
background: var(--inset);
border-radius: 10px;
box-shadow: inset 0 0 0 1px var(--line);
}
.segmented button {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
height: 36px; padding: 0 10px;
font: inherit; font-size: 13px; font-weight: 500;
color: var(--ink-2);
background: transparent; border: 0; border-radius: 7px;
cursor: pointer;
transition: background .14s var(--ease), color .14s var(--ease);
}
.segmented button:hover { color: var(--ink); }
.segmented button.active {
color: var(--ink);
background: var(--surface);
box-shadow: var(--shadow-btn);
}
.provider-fields:not(:empty) { margin-top: 14px; }
.provider-fields .alert { margin-bottom: 14px; }
.brand svg { width: 26px; height: 26px; display: block; }
.auth-card .brand svg { width: 34px; height: 34px; }
/* ── Import list (Google export / multi-account) ───────────────────── */
.import-head {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; margin: 14px 0 8px;
}
.btn-sm { height: 26px; padding: 0 9px; font-size: 12px; }
/* Capped so a 20-account export scrolls inside the modal rather than
pushing the Import button off the bottom of the screen. */
.import-list {
max-height: 320px;
overflow-y: auto;
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 1px var(--line);
background: var(--inset);
}
.import-row {
display: grid;
grid-template-columns: auto 30px 1fr auto;
align-items: center;
gap: 11px;
padding: 9px 12px;
cursor: pointer;
transition: background .12s var(--ease);
}
.import-row + .import-row { border-top: 1px solid var(--line-soft); }
.import-row:hover { background: var(--hover); }
.import-row input[type="checkbox"] {
width: 16px; height: 16px; margin: 0;
accent-color: var(--accent);
cursor: pointer;
flex: none;
}
.import-row .avatar {
width: 30px; height: 30px; border-radius: 8px; font-size: 13px;
}
.import-row .meta { min-width: 0; }
.import-row .issuer { font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.import-row .label { font-size: 11.5px; color: var(--ink-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.import-row .tags { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
.import-row .tags .chip { height: 19px; padding: 0 7px; font-size: 10.5px; }
/* An account already in the vault is de-emphasised but still selectable. */
.import-row:has(input:not(:checked)) .meta { opacity: .55; }
/* ── Toast ────────────────────────────────────────────────────────────── */
.toasts {
position: fixed; left: 50%; bottom: 22px; z-index: 60;
transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px;
align-items: center;
pointer-events: none;
}
.toast {
display: flex; align-items: center; gap: 8px;
padding: 9px 14px;
font-size: 13px; font-weight: 500;
color: var(--ink);
background: var(--surface);
border-radius: 999px;
box-shadow: var(--shadow-overlay);
animation: toast-in .22s var(--ease);
}
.toast svg { width: 15px; height: 15px; flex: none; }
.toast.ok svg { color: var(--green); }
.toast.err svg { color: var(--red); }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px) } }
/* ── Auth screens (setup / login) ─────────────────────────────────────── */
.auth-wrap {
min-height: 100vh;
display: grid; place-items: center;
padding: 24px;
}
.auth-card {
width: 100%; max-width: 400px;
padding: 28px;
background: var(--surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-overlay);
}
.auth-card .brand {
justify-content: center;
margin-bottom: 6px;
}
.auth-card .brand svg { width: 34px; height: 34px; }
.auth-card h1 { text-align: center; font-size: 19px; margin-top: 12px; }
.auth-card .sub {
text-align: center; font-size: 13px; color: var(--ink-2);
margin: 5px 0 22px;
}
.auth-card .btn { margin-top: 6px; }
.alert {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 12px; margin-bottom: 14px;
font-size: 12.5px; line-height: 1.45;
border-radius: var(--radius);
}
.alert svg { width: 15px; height: 15px; flex: none; margin-top: 1px; }
.alert.err { color: var(--red); background: var(--red-tint); }
.alert.warn { color: var(--orange); background: var(--orange-tint); }
.alert.info { color: var(--ink-2); background: var(--inset);
box-shadow: inset 0 0 0 1px var(--line); }
/* ── Settings ─────────────────────────────────────────────────────────── */
/* 8px, not 4: each pill carries a 1px hairline ring outside its box, so a
smaller gap reads as the pills touching. */
.settings-nav { display: flex; gap: 8px; margin-bottom: 18px; flex-wrap: wrap; }
.kv { display: flex; justify-content: space-between; gap: 16px;
padding: 9px 0; font-size: 13px; }
.kv + .kv { border-top: 1px solid var(--line-soft); }
.kv dt { color: var(--ink-2); }
.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px;
text-align: right; word-break: break-all; }
/* ── Utility ──────────────────────────────────────────────────────────── */
/* ── Log ──────────────────────────────────────────────────────────────── */
.log-list { display: flex; flex-direction: column; }
.log-row {
display: grid; grid-template-columns: 28px 1fr; gap: 12px;
padding: 11px 0;
}
.log-row + .log-row { border-top: 1px solid var(--line-soft); }
.log-glyph {
display: grid; place-items: center;
width: 28px; height: 28px; margin-top: 1px;
border-radius: 8px;
color: var(--ink-2);
background: var(--inset);
box-shadow: inset 0 0 0 1px var(--line);
}
.log-glyph svg { width: 14px; height: 14px; }
.log-body { min-width: 0; }
.log-line { font-size: 13.5px; line-height: 1.45; overflow-wrap: anywhere; }
.log-meta {
display: flex; flex-wrap: wrap; gap: 0 9px;
margin-top: 3px;
font-size: 12px; color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.log-meta span + span::before { content: "|"; margin-right: 9px; color: var(--line-strong); }
.log-empty { padding: 26px 0; text-align: center; font-size: 13px; color: var(--ink-3); }
.mono { font-family: var(--mono); }
.muted { color: var(--ink-2); }
.small { font-size: 12.5px; }
.stack { display: flex; flex-direction: column; gap: 14px; }
.stack-s { display: flex; flex-direction: column; gap: 8px; }
.hidden { display: none !important; }
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg) } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
}