1765 lines
54 KiB
CSS
1765 lines
54 KiB
CSS
@import 'tailwindcss';
|
|
|
|
/* Manual dark mode: Tailwind v4 defaults `dark:` to the OS preference, but this
|
|
app needs a user-controlled toggle independent of the OS, so `dark:` is
|
|
redefined to key off a `.dark` class on <html> instead. */
|
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
|
|
/* ══ Tokens ══════════════════════════════════════════════════════════════════
|
|
One palette for the whole product, declared once here and nowhere else. Every
|
|
component reads these through the Tailwind utilities registered in the
|
|
`@theme inline` block below (`bg-surface`, `text-ink`, `border-line`, …), so
|
|
a colour decision is made in this file or not at all.
|
|
|
|
LIGHT is the default set; `.dark` below overrides only what changes. Those
|
|
are the only two appearances. An `eye` mode used to sit beside them — the
|
|
light palette under a warm sepia filter — and it is gone: it was a third
|
|
appearance to keep in sync for a warmth effect the OS and the monitor both
|
|
already offer.
|
|
|
|
The ink ramp is three steps on purpose. `--ink` is for anything being read,
|
|
`--ink-2` for text that supports it (group headers, a document name in a
|
|
toolbar), `--muted` for labels and captions that should be found rather than
|
|
read. Anything reaching for a fourth is usually reaching for the wrong one. */
|
|
:root {
|
|
--ground: #f4f3f7;
|
|
--rail: #ffffff;
|
|
--surface: #ffffff;
|
|
--surface-2: #f7f6fa;
|
|
--line: #e2dfe9;
|
|
--line-soft: #eceaf1;
|
|
--ink: #18151f;
|
|
--ink-2: #3d3850;
|
|
--muted: #66607a;
|
|
--shadow: 0 20px 50px -26px rgba(30, 20, 60, 0.3);
|
|
}
|
|
|
|
.dark {
|
|
--ground: #0a0912;
|
|
--rail: #100e1a;
|
|
--surface: #151220;
|
|
--surface-2: #1b1727;
|
|
--line: #2a2438;
|
|
--line-soft: #221d31;
|
|
--ink: #f3f1f8;
|
|
--ink-2: #c9c4d8;
|
|
--muted: #8d87a0;
|
|
--shadow: 0 24px 60px -28px rgba(0, 0, 0, 0.75);
|
|
}
|
|
|
|
/* The brand ramp — identical in both appearances, and the same two hues the
|
|
sign-in screen uses, so signing in is a continuation rather than a change of
|
|
product. This is the ONLY saturated thing on a page: the header bar, primary
|
|
buttons, the active nav row, the super-admin badge, the progress fill.
|
|
Nothing else gets the gradient.
|
|
|
|
`--brand-*` are the sign-in screen's own names for the same two hues, kept
|
|
because `.sb-login` and the agents layer reference them and that screen is
|
|
deliberately untouched. --accent-1/--accent-2 are the names the rest of the
|
|
product uses. Same colours, two vocabularies, no second palette. */
|
|
:root {
|
|
--accent-1: #ff3049;
|
|
--accent-2: #f26b21;
|
|
|
|
--brand-red-hot: var(--accent-1);
|
|
--brand-orange: var(--accent-2);
|
|
|
|
/* The header bar and the primary button. 100deg for the wide header band so
|
|
the two stops are read left-to-right across it; 115deg on buttons so a
|
|
small pill still shows both hues rather than reading as one flat colour. */
|
|
--brand-bar: linear-gradient(100deg, var(--accent-1), var(--accent-2));
|
|
--brand-grad: linear-gradient(115deg, var(--accent-1), var(--accent-2));
|
|
|
|
/* The one accent, for focus rings and confirm affordances. Repointed from the
|
|
old #c81e3a to the ramp's own hot end so a focus ring matches the button it
|
|
lands on. */
|
|
--accent: var(--accent-1);
|
|
--accent-fg: #ffffff;
|
|
}
|
|
|
|
/* ── Grading colours ────────────────────────────────────────────────────────
|
|
Semantic, never decorative. These say how legibly a value was PRINTED, not
|
|
whether it is correct — a cleanly printed misread still scores clean.
|
|
|
|
They appear on status pills, the 3px grade border down a field input, legend
|
|
swatches, donut segments and quality meters. Never on a button, never on a
|
|
heading: the moment green means "primary action" as well as "clean print",
|
|
neither reading survives. */
|
|
:root {
|
|
--clean: #51a68f;
|
|
--verify: #e39b34;
|
|
--review: #de5243;
|
|
/* "Not on the page" — the fourth donut segment and the blank band. Grey on
|
|
purpose: nothing was read, so there is no clarity to grade. */
|
|
--absent: #6b6580;
|
|
/* The logo's shield body. Fixed in both appearances — it is artwork. */
|
|
--shield: #21395a;
|
|
}
|
|
|
|
/* Register the tokens as Tailwind colours so components use `bg-surface`,
|
|
`text-muted`, `border-line` and never a hex.
|
|
|
|
`@theme inline` rather than plain `@theme`: inline emits the utilities as
|
|
`var(--surface)` at the use site instead of snapshotting the value into a
|
|
generated variable, which is what lets `.dark` reassign the token and repaint
|
|
every utility built on it. Plain `@theme` would freeze the light value. */
|
|
@theme inline {
|
|
--color-ground: var(--ground);
|
|
--color-rail: var(--rail);
|
|
--color-surface: var(--surface);
|
|
--color-surface-2: var(--surface-2);
|
|
--color-line: var(--line);
|
|
--color-line-soft: var(--line-soft);
|
|
--color-ink: var(--ink);
|
|
--color-ink-2: var(--ink-2);
|
|
--color-muted: var(--muted);
|
|
--color-accent-1: var(--accent-1);
|
|
--color-accent-2: var(--accent-2);
|
|
--color-clean: var(--clean);
|
|
--color-verify: var(--verify);
|
|
--color-review: var(--review);
|
|
--color-absent: var(--absent);
|
|
--color-shield: var(--shield);
|
|
|
|
--radius-card: 14px;
|
|
|
|
--font-sora: 'Sora', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
|
--font-plex: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
|
}
|
|
|
|
body {
|
|
background: var(--ground);
|
|
color: var(--ink);
|
|
/* IBM Plex Sans for body, Sora for display — the sign-in screen's pairing,
|
|
now the product's. The interface previously ran on the system stack as a
|
|
density choice for 11-13px lists; the two screens reading as different
|
|
products was the larger cost. Both carry the full system fallback, so a
|
|
blocked CDN costs voice and nothing else. */
|
|
font-family: var(--font-plex);
|
|
/* Never transition the body. The reduced-motion block at the end of this file
|
|
sets `transition-property: all` on every element to collapse durations,
|
|
which would make the body's own background transitionable — and switching
|
|
appearance is a state change, not an animation. */
|
|
transition: none !important;
|
|
}
|
|
|
|
/* Money, counts, timestamps and confidence figures line up in a column. */
|
|
.tnum,
|
|
.sb-num {
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Display type. One class rather than a utility per call site, because every
|
|
use is the same decision: this is a heading, a figure, or a control label. */
|
|
.sb-display {
|
|
font-family: var(--font-sora);
|
|
}
|
|
|
|
/* ══ Signed-in shell and components ══════════════════════════════════════════
|
|
Ported from the approved `inner-pages.html` mockup. Values are the mockup's,
|
|
not re-derived, so the two stay comparable.
|
|
|
|
One card radius (14px), one border colour (--line), and hierarchy from
|
|
spacing rather than shadows: a page of cards at three elevations reads as a
|
|
pile, and this product's screens are lists of peers. */
|
|
|
|
/* ── header ─────────────────────────────────────────────────────────────── */
|
|
.sb-appbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 12px 18px;
|
|
background: var(--brand-bar);
|
|
color: #fff;
|
|
}
|
|
.sb-appbar-mark {
|
|
width: 34px;
|
|
height: 34px;
|
|
flex: none;
|
|
filter: drop-shadow(0 0 8px rgba(0, 0, 0, 0.35));
|
|
}
|
|
.sb-appbar-mark svg {
|
|
display: block;
|
|
width: 34px;
|
|
height: 34px;
|
|
}
|
|
.sb-appbar-title {
|
|
font-family: var(--font-sora);
|
|
font-weight: 700;
|
|
font-size: 17px;
|
|
letter-spacing: 0.005em;
|
|
line-height: 1.15;
|
|
}
|
|
.sb-appbar-sub {
|
|
font-size: 11.5px;
|
|
opacity: 0.86;
|
|
margin-top: 2px;
|
|
}
|
|
.sb-who {
|
|
text-align: right;
|
|
line-height: 1.2;
|
|
}
|
|
.sb-who b {
|
|
font-family: var(--font-sora);
|
|
font-size: 13px;
|
|
display: block;
|
|
}
|
|
.sb-who span {
|
|
font-size: 10px;
|
|
letter-spacing: 0.08em;
|
|
opacity: 0.85;
|
|
}
|
|
/* A button ON the gradient. It cannot use --surface tokens: the bar is the one
|
|
saturated surface in the product and a token-coloured control on it would
|
|
read as a hole punched through the band. */
|
|
.sb-ghost-btn {
|
|
background: rgba(255, 255, 255, 0.16);
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
padding: 6px 12px;
|
|
font: 600 12.5px var(--font-plex);
|
|
cursor: pointer;
|
|
}
|
|
.sb-ghost-btn:hover {
|
|
background: rgba(255, 255, 255, 0.26);
|
|
}
|
|
.sb-themes {
|
|
display: flex;
|
|
gap: 2px;
|
|
background: rgba(0, 0, 0, 0.22);
|
|
border-radius: 999px;
|
|
padding: 3px;
|
|
}
|
|
.sb-themes button {
|
|
border: none;
|
|
background: transparent;
|
|
color: rgba(255, 255, 255, 0.85);
|
|
font: 600 11.5px var(--font-plex);
|
|
padding: 5px 11px;
|
|
border-radius: 999px;
|
|
cursor: pointer;
|
|
}
|
|
.sb-themes button[aria-pressed='true'] {
|
|
background: rgba(0, 0, 0, 0.45);
|
|
color: #fff;
|
|
}
|
|
|
|
/* ── sidebar ────────────────────────────────────────────────────────────── */
|
|
.sb-rail {
|
|
background: var(--rail);
|
|
border-right: 1px solid var(--line);
|
|
padding: 10px 0;
|
|
}
|
|
.sb-rail-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
padding: 12px 16px 8px;
|
|
color: var(--ink-2);
|
|
font-family: var(--font-sora);
|
|
font-weight: 600;
|
|
font-size: 11.5px;
|
|
line-height: 1.25;
|
|
}
|
|
.sb-rail-group svg {
|
|
width: 15px;
|
|
height: 15px;
|
|
flex: none;
|
|
color: var(--accent-2);
|
|
}
|
|
.sb-rail-divider {
|
|
border: none;
|
|
border-top: 1px solid var(--line-soft);
|
|
margin: 8px 14px;
|
|
}
|
|
.sb-nav-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
text-align: left;
|
|
padding: 9px 16px;
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
color: var(--muted);
|
|
font: 500 13px var(--font-plex);
|
|
/* Transparent rather than absent, so the row does not shift 3px when it
|
|
becomes current. */
|
|
border-left: 3px solid transparent;
|
|
}
|
|
.sb-nav-item svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
flex: none;
|
|
}
|
|
.sb-nav-item:hover {
|
|
color: var(--ink-2);
|
|
background: var(--surface-2);
|
|
}
|
|
.sb-nav-item[aria-current='page'] {
|
|
color: var(--ink);
|
|
font-weight: 600;
|
|
border-left-color: var(--accent-1);
|
|
background: linear-gradient(90deg, color-mix(in srgb, var(--accent-1) 12%, transparent), transparent);
|
|
}
|
|
.sb-count {
|
|
margin-left: auto;
|
|
min-width: 20px;
|
|
text-align: center;
|
|
font: 600 10.5px var(--font-plex);
|
|
font-variant-numeric: tabular-nums;
|
|
padding: 2px 6px;
|
|
border-radius: 999px;
|
|
background: var(--surface-2);
|
|
color: var(--muted);
|
|
border: 1px solid var(--line);
|
|
}
|
|
/* Red only when the number is something a person has to act on. A count that
|
|
is red at rest teaches people to ignore red. */
|
|
.sb-count.sb-count-hot {
|
|
background: var(--accent-1);
|
|
color: #fff;
|
|
border-color: transparent;
|
|
}
|
|
|
|
/* ── page furniture ─────────────────────────────────────────────────────── */
|
|
.sb-page-title {
|
|
font-family: var(--font-sora);
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
margin: 0 0 4px;
|
|
letter-spacing: -0.01em;
|
|
color: var(--ink);
|
|
}
|
|
.sb-page-sub {
|
|
margin: 0 0 18px;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
}
|
|
.sb-eyebrow {
|
|
font: 600 10.5px var(--font-plex);
|
|
letter-spacing: 0.12em;
|
|
color: var(--muted);
|
|
text-transform: uppercase;
|
|
margin: 0 0 10px;
|
|
}
|
|
|
|
/* ── run strip ──────────────────────────────────────────────────────────── */
|
|
.sb-runbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 8px 18px;
|
|
padding: 10px 2px 16px;
|
|
border-bottom: 1px solid var(--line-soft);
|
|
margin-bottom: 18px;
|
|
font-size: 12.5px;
|
|
color: var(--muted);
|
|
}
|
|
.sb-runbar b {
|
|
font-family: var(--font-sora);
|
|
font-size: 17px;
|
|
color: var(--ink);
|
|
margin-right: 4px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.sb-runbar b.sb-red {
|
|
color: var(--accent-1);
|
|
}
|
|
.sb-runbar b.sb-amber {
|
|
color: var(--verify);
|
|
}
|
|
.sb-keys {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 14px;
|
|
margin-left: auto;
|
|
align-items: center;
|
|
font-size: 11.5px;
|
|
}
|
|
.sb-swatch {
|
|
width: 16px;
|
|
height: 9px;
|
|
border-radius: 3px;
|
|
display: inline-block;
|
|
margin-right: 5px;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
/* ── stat tiles ─────────────────────────────────────────────────────────── */
|
|
.sb-tiles {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
|
gap: 12px;
|
|
margin-bottom: 18px;
|
|
}
|
|
.sb-tile {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-card, 14px);
|
|
padding: 16px;
|
|
}
|
|
/* The icon chip carries 18% of its own semantic colour — enough to identify
|
|
the tile at a glance, not enough to compete with the figure beside it. */
|
|
.sb-chip {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 10px;
|
|
display: grid;
|
|
place-items: center;
|
|
flex: none;
|
|
}
|
|
.sb-chip svg {
|
|
width: 18px;
|
|
height: 18px;
|
|
}
|
|
.sb-tile-num {
|
|
font-family: var(--font-sora);
|
|
font-weight: 700;
|
|
font-size: 21px;
|
|
line-height: 1.1;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--ink);
|
|
}
|
|
.sb-tile-num.sb-spend {
|
|
color: var(--accent-1);
|
|
font-size: 24px;
|
|
}
|
|
.sb-tile-cap {
|
|
font-size: 11.5px;
|
|
color: var(--muted);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* ── panels and cards ───────────────────────────────────────────────────── */
|
|
.sb-panel,
|
|
.sb-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-card, 14px);
|
|
}
|
|
.sb-panel {
|
|
padding: 18px;
|
|
}
|
|
.sb-panel h2,
|
|
.sb-panel-title {
|
|
font-family: var(--font-sora);
|
|
font-size: 14px;
|
|
margin: 0 0 14px;
|
|
font-weight: 600;
|
|
color: var(--ink);
|
|
}
|
|
.sb-card {
|
|
padding: 14px 16px;
|
|
}
|
|
.sb-card-top {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
font-size: 12.5px;
|
|
color: var(--muted);
|
|
}
|
|
.sb-batch-id {
|
|
font-family: var(--font-sora);
|
|
font-weight: 700;
|
|
color: var(--accent-1);
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.sb-stamp {
|
|
margin-left: auto;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.sb-doc-name {
|
|
font-weight: 600;
|
|
font-size: 13.5px;
|
|
color: var(--ink);
|
|
}
|
|
.sb-doc-state {
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
.sb-hint {
|
|
font-size: 11.5px;
|
|
color: var(--muted);
|
|
margin: 9px 0 0;
|
|
}
|
|
/* The quality meter: the document's own grade split, as one bar. Its stops are
|
|
set inline per document because they are data, not style. */
|
|
.sb-quality {
|
|
flex: 1;
|
|
min-width: 140px;
|
|
height: 7px;
|
|
border-radius: 999px;
|
|
background: var(--surface-2);
|
|
}
|
|
.sb-meter {
|
|
height: 7px;
|
|
border-radius: 999px;
|
|
background: var(--surface-2);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── pills ──────────────────────────────────────────────────────────────── */
|
|
.sb-pill {
|
|
font: 600 11px var(--font-plex);
|
|
padding: 3px 9px;
|
|
border-radius: 999px;
|
|
display: inline-block;
|
|
}
|
|
.sb-pill-ok {
|
|
background: color-mix(in srgb, var(--clean) 22%, transparent);
|
|
color: var(--clean);
|
|
}
|
|
.sb-pill-warn {
|
|
background: color-mix(in srgb, var(--verify) 20%, transparent);
|
|
color: var(--verify);
|
|
}
|
|
.sb-pill-bad {
|
|
background: color-mix(in srgb, var(--review) 20%, transparent);
|
|
color: var(--review);
|
|
}
|
|
.sb-pill-mute {
|
|
background: var(--surface-2);
|
|
color: var(--muted);
|
|
border: 1px solid var(--line);
|
|
}
|
|
.sb-badge-super {
|
|
display: inline-block;
|
|
padding: 5px 12px;
|
|
border-radius: 999px;
|
|
font: 600 11.5px var(--font-plex);
|
|
color: #fff;
|
|
background: var(--brand-grad);
|
|
}
|
|
|
|
/* ── buttons ────────────────────────────────────────────────────────────── */
|
|
.sb-btn {
|
|
border: none;
|
|
border-radius: 9px;
|
|
cursor: pointer;
|
|
font: 600 13px var(--font-sora);
|
|
padding: 9px 16px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
transition: filter 0.15s ease, background-color 0.15s ease, opacity 0.15s ease;
|
|
}
|
|
.sb-btn:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
.sb-btn-primary {
|
|
background: var(--brand-grad);
|
|
color: #fff;
|
|
}
|
|
.sb-btn-primary:hover:not(:disabled) {
|
|
filter: brightness(1.07);
|
|
}
|
|
.sb-btn-quiet {
|
|
background: var(--surface-2);
|
|
color: var(--ink);
|
|
border: 1px solid var(--line);
|
|
}
|
|
.sb-btn-quiet:hover:not(:disabled) {
|
|
border-color: var(--muted);
|
|
}
|
|
/* Run is deliberately between quiet and primary: it is the action the board is
|
|
for, but it costs money, so it does not get the full gradient. */
|
|
.sb-btn-run {
|
|
background: color-mix(in srgb, var(--accent-1) 30%, var(--surface-2));
|
|
color: var(--ink-2);
|
|
border: 1px solid color-mix(in srgb, var(--accent-1) 45%, transparent);
|
|
}
|
|
.sb-btn-run:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--accent-1) 42%, var(--surface-2));
|
|
}
|
|
.sb-btn-next {
|
|
background: var(--verify);
|
|
color: #221704;
|
|
}
|
|
.sb-btn-danger {
|
|
background: none;
|
|
border: none;
|
|
color: var(--review);
|
|
font: 600 12.5px var(--font-plex);
|
|
cursor: pointer;
|
|
}
|
|
.sb-btn-sm {
|
|
font-size: 12px;
|
|
padding: 7px 12px;
|
|
}
|
|
.sb-link {
|
|
background: none;
|
|
border: none;
|
|
color: var(--ink-2);
|
|
font: 500 12.5px var(--font-plex);
|
|
cursor: pointer;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.sb-link:hover {
|
|
color: var(--accent-1);
|
|
}
|
|
.sb-icon-btn {
|
|
width: 26px;
|
|
height: 26px;
|
|
border-radius: 7px;
|
|
border: 1px solid var(--line);
|
|
background: var(--surface);
|
|
color: var(--ink-2);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
display: inline-grid;
|
|
place-items: center;
|
|
}
|
|
.sb-icon-btn:hover:not(:disabled) {
|
|
border-color: var(--muted);
|
|
}
|
|
.sb-icon-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* ── form controls ──────────────────────────────────────────────────────── */
|
|
.sb-input,
|
|
.sb-select {
|
|
background: var(--surface-2);
|
|
color: var(--ink);
|
|
border: 1px solid var(--line);
|
|
border-radius: 9px;
|
|
padding: 9px 12px;
|
|
font: 400 13px var(--font-plex);
|
|
}
|
|
.sb-select {
|
|
border-radius: 8px;
|
|
padding: 7px 10px;
|
|
font-weight: 500;
|
|
font-size: 12.5px;
|
|
}
|
|
.sb-input::placeholder {
|
|
color: var(--muted);
|
|
}
|
|
.sb-input:focus,
|
|
.sb-select:focus {
|
|
outline: none;
|
|
border-color: var(--accent-1);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-1) 20%, transparent);
|
|
}
|
|
.sb-inline-label {
|
|
font-size: 12.5px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
/* ── dropzone ───────────────────────────────────────────────────────────── */
|
|
.sb-drop {
|
|
border: 1.5px dashed var(--line);
|
|
border-radius: var(--radius-card, 14px);
|
|
background: var(--surface);
|
|
padding: 26px 20px;
|
|
text-align: center;
|
|
}
|
|
.sb-drop h2,
|
|
.sb-drop-title {
|
|
font-family: var(--font-sora);
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
margin: 0 0 6px;
|
|
color: var(--ink);
|
|
}
|
|
.sb-drop p {
|
|
margin: 0 auto 14px;
|
|
color: var(--muted);
|
|
font-size: 12.5px;
|
|
max-width: 52ch;
|
|
line-height: 1.5;
|
|
}
|
|
.sb-drop-active {
|
|
border-color: var(--accent-1);
|
|
background: color-mix(in srgb, var(--accent-1) 6%, var(--surface));
|
|
}
|
|
|
|
/* ── file row ───────────────────────────────────────────────────────────── */
|
|
.sb-file-ico {
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 8px;
|
|
background: var(--surface-2);
|
|
border: 1px solid var(--line);
|
|
display: grid;
|
|
place-items: center;
|
|
flex: none;
|
|
}
|
|
.sb-file-ico svg {
|
|
width: 15px;
|
|
height: 15px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
/* ── empty state ────────────────────────────────────────────────────────── */
|
|
.sb-empty {
|
|
border: 1px solid var(--line-soft);
|
|
border-radius: var(--radius-card, 14px);
|
|
padding: 64px 20px;
|
|
text-align: center;
|
|
color: var(--muted);
|
|
font-size: 13px;
|
|
background: var(--surface);
|
|
}
|
|
|
|
/* ── tables ─────────────────────────────────────────────────────────────── */
|
|
.sb-table-wrap {
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius-card, 14px);
|
|
overflow-x: auto;
|
|
}
|
|
.sb-table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
min-width: 620px;
|
|
}
|
|
.sb-table th {
|
|
text-align: left;
|
|
font: 600 10.5px var(--font-plex);
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--muted);
|
|
padding: 13px 16px;
|
|
border-bottom: 1px solid var(--line);
|
|
white-space: nowrap;
|
|
}
|
|
.sb-table td {
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--line-soft);
|
|
font-size: 13px;
|
|
vertical-align: middle;
|
|
color: var(--ink);
|
|
}
|
|
.sb-table tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
.sb-table td b {
|
|
display: block;
|
|
font-size: 13.5px;
|
|
}
|
|
.sb-table .sb-code {
|
|
font-size: 11.5px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
/* ── donut and legend ───────────────────────────────────────────────────── */
|
|
.sb-legend {
|
|
display: grid;
|
|
gap: 9px;
|
|
min-width: 180px;
|
|
flex: 1;
|
|
}
|
|
.sb-legend > div {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12.5px;
|
|
color: var(--ink);
|
|
}
|
|
.sb-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex: none;
|
|
}
|
|
.sb-legend .sb-n {
|
|
margin-left: auto;
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 600;
|
|
}
|
|
.sb-legend .sb-p {
|
|
color: var(--muted);
|
|
width: 34px;
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* ── review drill-down ──────────────────────────────────────────────────── */
|
|
.sb-rv-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 14px;
|
|
padding: 10px 16px;
|
|
background: var(--brand-bar);
|
|
color: #fff;
|
|
}
|
|
.sb-rv-bar .sb-appbar-mark,
|
|
.sb-rv-bar .sb-appbar-mark svg {
|
|
width: 26px;
|
|
height: 26px;
|
|
}
|
|
.sb-rv-back {
|
|
background: rgba(255, 255, 255, 0.18);
|
|
border: 1px solid rgba(255, 255, 255, 0.32);
|
|
color: #fff;
|
|
border-radius: 8px;
|
|
padding: 6px 12px;
|
|
font: 600 12.5px var(--font-plex);
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
}
|
|
.sb-rv-back:hover {
|
|
background: rgba(255, 255, 255, 0.3);
|
|
}
|
|
.sb-rv-file b {
|
|
font-family: var(--font-sora);
|
|
font-size: 14px;
|
|
display: block;
|
|
line-height: 1.2;
|
|
}
|
|
.sb-rv-file span {
|
|
font-size: 11px;
|
|
opacity: 0.85;
|
|
}
|
|
.sb-rv-keys {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 9px 16px;
|
|
border-bottom: 1px solid var(--line-soft);
|
|
font-size: 11.5px;
|
|
color: var(--muted);
|
|
background: var(--rail);
|
|
}
|
|
.sb-rv-status {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid var(--line-soft);
|
|
background: var(--rail);
|
|
}
|
|
.sb-chip-count {
|
|
font: 600 12px var(--font-plex);
|
|
padding: 5px 11px;
|
|
border-radius: 8px;
|
|
background: var(--surface-2);
|
|
border: 1px solid var(--line);
|
|
color: var(--ink-2);
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
.sb-chip-count.sb-clean {
|
|
color: var(--clean);
|
|
border-color: color-mix(in srgb, var(--clean) 45%, transparent);
|
|
}
|
|
.sb-chip-count.sb-sample {
|
|
color: var(--verify);
|
|
border-color: color-mix(in srgb, var(--verify) 45%, transparent);
|
|
}
|
|
.sb-chip-count.sb-review {
|
|
color: var(--review);
|
|
border-color: color-mix(in srgb, var(--review) 45%, transparent);
|
|
}
|
|
.sb-chip-done {
|
|
background: color-mix(in srgb, var(--clean) 20%, transparent);
|
|
color: var(--clean);
|
|
border-color: transparent;
|
|
}
|
|
.sb-rv-progress {
|
|
width: 190px;
|
|
height: 7px;
|
|
border-radius: 999px;
|
|
background: var(--surface-2);
|
|
overflow: hidden;
|
|
}
|
|
/* The fill is sized by `width` from the verified/total ratio. The mockup eased
|
|
that width over 250ms; this does not, deliberately — animating `width`
|
|
dirties layout on every frame, and the ease bought nothing on a bar that
|
|
steps forward once per field a reviewer clears. The value change is still
|
|
perfectly legible without it. */
|
|
.sb-rv-progress i {
|
|
display: block;
|
|
height: 100%;
|
|
background: var(--brand-grad);
|
|
}
|
|
.sb-rv-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--line);
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
/* The field row: label / input / confidence / OK-Undo. The grade is a 3px
|
|
border down the input's left edge rather than a fill, so the value stays on
|
|
the same surface as every other value and only its margin is coloured. */
|
|
.sb-field-row {
|
|
display: grid;
|
|
grid-template-columns: 78px minmax(0, 1fr) 42px 70px;
|
|
align-items: center;
|
|
gap: 9px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--line);
|
|
border-radius: 11px;
|
|
padding: 9px 11px;
|
|
}
|
|
.sb-field-row.sb-done {
|
|
border-color: color-mix(in srgb, var(--clean) 40%, transparent);
|
|
}
|
|
.sb-field-row .sb-lbl {
|
|
font: 600 12px var(--font-plex);
|
|
color: var(--muted);
|
|
}
|
|
.sb-field-row.sb-done .sb-lbl {
|
|
color: var(--ink-2);
|
|
}
|
|
.sb-field-input {
|
|
width: 100%;
|
|
background: var(--surface-2);
|
|
border: 1px solid var(--line);
|
|
border-radius: 8px;
|
|
padding: 7px 9px;
|
|
color: var(--ink);
|
|
font: 500 12.5px var(--font-plex);
|
|
min-width: 0;
|
|
}
|
|
.sb-field-input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-1);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-1) 20%, transparent);
|
|
}
|
|
.sb-field-input.sb-grade-clean {
|
|
border-left: 3px solid var(--clean);
|
|
}
|
|
.sb-field-input.sb-grade-sample {
|
|
border-left: 3px solid var(--verify);
|
|
}
|
|
.sb-field-input.sb-grade-missing {
|
|
border-left: 3px solid var(--review);
|
|
}
|
|
.sb-field-input.sb-grade-absent {
|
|
border-left: 3px solid var(--absent);
|
|
}
|
|
.sb-conf {
|
|
font-size: 11.5px;
|
|
color: var(--muted);
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* ── focus ──────────────────────────────────────────────────────────────── */
|
|
/* `:focus-visible`, never `:focus` — a mouse click should never paint a ring,
|
|
and keyboard navigation should always get one. No border-radius here on
|
|
purpose: `outline` already follows each element's own radius. */
|
|
:focus-visible {
|
|
outline: 2px solid var(--accent-1);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
/* ── Motion ─────────────────────────────────────────────────────────────────
|
|
Every animation below is transform/opacity only — never width, height, top
|
|
or left — so each one runs on the compositor and cannot re-trigger layout on
|
|
a pane that is already re-rendering a 1275x1650 page image beside it.
|
|
|
|
None of them is the ONLY way a state change is communicated, which is what
|
|
makes the prefers-reduced-motion collapse at the bottom of this file safe:
|
|
removing the motion removes polish, never information. */
|
|
|
|
/* Rows arriving when a patient opens. Staggered by index in ReviewPane so a
|
|
record resolves top-to-bottom instead of snapping in as one block. */
|
|
@keyframes sb-rise {
|
|
from { opacity: 0; transform: translateY(6px); }
|
|
to { opacity: 1; transform: none; }
|
|
}
|
|
.sb-rise {
|
|
animation: sb-rise 280ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
|
}
|
|
|
|
/* The unsaved-changes bar sliding up from the bottom edge of the pane. */
|
|
@keyframes sb-slide-up {
|
|
from { opacity: 0; transform: translateY(100%); }
|
|
to { opacity: 1; transform: none; }
|
|
}
|
|
.sb-slide-up {
|
|
animation: sb-slide-up 220ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
|
|
}
|
|
|
|
/* A save landing. Deliberately a scale pulse rather than a colour flash: the
|
|
band colour underneath is load-bearing and must not be repainted, even for
|
|
200ms, to signal something unrelated to legibility. */
|
|
@keyframes sb-pulse {
|
|
0% { transform: scale(1); }
|
|
40% { transform: scale(1.015); }
|
|
100% { transform: scale(1); }
|
|
}
|
|
.sb-pulse {
|
|
animation: sb-pulse 320ms ease-out both;
|
|
}
|
|
|
|
/* Indeterminate sweep for work in flight (extraction, saving). */
|
|
@keyframes sb-sweep {
|
|
from { transform: translateX(-100%); }
|
|
to { transform: translateX(300%); }
|
|
}
|
|
.sb-sweep::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 33%;
|
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.55), transparent);
|
|
animation: sb-sweep 1.4s linear infinite;
|
|
}
|
|
|
|
/* ── Extraction fire ────────────────────────────────────────────────────────
|
|
A document being read is the one place in this app where something is
|
|
genuinely burning through work, and the brand palette already IS a fire —
|
|
deep red through orange into gold. So `running` renders as embers over a
|
|
breathing heat band instead of one more grey spinner, using colours the
|
|
shell already owns rather than introducing a hue for the occasion.
|
|
|
|
Animated properties are opacity and transform only, so each layer
|
|
composites and none of it re-triggers layout on a board that is polling
|
|
every two seconds. The blur is static — one paint, not one per frame.
|
|
|
|
None of it carries information. "page 4 of 9" sits beside the fire and is
|
|
the actual statement of progress, which is what keeps the reduced-motion
|
|
collapse below honest: removing the motion removes the drama, never the
|
|
fact that a document is running. */
|
|
|
|
/* The extraction beam has NO pulse. There was a `sb-beam-breathe` here cycling
|
|
the bloom's opacity every 3.4s; even shallow, a glow that throbs reclaims
|
|
attention on its own schedule, and this element sits beside a filename someone
|
|
is trying to read. The light is steady and the only motion is the sweep below.
|
|
Deleted rather than commented out — a disabled animation left in the sheet is
|
|
the one that gets re-enabled by accident. */
|
|
|
|
/* The hotspot scanning the beam. Travels a full row width plus its own width at
|
|
each end, so it enters and leaves through the tapered edges instead of
|
|
appearing and vanishing mid-beam. `alternate` rather than a wrap-around: a
|
|
scan that returns is a reader working back over the page, and it removes the
|
|
jump a looping translate would show at the seam. */
|
|
@keyframes sb-beam-sweep {
|
|
from { transform: translate3d(-100%, -50%, 0); }
|
|
to { transform: translate3d(400%, -50%, 0); }
|
|
}
|
|
.sb-beam-sweep {
|
|
animation-name: sb-beam-sweep;
|
|
animation-duration: 2600ms;
|
|
animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1);
|
|
animation-iteration-count: infinite;
|
|
animation-direction: alternate;
|
|
}
|
|
|
|
/* One ember. Drift, duration and delay come in per-instance so a bed of these
|
|
reads as scattered sparks rather than one pulse repeated in a row. */
|
|
@keyframes sb-ember {
|
|
0% { opacity: 0; transform: translate3d(0, 3px, 0) scale(0.35); }
|
|
18% { opacity: 0.95; }
|
|
100% { opacity: 0; transform: translate3d(var(--sb-drift, 0px), -20px, 0) scale(1.45); }
|
|
}
|
|
.sb-ember {
|
|
animation-name: sb-ember;
|
|
animation-duration: var(--sb-dur, 1800ms);
|
|
animation-delay: var(--sb-delay, 0ms);
|
|
animation-timing-function: ease-out;
|
|
animation-iteration-count: infinite;
|
|
/* A spark has no edge. Without this they read as tidy dots. */
|
|
filter: blur(0.5px);
|
|
}
|
|
|
|
/* The heat under the embers. Scales from its bottom edge so it grows upward
|
|
out of the surface it sits on, the way a flame does. */
|
|
@keyframes sb-heat {
|
|
0%, 100% { opacity: 0.45; transform: scaleY(1); }
|
|
50% { opacity: 0.95; transform: scaleY(1.6); }
|
|
}
|
|
.sb-heat {
|
|
animation-name: sb-heat;
|
|
animation-duration: 1400ms;
|
|
animation-timing-function: ease-in-out;
|
|
animation-iteration-count: infinite;
|
|
transform-origin: bottom;
|
|
filter: blur(3px);
|
|
}
|
|
|
|
/* The burning head of a progress bar — brightest right where it is eating in. */
|
|
@keyframes sb-flicker {
|
|
0%, 100% { opacity: 0.7; }
|
|
35% { opacity: 1; }
|
|
62% { opacity: 0.82; }
|
|
}
|
|
.sb-flicker {
|
|
animation-name: sb-flicker;
|
|
animation-duration: 900ms;
|
|
animation-timing-function: ease-in-out;
|
|
animation-iteration-count: infinite;
|
|
}
|
|
|
|
/* ── Browser surfaces ───────────────────────────────────────────────────────
|
|
The parts nobody draws still carry the design. A default blue selection
|
|
highlight and a default grey scrollbar belong to no design system, and inside
|
|
a red-branded app they are the tell that a page was assembled rather than
|
|
built.
|
|
|
|
Selection is slate, not brand red, deliberately. Brand colour is quarantined
|
|
to the shell (DESIGN.md, "The One Loud Color Rule") and selecting a value
|
|
inside a red confidence chip would drag brand red straight into the one
|
|
surface that must never see it. Slate is already this app's neutral, so the
|
|
highlight is themed without breaking the quarantine.
|
|
|
|
The caret inherits `currentColor` for the same reason: inside a band chip it
|
|
picks up that band's own text colour instead of stamping an accent on it. */
|
|
::selection {
|
|
background: rgba(100, 116, 139, 0.30);
|
|
}
|
|
.dark ::selection {
|
|
background: rgba(148, 163, 184, 0.34);
|
|
}
|
|
|
|
input,
|
|
textarea,
|
|
[contenteditable] {
|
|
caret-color: currentColor;
|
|
}
|
|
|
|
:root {
|
|
--sb-scroll-thumb: #cbd5e1;
|
|
--sb-scroll-thumb-hover: #94a3b8;
|
|
}
|
|
.dark {
|
|
--sb-scroll-thumb: #334155;
|
|
--sb-scroll-thumb-hover: #475569;
|
|
}
|
|
|
|
* {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--sb-scroll-thumb) transparent;
|
|
}
|
|
::-webkit-scrollbar {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--sb-scroll-thumb);
|
|
border-radius: 9999px;
|
|
/* Inset via a transparent border so the thumb reads as a floating pill rather
|
|
than a bar welded to the edge of the pane. */
|
|
border: 2px solid transparent;
|
|
background-clip: content-box;
|
|
}
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: var(--sb-scroll-thumb-hover);
|
|
background-clip: content-box;
|
|
}
|
|
::-webkit-scrollbar-corner {
|
|
background: transparent;
|
|
}
|
|
|
|
/* ── the sign-in screen ────────────────────────────────────────────────────
|
|
Every value here is carried over from the approved `login-animated.html`
|
|
mockup rather than re-derived, so the two render identically: card 400px at
|
|
36/32/32 padding, 20px radius, 14.5px fields, 12.5px uppercase labels.
|
|
|
|
Two things the earlier pass had that the mockup does not, and which are
|
|
deliberately gone: the three blurred radials and the raked brand rule. The
|
|
rule crossed the viewport straight through the middle of the card, which is
|
|
what the diagonal cutting the screen in half was.
|
|
|
|
This screen is no longer committed to a dark ground. It follows the app's
|
|
appearance setting — `.dark` from theme.tsx paints it dark, and light and
|
|
`eye` both take the light tokens below (`eye` is the light surface plus a
|
|
warm filter, applied globally, so it needs nothing of its own here). */
|
|
.sb-login {
|
|
--lg-bg: #f4f3f7;
|
|
--lg-ink: #18151f;
|
|
--lg-muted: #5f5b6c;
|
|
--lg-card-bg: #ffffff;
|
|
--lg-card-border: #e2dfe9;
|
|
--lg-field-bg: #f7f6fa;
|
|
--lg-field-border: #dcd8e6;
|
|
--lg-shadow: 0 20px 60px -20px rgba(30, 20, 60, 0.25);
|
|
|
|
/* The mockup's --accent-1/--accent-2 are already the brand's own two hues,
|
|
so these alias the product tokens instead of restating the hex. */
|
|
--lg-accent-1: var(--brand-red-hot);
|
|
--lg-accent-2: var(--brand-orange);
|
|
|
|
background: var(--lg-bg);
|
|
color: var(--lg-ink);
|
|
font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
|
}
|
|
|
|
.dark .sb-login {
|
|
--lg-bg: #0a0912;
|
|
--lg-ink: #f3f1f8;
|
|
--lg-muted: #9791a8;
|
|
--lg-card-bg: #151220;
|
|
--lg-card-border: #2a2438;
|
|
--lg-field-bg: #1b1727;
|
|
--lg-field-border: #332c46;
|
|
--lg-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
|
|
}
|
|
|
|
/* The card's column. `pointer-events: none` so hovers fall through to the
|
|
agents behind it; the card itself puts them back on. */
|
|
.sb-login-stage {
|
|
position: relative;
|
|
z-index: 2;
|
|
pointer-events: none;
|
|
width: 100%;
|
|
max-width: 1100px;
|
|
min-height: 640px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 40px clamp(16px, 6vw, 96px);
|
|
}
|
|
|
|
.sb-login-card {
|
|
pointer-events: auto;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
background: var(--lg-card-bg);
|
|
border: 1px solid var(--lg-card-border);
|
|
border-radius: 20px;
|
|
padding: 36px 32px 32px;
|
|
box-shadow: var(--lg-shadow);
|
|
}
|
|
|
|
/* Each block fades up a beat after the one above it, so the card assembles
|
|
top-down instead of appearing all at once. `forwards` with `opacity: 0` at
|
|
rest is what holds each one hidden until its own delay comes round. */
|
|
@keyframes sb-login-rise {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
|
|
.sb-login-brand,
|
|
.sb-login-title,
|
|
.sb-login-lede,
|
|
.sb-login-form {
|
|
opacity: 0;
|
|
animation: sb-login-rise 0.7s cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
|
|
}
|
|
.sb-login-brand { animation-delay: 0.1s; }
|
|
.sb-login-title { animation-delay: 0.22s; }
|
|
.sb-login-lede { animation-delay: 0.3s; }
|
|
.sb-login-form { animation-delay: 0.38s; }
|
|
|
|
.sb-login-brand {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 28px;
|
|
}
|
|
|
|
/* The shield, lifting on a slow bob under a warm glow.
|
|
|
|
Two drop-shadows, not one: the tight warm pass is the light coming off the
|
|
shield's own bars, the wide red one is what that light throws onto the card
|
|
behind it. A single large blur only ever reads as a smudge.
|
|
|
|
The bob is a lift with a slight lean, not a bounce — the rotation is what
|
|
keeps it from reading as a loading indicator. */
|
|
.sb-mark-bob {
|
|
position: relative;
|
|
width: 38px;
|
|
height: 38px;
|
|
display: grid;
|
|
place-items: center;
|
|
flex: none;
|
|
filter: drop-shadow(0 0 9px rgba(227, 155, 52, 0.55))
|
|
drop-shadow(0 0 18px rgba(222, 82, 67, 0.28));
|
|
animation: sb-mark-bob 4.5s ease-in-out 0.9s infinite;
|
|
}
|
|
.sb-mark-bob > svg {
|
|
display: block;
|
|
width: 38px;
|
|
height: 38px;
|
|
}
|
|
@keyframes sb-mark-bob {
|
|
0%, 100% { transform: translateY(0) rotate(0deg); }
|
|
50% { transform: translateY(-3px) rotate(-4deg); }
|
|
}
|
|
|
|
/* The product name with a sheen travelling through it.
|
|
|
|
The gradient is 260% of the text's own width and its POSITION is what
|
|
animates, so a bright band sweeps across the glyphs while the ink either
|
|
side of it stays the card's own ink colour. Animating `background-position`
|
|
on a clipped background is what does this without a second overlay element
|
|
and a mask to keep aligned with it.
|
|
|
|
Stops 45% and 100% are the same position on purpose: the sheen passes once
|
|
and the name then sits still for the rest of the cycle. Ending on a
|
|
different stop would make it a shimmer that never settles, which is
|
|
movement in the corner of the eye while somebody is typing. */
|
|
.sb-brand-name {
|
|
font-family: 'Sora', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
letter-spacing: 0.01em;
|
|
background-image: linear-gradient(
|
|
100deg,
|
|
var(--lg-ink) 30%,
|
|
var(--lg-accent-1) 46%,
|
|
var(--lg-accent-2) 52%,
|
|
var(--lg-ink) 68%
|
|
);
|
|
background-size: 260% 100%;
|
|
background-position: 100% 0;
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
color: transparent;
|
|
animation: sb-brand-sheen 5.5s ease-in-out 1.4s infinite;
|
|
}
|
|
@keyframes sb-brand-sheen {
|
|
0% { background-position: 130% 0; }
|
|
45% { background-position: -10% 0; }
|
|
100% { background-position: -10% 0; }
|
|
}
|
|
|
|
.sb-login-title {
|
|
font-family: 'Sora', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
|
font-size: clamp(22px, 3.2vw, 26px);
|
|
font-weight: 700;
|
|
line-height: 1.2;
|
|
letter-spacing: -0.01em;
|
|
margin: 0 0 8px;
|
|
text-wrap: balance;
|
|
}
|
|
.sb-login-title .sb-login-accent {
|
|
background-image: linear-gradient(100deg, var(--lg-accent-1), var(--lg-accent-2));
|
|
-webkit-background-clip: text;
|
|
background-clip: text;
|
|
color: transparent;
|
|
}
|
|
|
|
.sb-login-lede {
|
|
margin: 0 0 26px;
|
|
color: var(--lg-muted);
|
|
font-size: 14.5px;
|
|
line-height: 1.55;
|
|
max-width: 34ch;
|
|
}
|
|
|
|
.sb-login-field {
|
|
margin-bottom: 16px;
|
|
}
|
|
.sb-login-field label {
|
|
display: block;
|
|
font-size: 12.5px;
|
|
font-weight: 600;
|
|
color: var(--lg-muted);
|
|
margin: 0 0 6px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
.sb-login-field input {
|
|
width: 100%;
|
|
padding: 11px 13px;
|
|
border-radius: 10px;
|
|
border: 1px solid var(--lg-field-border);
|
|
background: var(--lg-field-bg);
|
|
color: var(--lg-ink);
|
|
font-family: inherit;
|
|
font-size: 14.5px;
|
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
}
|
|
.sb-login-field input:focus {
|
|
outline: none;
|
|
border-color: var(--lg-accent-1);
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lg-accent-1) 22%, transparent);
|
|
}
|
|
.sb-login-field input::placeholder {
|
|
color: var(--lg-muted);
|
|
opacity: 0.7;
|
|
}
|
|
|
|
/* The refusal notice. Not in the mockup — it has no auth to fail — so it is
|
|
built from the same tokens rather than invented: accent-1 at low alpha, on
|
|
the card's own field background. */
|
|
.sb-login-error {
|
|
margin: 0 0 16px;
|
|
border-radius: 10px;
|
|
border: 1px solid color-mix(in srgb, var(--lg-accent-1) 35%, transparent);
|
|
background: color-mix(in srgb, var(--lg-accent-1) 10%, transparent);
|
|
color: color-mix(in srgb, var(--lg-accent-1) 75%, var(--lg-ink));
|
|
padding: 10px 13px;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.sb-login-submit {
|
|
width: 100%;
|
|
margin-top: 6px;
|
|
padding: 12px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
background: linear-gradient(120deg, var(--lg-accent-1), var(--lg-accent-2));
|
|
color: #ffffff;
|
|
font-family: 'Sora', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Arial, sans-serif;
|
|
font-weight: 700;
|
|
font-size: 14.5px;
|
|
cursor: pointer;
|
|
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
|
|
box-shadow: 0 10px 24px -8px color-mix(in srgb, var(--lg-accent-1) 60%, transparent);
|
|
}
|
|
.sb-login-submit:hover:not(:disabled) {
|
|
transform: translateY(-1px);
|
|
}
|
|
.sb-login-submit:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.4;
|
|
box-shadow: none;
|
|
}
|
|
|
|
/* ── the crew ──────────────────────────────────────────────────────────────
|
|
Five line-art agents crossing the background behind the card, at five sizes
|
|
and five speeds.
|
|
|
|
`fixed` so the walk is measured against the viewport rather than the card's
|
|
own column, and `overflow: hidden` so a figure part-way past either edge is
|
|
clipped instead of making the page scrollable.
|
|
|
|
`pointer-events: none` on the layer with `auto` on each agent, and that
|
|
split is the whole reason hover works here. The layer spans the screen; left
|
|
hit-testable it would sit between the pointer and everything under it. Each
|
|
agent is only as big as itself, so it can take a hover without shadowing the
|
|
card. The page wrapper is `pointer-events-none` for the same reason — it is
|
|
a full-screen flex container and would otherwise swallow every hover before
|
|
it reached this layer. */
|
|
.sb-agents {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 1;
|
|
overflow: hidden;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.sb-agent {
|
|
position: absolute;
|
|
left: 0;
|
|
color: var(--brand-red-hot);
|
|
/* Low enough to stay behind the reading, high enough to be seen as a figure
|
|
rather than a stain. Hover lifts it to near-full — see below. */
|
|
opacity: 0.42;
|
|
pointer-events: auto;
|
|
cursor: default;
|
|
/* Five simultaneous drifts; promoting the transform keeps them off the main
|
|
thread while somebody is typing into the form in front of them. */
|
|
will-change: transform;
|
|
transition: opacity 0.35s ease;
|
|
}
|
|
|
|
/* The light-line each one trails behind it. Explicitly not hit-testable: it is
|
|
90px wide and would otherwise triple the bot's hover target, so the figure
|
|
would stop while the pointer was nowhere near it. */
|
|
.sb-agent::before {
|
|
content: '';
|
|
position: absolute;
|
|
right: 100%;
|
|
top: 50%;
|
|
width: 90px;
|
|
height: 1px;
|
|
margin-right: 6px;
|
|
background: linear-gradient(90deg, transparent, currentColor);
|
|
opacity: 0.55;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* `content-box` padding, so the 10px grows the hit area without shrinking the
|
|
figure inside the width each `.sb-agent--n` sets. */
|
|
.sb-ai-bot {
|
|
display: block;
|
|
padding: 10px;
|
|
box-sizing: content-box;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Line art is mostly holes, and SVG only hit-tests pixels it painted — with no
|
|
filled shape behind it the pointer falls between the strokes and the hover
|
|
strobes on and off as the cursor crosses a limb. A transparent fill still
|
|
hit-tests; `fill: none` does not, which is the distinction this rule exists
|
|
for. */
|
|
.sb-ai-hit {
|
|
fill: transparent;
|
|
stroke: none;
|
|
}
|
|
|
|
/* The dance is on the inner group, the drift on the outer span. Two elements
|
|
on purpose: one `transform` property cannot hold both animations, and
|
|
nesting is what composes them instead of letting one overwrite the other. */
|
|
.sb-ai-core {
|
|
transform-origin: 12px 27px;
|
|
animation: sb-bot-dance 2.8s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes sb-bot-dance {
|
|
0%, 100% { transform: translateY(0) rotate(-7deg); }
|
|
25% { transform: translateY(-5px) rotate(0deg); }
|
|
50% { transform: translateY(0) rotate(7deg); }
|
|
75% { transform: translateY(-5px) rotate(0deg); }
|
|
}
|
|
|
|
/* Off the left edge, across, and off the right. The 160px overshoot at each
|
|
end is wider than the largest figure, so none of them pops into existence
|
|
at the edge of the screen. */
|
|
@keyframes sb-bot-drift {
|
|
from { transform: translateX(-160px); }
|
|
to { transform: translateX(calc(100vw + 160px)); }
|
|
}
|
|
|
|
/* Five sizes, five durations, five negative delays.
|
|
|
|
No two durations match: equal speeds would send pairs across in lockstep and
|
|
give away that this is five copies of one drawing. The negative delays start
|
|
each one part-way through its own crossing, so the screen is already
|
|
populated on the first frame rather than filling up over the first minute. */
|
|
.sb-agent--1 { top: 12%; animation: sb-bot-drift 48s linear infinite; }
|
|
.sb-agent--1 .sb-ai-bot { width: 64px; height: 80px; }
|
|
|
|
.sb-agent--2 { top: 40%; animation: sb-bot-drift 62s linear -18s infinite; }
|
|
.sb-agent--2 .sb-ai-bot { width: 34px; height: 42px; }
|
|
.sb-agent--2 .sb-ai-core { animation-duration: 3.6s; animation-delay: -0.6s; }
|
|
|
|
.sb-agent--3 { top: 70%; animation: sb-bot-drift 54s linear -34s infinite; }
|
|
.sb-agent--3 .sb-ai-bot { width: 52px; height: 65px; }
|
|
.sb-agent--3 .sb-ai-core { animation-duration: 2.3s; animation-delay: -1.2s; }
|
|
|
|
.sb-agent--4 { top: 26%; animation: sb-bot-drift 70s linear -50s infinite; }
|
|
.sb-agent--4 .sb-ai-bot { width: 28px; height: 35px; }
|
|
.sb-agent--4 .sb-ai-core { animation-duration: 4.1s; animation-delay: -2s; }
|
|
|
|
.sb-agent--5 { top: 58%; animation: sb-bot-drift 44s linear -8s infinite; }
|
|
.sb-agent--5 .sb-ai-bot { width: 76px; height: 95px; }
|
|
.sb-agent--5 .sb-ai-core { animation-duration: 3.1s; animation-delay: -1.7s; }
|
|
|
|
/* Hover: that one agent halts where it stands and grins. The other four keep
|
|
walking, which is what the per-agent drift animations above are for.
|
|
|
|
These rules MUST stay below the `.sb-agent--n` blocks, and they carry
|
|
`!important`, and both of those are for the same reason: those blocks set
|
|
the `animation` SHORTHAND, which expands to include
|
|
`animation-play-state: running`. A pause declared earlier loses on source
|
|
order; a pause at equal specificity loses too. Either way the bot keeps
|
|
drifting out from under a cursor that was meant to have stopped it. */
|
|
.sb-agents .sb-agent:hover,
|
|
.sb-agents .sb-agent:hover .sb-ai-core {
|
|
animation-play-state: paused !important;
|
|
}
|
|
.sb-agents .sb-agent:hover {
|
|
opacity: 0.95;
|
|
}
|
|
|
|
/* Only one mouth shows at a time: the gentle arc while walking, the wide one
|
|
while the pointer is holding it still. Crossfaded with a transition rather
|
|
than an animation, deliberately — hovering pauses animations, and a paused
|
|
crossfade would freeze the face on whichever expression it happened to be
|
|
interpolating. `animation-play-state` does not touch transitions, so the
|
|
grin lands and stays for as long as the pointer is there. */
|
|
.sb-ai-mouth-idle,
|
|
.sb-ai-mouth-laugh {
|
|
transition: opacity 260ms ease-out;
|
|
}
|
|
.sb-ai-mouth-laugh {
|
|
opacity: 0;
|
|
}
|
|
.sb-agents .sb-agent:hover .sb-ai-mouth-idle {
|
|
opacity: 0;
|
|
}
|
|
.sb-agents .sb-agent:hover .sb-ai-mouth-laugh {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* The antenna bulb's click response — separate from the hover dip/smile,
|
|
which are ambient and never triggered by the visitor directly. This is the
|
|
one thing on the row that answers a click.
|
|
`transform-box: fill-box` is required for an SVG shape: without it a CSS
|
|
`scale` transform pivots on the SVG viewport's origin (0,0), not the
|
|
circle's own centre, and the bulb would swing out on an arc instead of
|
|
growing in place. */
|
|
.sb-ai-bulb {
|
|
transform-box: fill-box;
|
|
transform-origin: center;
|
|
}
|
|
.sb-ai-bulb--pulsing {
|
|
animation: sb-bulb-flash 650ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
@keyframes sb-bulb-flash {
|
|
0%, 100% { transform: scale(1); }
|
|
45% { transform: scale(2.2); }
|
|
}
|
|
|
|
/* The halo: a second, larger circle at the same centre, invisible at rest.
|
|
Expanding radius AND fading opacity together is what reads as light
|
|
blooming outward rather than a shape simply growing — a scale-only halo
|
|
would look like a second solid dot. */
|
|
.sb-ai-bulb-halo {
|
|
transform-box: fill-box;
|
|
transform-origin: center;
|
|
filter: blur(1.5px);
|
|
}
|
|
.sb-ai-bulb-halo--pulsing {
|
|
animation: sb-bulb-halo 650ms ease-out;
|
|
}
|
|
@keyframes sb-bulb-halo {
|
|
0% { transform: scale(1); opacity: 0.9; }
|
|
100% { transform: scale(5); opacity: 0; }
|
|
}
|
|
|
|
/* A near-zero duration cut is safe here specifically because nothing in this
|
|
app uses motion to CARRY information — every transition here (hover color,
|
|
progress-bar fill, a focus ring) has both its start and end state fully
|
|
legible on their own; the animation is polish, not the only way a state
|
|
change is communicated. That is the case where collapsing the duration
|
|
preserves every state change and loses only the easing, rather than the
|
|
riskier version of this pattern that hides a spinner or skips a toast's
|
|
only appearance. */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*, *::before, *::after {
|
|
transition-duration: 0.01ms !important;
|
|
animation-duration: 0.01ms !important;
|
|
scroll-behavior: auto !important;
|
|
}
|
|
|
|
/* The blanket collapse above is wrong for anything `infinite`: a 0.01ms
|
|
duration doesn't finish and settle, it restarts forever, so an ambient
|
|
loop reads as a rapid strobe instead of holding still — worse for a
|
|
motion-sensitive visitor than the motion it was meant to remove. The
|
|
sign-in screen's drifting agents, their dance, the brand mark's bob and
|
|
the name's sheen are all `infinite`, so they need to actually stop. */
|
|
.sb-agent,
|
|
.sb-agent .sb-ai-core,
|
|
.sb-mark-bob,
|
|
.sb-brand-name {
|
|
animation: none !important;
|
|
}
|
|
|
|
/* Stopping the drift leaves every agent stacked at the left edge, since 0%
|
|
of `sb-bot-drift` is where they all start. Parked across the width
|
|
instead, so the still frame is the composition the motion was making. */
|
|
.sb-agent--1 { transform: translateX(14vw); }
|
|
.sb-agent--2 { transform: translateX(58vw); }
|
|
.sb-agent--3 { transform: translateX(80vw); }
|
|
.sb-agent--4 { transform: translateX(34vw); }
|
|
.sb-agent--5 { transform: translateX(66vw); }
|
|
|
|
/* And the sheen settles on its resting stop rather than the off-screen one
|
|
it starts from, which would leave the name in flat white. */
|
|
.sb-brand-name { background-position: 0 0; }
|
|
}
|
|
|
|
/* ── Office Location (a Gulf Coast ticket's yellow note) ──────────────────────
|
|
The note is amber because it IS amber on the page; everything else stays on
|
|
the neutral surfaces. No brand ramp here -- it is reserved for its six
|
|
things (DESIGN.md, "The One Loud Colour Rule"). */
|
|
.sb-ol-sect {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 8px;
|
|
font: 600 11px var(--font-plex);
|
|
color: var(--muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
.sb-ol-sect::after {
|
|
content: '';
|
|
flex: 1;
|
|
height: 1px;
|
|
background: var(--line-soft);
|
|
}
|
|
.sb-ol-tag {
|
|
font: 600 10.5px var(--font-plex);
|
|
color: var(--muted);
|
|
border: 1px solid var(--line);
|
|
border-radius: 999px;
|
|
padding: 1px 7px;
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
}
|
|
.sb-ol-tabs {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
.sb-ol-tab {
|
|
border: 1px solid var(--line);
|
|
background: var(--surface-2);
|
|
border-radius: 9px;
|
|
padding: 6px 11px;
|
|
font: 600 12px var(--font-plex);
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
}
|
|
.sb-ol-tab[aria-selected='true'] {
|
|
color: var(--ink);
|
|
background: var(--surface);
|
|
border-color: var(--ink-2);
|
|
}
|
|
.sb-ol-tab small {
|
|
margin-left: 6px;
|
|
font-weight: 500;
|
|
color: var(--muted);
|
|
}
|
|
.sb-ol-info {
|
|
font: 500 11.5px var(--font-plex);
|
|
color: var(--muted);
|
|
}
|
|
.sb-ol-line {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 7px;
|
|
border: 1px solid var(--line);
|
|
border-radius: 11px;
|
|
padding: 8px;
|
|
background: var(--surface-2);
|
|
}
|
|
/* A separate encounter billed under its own provider. */
|
|
.sb-ol-line-own {
|
|
border: 2px solid var(--accent-1);
|
|
}
|
|
.sb-ol-line-own .sb-ol-tag {
|
|
margin-left: 6px;
|
|
color: var(--accent-1);
|
|
border-color: var(--accent-1);
|
|
}
|
|
.sb-ol-line-h {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: space-between;
|
|
gap: 4px 8px;
|
|
padding: 0 3px;
|
|
font: 500 11.5px var(--font-plex);
|
|
color: var(--muted);
|
|
}
|
|
.sb-ol-line-h b {
|
|
font-weight: 600;
|
|
color: var(--ink);
|
|
}
|
|
.sb-ol-warn {
|
|
font: 500 11.5px var(--font-plex);
|
|
color: var(--verify);
|
|
background: color-mix(in srgb, var(--verify) 12%, transparent);
|
|
border-radius: 8px;
|
|
padding: 6px 9px;
|
|
}
|
|
/* The three note-rule names are long ("Referring Provider Update"); the
|
|
standard 78px label column would clip them to nothing. */
|
|
.sb-field-row.sb-wide-lbl {
|
|
grid-template-columns: 172px minmax(0, 1fr) 42px 70px;
|
|
}
|