Files
sencho/frontend/src/index.css
T
Anso 0b046bfa52 chore: add in-app What's New scaffolding (#1767)
* feat: add whats-new entries data model

* feat: add useWhatsNewPreference hook

* feat: add whats-new breathing animation

* feat: add WhatsNewTrigger sparkle icon

* feat: add whatsNew slot to TopBar

* feat: add WhatsNewModal

* feat: wire whats-new sparkle icon and modal into EditorLayout

* feat: add What's New opt-out toggle to Settings

* fix: seed What's New watermark for zero-entry releases

A release that ships with entries.json still empty must stamp a
watermark on first run, or an existing install can never distinguish
itself from a genuinely fresh install once a later release adds its
first real entry, silently swallowing that entry's unseen signal.

* fix: constrain WhatsNewModal height and clarify settings copy

Bound the dialog to 85vh as a flex column so ModalBody's fill can
constrain the entry list to scroll while the header and footer stay
pinned, matching ConfirmModal's pattern. Also clarifies the "Show
What's New" helper text in Settings.

* fix: drop What's New screenshots that fail to load

Screenshots are authored by hand alongside the entry, so a typo'd or
not-yet-added filename is a realistic mistake. Previously that left the
browser's broken-image placeholder and alt text inside the card; now the
image is dropped and the title, blurb, and doc link still render.

* style: replace em dash in watermark comment with a comma

* fix: make "Never show again" actually hide What's New

Turning the feature off previously only stopped the breathing animation
and left the sparkle icon in the top bar, which is not what that label
means anywhere else. Opting out now removes the trigger entirely and
closes the modal, and Settings > About is the single way back.

Also brings the trigger in line with its top bar siblings: it now uses
the search trigger's hover treatment and gains a visible keyboard focus
ring, and the modal bounds itself with dvh rather than vh so the footer
cannot sit under a mobile URL bar. The modal is rendered in the desktop
branch only, since the bespoke mobile screens drop the top bar that
carries its trigger.

* test: cover the empty-entries and loader paths of What's New

The shipped state has an empty entries.json, so the branch where there is
no newest entry is the one actually running, yet nothing exercised it.
Adds a sibling hook test mocking that state to pin the empty-string
watermark write, and a loader test for the newest-is-last contract the
watermark depends on plus the malformed-entry filter.

Drops the unreachable array check in the loader and the assertion that
mirrored it: TypeScript types the JSON import, so a non-array file fails
the build rather than reaching that branch.

* refactor: fold the What's New storage writers into one helper

writeEnabled and writeLastSeenId were identical apart from the key and
the value encoding, duplicating the comment explaining why a failed
write is non-fatal. The boolean encoding now sits at its single call
site. Also hoists the reversed entry list to module scope, since the
source array is a module constant, and factors the repeated preference
mock in the trigger test behind a helper.

* chore: keep the What's New icon hidden until an entry exists

With entries.json empty there is nothing to announce, so a permanent
sparkle in the top bar would only ever open a modal reading "Nothing new
to show yet". The trigger now renders nothing in that state, leaving the
modal's empty state as a runtime fallback for entries that fail
validation rather than the shipping experience.

This keeps the scaffolding invisible until the first entry is authored,
which is the change that actually surfaces the feature to users.
2026-08-04 02:20:11 -04:00

847 lines
36 KiB
CSS

@import "tailwindcss";
@plugin 'tailwindcss-animate';
@custom-variant dark (&:is(.dark *));
/* ─────────────────────────────────────────────────────────────
THEME ENGINE
Theme is an attribute on <html> (data-theme = dim · oled · light);
accent is a second attribute (data-accent = one of eight hues).
Surfaces, ink and borders derive from a handful of raw lightness
vars per theme, so the live knobs (--border-boost, --glow) modulate
every hairline and the page glow through calc(). The accent sets
--brand everywhere and tints the ambient glow.
:root carries the shared + computed tokens AND the Dim defaults, so
a document with no data-theme renders Dim (the default theme).
[data-theme="oled"] and [data-theme="light"] override from there.
───────────────────────────────────────────────────────────── */
:root {
color-scheme: dark;
/* Live knobs (overwritten on <html> by the pre-paint script / useTheme) */
--border-boost: 0; /* signed offset added to every border L */
--glow: 0.16; /* ambient top-glow alpha */
--contrast: 0; /* master contrast: spreads page, ink and borders, signed by --ink-dir */
/* Accent default: cyan (overridden by [data-accent]) */
--accent-h: 196;
--accent-c: 0.130;
/* ---- Computed surfaces (flat neutral ladder per theme). The page tone
spreads away from the cards as --contrast rises; clamp keeps it in gamut
and always a hair below --card so page/card separation never collapses
(matters at high contrast in Light, where --card is pure white). ---- */
--background: oklch(clamp(0, var(--bg) - var(--ink-dir) * var(--contrast) * 0.03, var(--card-l) - 0.012) 0 0);
--card: oklch(var(--card-l) 0 0);
--band: oklch(var(--band-l) 0 0);
--well: oklch(var(--well-l) 0 0);
/* ---- Computed popover / floating-panel fill. Mirrors --background's clamp +
--ink-dir + contrast spread off a per-theme raw lightness + alpha, so every
floating surface (sheets, dialogs, dropdowns, menus) tones with the active
theme (incl. OLED true-black) and deepens with --contrast, instead of being
a flat per-theme literal that stranded OLED at the Dim value. ---- */
--popover: oklch(clamp(0, var(--pop-l) - var(--ink-dir) * var(--contrast) * 0.03, 1) 0 0 / var(--pop-a));
/* Overlay scrim behind modals & sheets (theme-aware; softened in Light). */
--scrim: oklch(0 0 0 / 0.45);
/* ---- Computed borders (opaque grey, directionally lit). --border-boost and
--contrast both lift every hairline through one expression; clamp keeps
them in gamut and below pure white so the lit edge never merges into a
white card in Light at the low end of the knobs. ---- */
--card-border: oklch(clamp(0, var(--bb) + var(--border-dir) * (var(--border-boost) + var(--contrast) * 0.05), 0.97) 0 0);
--card-border-top: oklch(clamp(0, var(--bt) + var(--border-dir) * (var(--border-boost) + var(--contrast) * 0.05), 0.97) 0 0);
--card-border-hover: oklch(clamp(0, var(--bh) + var(--border-dir) * (var(--border-boost) + var(--contrast) * 0.05), 0.97) 0 0);
--hairline: oklch(clamp(0, var(--hl) + var(--border-dir) * (var(--border-boost) + var(--contrast) * 0.05), 0.97) 0 0);
--border: var(--card-border);
--sidebar-border: var(--card-border);
/* ---- Computed ink tiers. --contrast pushes each tier toward its extreme so
secondary text crisps up as it rises; clamp keeps every tier in gamut. ---- */
--stat-value: oklch(clamp(0, var(--val) + var(--ink-dir) * var(--contrast) * 0.04, 1) 0 0);
--stat-title: oklch(clamp(0, var(--title) + var(--ink-dir) * var(--contrast) * 0.055, 1) 0 0);
--stat-subtitle: oklch(clamp(0, var(--sub) + var(--ink-dir) * var(--contrast) * 0.07, 1) 0 0);
--stat-icon: oklch(clamp(0, var(--icon) + var(--ink-dir) * var(--contrast) * 0.06, 1) 0 0);
/* ---- Computed brand (the accent is the one data color) ---- */
--brand: oklch(var(--accent-l) var(--accent-c) var(--accent-h));
--brand-foreground: var(--brand-fg);
--brand-muted: oklch(var(--accent-l) var(--accent-c) var(--accent-h) / 0.16);
--ring: var(--brand);
--sidebar-ring: var(--brand);
/* Glass surface = the card tone; borders use the opaque hairline */
--glass: var(--card);
/* Ambient glow tracks the accent hue so the whole page tints with it */
--glow-color: oklch(var(--glow-l) var(--glow-c) var(--accent-h) / var(--glow));
/* Charts: cyan leads as the primary data color, amber for peaks, rose for errors */
--chart-1: var(--brand);
--chart-2: var(--warning);
--chart-3: var(--destructive);
/* Severity / chart palette (Signature default = today's saturated semantics).
Colours only; per-palette fill/gradient/stroke live in JS (SecurityCharts).
[data-chart-style="muted"|"heat"] override these; signature needs no rule. */
--sev-critical: var(--destructive);
--sev-high: var(--warning);
--sev-medium: color-mix(in oklch, var(--warning) 52%, var(--stat-icon));
--sev-low: color-mix(in oklch, var(--stat-icon) 72%, var(--well));
--sev-vuln: var(--brand);
/* Sparkline / data-line styling (centralized) */
--data-line-width: 1.25px;
--data-peak: var(--warning);
/* Fleet Action card transformative chip (reuses the purple label hue) */
--action-transformative: var(--label-purple);
/* ─────────────── Dim defaults (the default theme) ─────────────── */
/* Raw lightness vars */
--bg: 0.160; --card-l: 0.212; --band-l: 0.190; --well-l: 0.128;
--bb: 0.320; --bt: 0.400; --bh: 0.460; --hl: 0.300; --border-dir: 1;
--val: 0.970; --title: 0.800; --sub: 0.605; --icon: 0.480; --ink-dir: 1;
--accent-l: 0.745; --brand-fg: oklch(0.15 0 0);
--glow-l: 0.55; --glow-c: 0.085;
--pop-l: 0.14; --pop-a: 0.82;
/* Dark non-derivable tokens (shared by Dim & OLED) */
--foreground: oklch(0.94 0 0);
--card-foreground: oklch(0.94 0 0);
--popover-foreground: oklch(0.94 0 0);
--primary: oklch(0.98 0 0);
--primary-foreground: oklch(0.10 0 0);
--secondary: oklch(var(--card-l) 0 0);
--secondary-foreground: oklch(0.94 0 0);
--muted: oklch(var(--card-l) 0 0);
--muted-foreground: oklch(0.45 0 0);
--accent: oklch(1 0 0 / 0.06);
--accent-foreground: oklch(0.94 0 0);
--destructive: oklch(0.72 0.18 20);
--destructive-foreground: oklch(0.94 0 0);
--destructive-muted: oklch(0.72 0.18 20 / 0.15);
--input: oklch(var(--card-l) 0 0);
/* Glass: solid surface system (no blur on static surfaces) */
--glass-border: var(--card-border);
--glass-highlight: oklch(1 0 0 / 0.06);
/* Semantic status colors (dark set) */
--success: oklch(0.78 0.16 155);
--success-foreground: oklch(0.10 0 0);
--success-muted: oklch(0.78 0.16 155 / 0.15);
--warning: oklch(0.82 0.14 75);
--warning-foreground: oklch(0.10 0 0);
--warning-muted: oklch(0.82 0.14 75 / 0.15);
/* --info: TOAST USE ONLY. Not part of the §17 five-slot working palette. */
--info: oklch(0.70 0.14 250);
--info-foreground: oklch(0.10 0 0);
--info-muted: oklch(0.70 0.14 250 / 0.15);
--update: oklch(0.72 0.20 320);
--update-foreground: oklch(0.10 0 0);
--update-muted: oklch(0.72 0.20 320 / 0.15);
/* Sidebar (tracks the page tone; borders use the shared hairline) */
--sidebar: oklch(var(--bg) 0 0 / 0.80);
--sidebar-foreground: oklch(0.94 0 0);
--sidebar-primary: oklch(0.98 0 0);
--sidebar-primary-foreground: oklch(0.10 0 0);
--sidebar-accent: oklch(1 0 0 / 0.07);
--sidebar-accent-foreground: oklch(0.94 0 0);
/* Label palette: ten raw hues preserved for existing assignments (dark) */
--label-teal: oklch(0.75 0.10 192);
--label-teal-bg: oklch(0.75 0.10 192 / 0.15);
--label-blue: oklch(0.72 0.12 250);
--label-blue-bg: oklch(0.72 0.12 250 / 0.15);
--label-purple: oklch(0.72 0.12 300);
--label-purple-bg: oklch(0.72 0.12 300 / 0.15);
--label-rose: oklch(0.72 0.14 10);
--label-rose-bg: oklch(0.72 0.14 10 / 0.15);
--label-amber: oklch(0.78 0.14 60);
--label-amber-bg: oklch(0.78 0.14 60 / 0.15);
--label-green: oklch(0.72 0.13 150);
--label-green-bg: oklch(0.72 0.13 150 / 0.15);
--label-orange: oklch(0.75 0.14 45);
--label-orange-bg: oklch(0.75 0.14 45 / 0.15);
--label-pink: oklch(0.75 0.14 340);
--label-pink-bg: oklch(0.75 0.14 340 / 0.15);
--label-cyan: oklch(0.75 0.10 210);
--label-cyan-bg: oklch(0.75 0.10 210 / 0.15);
--label-slate: oklch(0.65 0.01 250);
--label-slate-bg: oklch(0.65 0.01 250 / 0.15);
/* Semantic label roles: new labels should consume these */
--label-env: var(--label-teal);
--label-env-bg: var(--label-teal-bg);
--label-role: var(--label-blue);
--label-role-bg: var(--label-blue-bg);
--label-owner: var(--label-green);
--label-owner-bg: var(--label-green-bg);
--label-risk: var(--label-rose);
--label-risk-bg: var(--label-rose-bg);
--label-custom: var(--label-amber);
--label-custom-bg: var(--label-amber-bg);
/* Chart neutrals + grid/axis (dark) */
--chart-4: oklch(0.55 0 0);
--chart-5: oklch(0.80 0 0);
--chart-grid: oklch(1 0 0 / 0.05);
--chart-tick: oklch(1 0 0 / 0.40);
/* Material simulation: inherent depth (dark) */
--card-bevel: inset 0 1px 0 0 oklch(1 0 0 / 0.05);
--button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.06);
--chrome-top-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.08);
/* Shadows: near-zero, depth comes from surface tone (dark) */
--shadow-x: 0px;
--shadow-y: 1px;
--shadow-blur: 2px;
--shadow-spread: 0px;
--shadow-opacity: 0.15;
--shadow-color: hsl(0 0% 0%);
--shadow-2xs: 0 0 0 0 transparent;
--shadow-xs: 0 0 0 0 transparent;
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.15);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.15);
--shadow-md: 0px 2px 4px 0px hsl(0 0% 0% / 0.15);
--shadow-lg: 0px 4px 6px -1px hsl(0 0% 0% / 0.15);
--shadow-xl: 0px 8px 10px -1px hsl(0 0% 0% / 0.20);
--shadow-2xl: 0px 12px 20px -2px hsl(0 0% 0% / 0.30);
/* Terminal (recessed well, tracks the theme's well tone) */
--terminal-bg: oklch(var(--well-l) 0 0);
--terminal-fg: oklch(0.85 0 0);
--terminal-cursor: oklch(1 0 0);
--terminal-cursor-accent: oklch(0 0 0);
--terminal-selection: oklch(1 0 0 / 0.3);
/* ─────────────── Shared (theme-independent) ─────────────── */
/* Typography. Display is the locked signature face; the interface (sans) and
data (mono) faces are user-swappable via --ui-font / --mono-font, and
--type-scale multiplies the root font-size (see the html rule below). */
--type-scale: 1;
--ui-font: 'Geist';
--mono-font: 'Geist Mono';
--font-sans: var(--ui-font), system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: var(--mono-font), ui-monospace, monospace;
--font-serif: 'Instrument Serif', Georgia, serif;
--font-display: 'Instrument Serif', Georgia, serif;
/* Operational heading family. Signature default = the display serif, italic;
family + style ONLY so migrated headings keep their own weight/tracking and
stay a true no-op in Signature. [data-headings="clean"] flips to the sans
face and the Calm weight/tracking lift lands via the .font-heading rule. */
--font-heading: var(--font-display);
--heading-style: italic;
/* Shape */
--radius: 0.5rem;
/* Height of the mobile bottom tab bar's interactive area (excludes the
safe-area inset, which is added where needed). Fixed-position overlays
(toasts, the deploy pill) offset above it on mobile so they never sit
behind the tab bar. */
--sn-mobile-tabbar-h: 56px;
/* Motion timing */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--duration-fast: 150ms;
--duration-base: 220ms;
--duration-slow: 350ms;
--tracking-normal: 0em;
--spacing: 0.25rem;
/* Density: comfortable defaults. Overridden under .density-compact. */
--density-scale: 1;
--density-row-y: 0.75rem;
--density-row-x: 1.25rem;
--density-cell-y: 0.5rem;
--density-tile-y: 1rem;
--density-gap: 1rem;
}
/* ─────────────────────────────────────────────────────────────
ACCENTS (hue + chroma only; lightness comes from the theme)
───────────────────────────────────────────────────────────── */
/* A tighter, well-spaced wheel: every pair is >=40deg apart and pushed
chromatic so the hues stay distinct at the accent lightness. Kept clear of
success(155) / warning(75) / destructive(20). Steel is the near-neutral. */
[data-accent="cyan"] { --accent-h: 196; --accent-c: 0.130; }
[data-accent="blue"] { --accent-h: 252; --accent-c: 0.165; }
[data-accent="violet"] { --accent-h: 298; --accent-c: 0.190; }
[data-accent="magenta"] { --accent-h: 344; --accent-c: 0.180; }
[data-accent="orange"] { --accent-h: 42; --accent-c: 0.165; }
[data-accent="amber"] { --accent-h: 88; --accent-c: 0.165; }
[data-accent="lime"] { --accent-h: 132; --accent-c: 0.195; }
[data-accent="steel"] { --accent-h: 250; --accent-c: 0.040; }
/* ─────────────────────────────────────────────────────────────
OLED: true black for power-saving panels. Only the raw surface,
ink and glow vars change; the dark non-derivable tokens inherit
from :root, and the opaque-grey borders keep cards from merging
into the black the way 6%-white hairlines did.
───────────────────────────────────────────────────────────── */
[data-theme="oled"] {
--bg: 0.035; --card-l: 0.105; --band-l: 0.072; --well-l: 0.020;
--bb: 0.262; --bt: 0.345; --bh: 0.405; --hl: 0.235; --border-dir: 1;
--val: 0.950; --title: 0.780; --sub: 0.575; --icon: 0.445; --ink-dir: 1;
--accent-l: 0.745; --brand-fg: oklch(0.11 0 0);
--glow-l: 0.46; --glow-c: 0.085;
--pop-l: 0.06; --pop-a: 0.86;
}
/* ─────────────────────────────────────────────────────────────
LIGHT: the existing light theme. Borders are opaque and
directionally lit (the lit top edge is lighter, sides darker,
so --border-dir flips to -1).
───────────────────────────────────────────────────────────── */
[data-theme="light"] {
color-scheme: light;
/* Raw lightness vars */
--bg: 0.965; --card-l: 1.000; --band-l: 0.972; --well-l: 0.945;
--bb: 0.855; --bt: 0.910; --bh: 0.780; --hl: 0.885; --border-dir: -1;
--val: 0.190; --title: 0.400; --sub: 0.500; --icon: 0.620; --ink-dir: -1;
--accent-l: 0.525; --brand-fg: oklch(0.99 0 0);
--glow-l: 0.90; --glow-c: 0.040;
--pop-l: 1.0; --pop-a: 0.92;
--scrim: oklch(0.22 0 0 / 0.32);
/* Light non-derivable tokens */
--foreground: oklch(0.15 0 0);
--card-foreground: oklch(0.15 0 0);
--popover-foreground: oklch(0.15 0 0);
--primary: oklch(0.15 0 0);
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0 0 0 / 0.05);
--secondary-foreground: oklch(0.15 0 0);
--muted: oklch(0 0 0 / 0.04);
--muted-foreground: oklch(0.50 0 0);
--accent: oklch(0 0 0 / 0.06);
--accent-foreground: oklch(0.15 0 0);
--destructive: oklch(0.63 0.18 20);
--destructive-foreground: oklch(1 0 0);
--destructive-muted: oklch(0.63 0.18 20 / 0.12);
--input: oklch(0.97 0 0);
--glass-border: var(--card-border);
--glass-highlight: oklch(0 0 0 / 0.06);
--success: oklch(0.65 0.16 155);
--success-foreground: oklch(1 0 0);
--success-muted: oklch(0.65 0.16 155 / 0.12);
--warning: oklch(0.75 0.14 75);
--warning-foreground: oklch(0.20 0 0);
--warning-muted: oklch(0.75 0.14 75 / 0.12);
--info: oklch(0.62 0.14 250);
--info-foreground: oklch(1 0 0);
--info-muted: oklch(0.62 0.14 250 / 0.12);
--update: oklch(0.62 0.22 320);
--update-foreground: oklch(1 0 0);
--update-muted: oklch(0.62 0.22 320 / 0.12);
--sidebar: oklch(0.98 0 0 / 0.80);
--sidebar-foreground: oklch(0.15 0 0);
--sidebar-primary: oklch(0.15 0 0);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0 0 0 / 0.06);
--sidebar-accent-foreground: oklch(0.15 0 0);
/* Label palette (light) */
--label-teal: oklch(0.55 0.12 192);
--label-teal-bg: oklch(0.55 0.12 192 / 0.12);
--label-blue: oklch(0.55 0.14 250);
--label-blue-bg: oklch(0.55 0.14 250 / 0.12);
--label-purple: oklch(0.55 0.14 300);
--label-purple-bg: oklch(0.55 0.14 300 / 0.12);
--label-rose: oklch(0.55 0.16 10);
--label-rose-bg: oklch(0.55 0.16 10 / 0.12);
--label-amber: oklch(0.60 0.16 60);
--label-amber-bg: oklch(0.60 0.16 60 / 0.12);
--label-green: oklch(0.55 0.15 150);
--label-green-bg: oklch(0.55 0.15 150 / 0.12);
--label-orange: oklch(0.58 0.16 45);
--label-orange-bg: oklch(0.58 0.16 45 / 0.12);
--label-pink: oklch(0.58 0.16 340);
--label-pink-bg: oklch(0.58 0.16 340 / 0.12);
--label-cyan: oklch(0.58 0.12 210);
--label-cyan-bg: oklch(0.58 0.12 210 / 0.12);
--label-slate: oklch(0.50 0.01 250);
--label-slate-bg: oklch(0.50 0.01 250 / 0.12);
/* Chart neutrals + grid/axis (light) */
--chart-4: oklch(0.70 0 0);
--chart-5: oklch(0.45 0 0);
--chart-grid: oklch(0 0 0 / 0.06);
--chart-tick: oklch(0 0 0 / 0.45);
/* Material simulation (light) */
--card-bevel: inset 0 1px 0 0 oklch(1 0 0 / 0.04), 0 1px 2px 0 oklch(0 0 0 / 0.05);
--button-inner-glow: inset 0 1px 0 0 oklch(1 0 0 / 0.10), 0 1px 2px 0 oklch(0 0 0 / 0.06);
--chrome-top-highlight: inset 0 1px 0 0 oklch(1 0 0 / 0.45);
/* Shadows (light) */
--shadow-opacity: 0.10;
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.08);
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.08), 0px 1px 2px -1px hsl(0 0% 0% / 0.08);
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.10), 0px 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.25);
/* Terminal stays a dark well even in the light theme */
--terminal-bg: oklch(0.12 0 0);
}
/* ─────────────────────────────────────────────────────────────
CALM / READABILITY REFRESH
Three appearance attributes on <html> switch the heading family, the
severity-chart palette, and material effects. Signature (the defaults in
:root) needs no rule; Calm flips them. Placed after the theme blocks so the
reduced-effects --card-bevel override wins in every theme.
───────────────────────────────────────────────────────────── */
/* Clean headings: the readability fix. Operational headings drop the display
serif for the upright interface face. */
[data-headings="clean"] {
--font-heading: var(--font-sans);
--heading-style: normal;
}
/* Muted: desaturated severity semantics, no brand/destructive clash. */
[data-chart-style="muted"] {
--sev-critical: oklch(0.605 0.105 28);
--sev-high: oklch(0.715 0.085 70);
--sev-medium: oklch(0.675 0.045 88);
--sev-low: oklch(0.565 0.018 250);
--sev-vuln: oklch(0.605 0.045 235);
}
/* Heat: a single warm sequential ramp with zero complementary vibration. */
[data-chart-style="heat"] {
--sev-critical: oklch(0.565 0.135 30);
--sev-high: oklch(0.675 0.115 48);
--sev-medium: oklch(0.770 0.080 70);
--sev-low: oklch(0.825 0.040 82);
--sev-vuln: oklch(0.660 0.100 45);
}
/* Reduced effects: lower-overhead rendering path (Calm / Reduced effects /
Readability). Flatten material, drop glass blur, and solidify translucent
chrome so constrained GPUs are not forced to sample backdrop-filter.
data-theme and data-effects both live on <html>, so token overrides use
combined selectors. Exceptions to the blur kill need a more-specific
!important rule, not a normal opt-out class.
Charts also read the reduced flag from the store. */
html[data-effects="reduced"] {
--card-bevel: none;
--pop-a: 1;
/* Dim (default theme when data-theme is dim or unset on first paint) */
--sidebar: oklch(var(--bg) 0 0);
}
html[data-theme="oled"][data-effects="reduced"] {
--sidebar: oklch(var(--bg) 0 0);
}
html[data-theme="light"][data-effects="reduced"] {
--sidebar: oklch(0.98 0 0);
}
/* Both properties: Chromium uses backdrop-filter; the WebKit-prefixed form
still appears in some engines. */
html[data-effects="reduced"] *,
html[data-effects="reduced"] *::before,
html[data-effects="reduced"] *::after {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* Direct-alpha glass surfaces (bg-popover/95, color-mix tab bar, etc.) stay
translucent after --pop-a: 1. Mark them with data-sn-glass and solidify here. */
html[data-effects="reduced"] [data-sn-glass="panel"] {
background-color: var(--popover);
}
html[data-effects="reduced"] [data-sn-glass="overlay"] {
background-color: var(--background);
}
html[data-effects="reduced"] [data-sn-glass="mobile-tabbar"] {
background-color: var(--card);
}
html[data-effects="reduced"] .animate-pulse {
animation: none;
}
html[data-effects="reduced"] .animate-whats-new-breathe {
animation: none;
}
/* Decorative masthead rails: tied to reduced material effects (Calm, Readability,
or manual Reduced effects), not to data-motion. Motion remains independently
toggleable; turning Motion off does not restore these rails while effects are reduced. */
html[data-effects="reduced"] .masthead-rail-glow,
html[data-effects="reduced"] .masthead-rail-shimmer {
animation: none;
}
/* Heading utility: every operational title routes through this. Base sets only
family + style (Signature parity); the Calm-only descendant rule lifts weight
and tightens tracking for the sans face, beating any single tracking or weight
utility on the element. */
.font-heading {
font-family: var(--font-heading);
font-style: var(--heading-style);
}
[data-headings="clean"] .font-heading {
font-weight: 600;
letter-spacing: -0.006em;
}
/* ─────────────────────────────────────────────────────────────
TAILWIND THEME MAPPING
───────────────────────────────────────────────────────────── */
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-brand: var(--brand);
--color-brand-foreground: var(--brand-foreground);
--color-brand-muted: var(--brand-muted);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-glass: var(--glass);
--color-glass-border: var(--glass-border);
--color-glass-highlight: var(--glass-highlight);
--color-stat-value: var(--stat-value);
--color-stat-title: var(--stat-title);
--color-stat-subtitle: var(--stat-subtitle);
--color-stat-icon: var(--stat-icon);
--color-card-border: var(--card-border);
--color-card-border-top: var(--card-border-top);
--color-card-border-hover: var(--card-border-hover);
--color-band: var(--band);
--color-well: var(--well);
--color-hairline: var(--hairline);
--color-chart-grid: var(--chart-grid);
--color-chart-tick: var(--chart-tick);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-success-muted: var(--success-muted);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-warning-muted: var(--warning-muted);
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
--color-info-muted: var(--info-muted);
--color-update: var(--update);
--color-update-foreground: var(--update-foreground);
--color-update-muted: var(--update-muted);
--color-destructive-muted: var(--destructive-muted);
--color-action-transformative: var(--action-transformative);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-display: var(--font-display);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-2xs: var(--shadow-2xs);
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow: var(--shadow);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-2xl: var(--shadow-2xl);
--shadow-card-bevel: var(--card-bevel);
--shadow-btn-glow: var(--button-inner-glow);
--shadow-chrome-top: var(--chrome-top-highlight);
}
/* ─────────────────────────────────────────────────────────────
BASE STYLES
───────────────────────────────────────────────────────────── */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply text-foreground;
/* Ambient top-center glow tracks the accent hue and the --glow knob. Shows
on screens whose root is transparent (e.g. Login). Full-screen app shells
that paint an opaque --background must use .app-canvas instead, or they
occlude this. */
background:
radial-gradient(
ellipse 156% 106% at 50% -6%,
var(--glow-color),
transparent 70%
),
var(--background);
background-attachment: fixed;
}
}
/* The page canvas: opaque --background base plus the ambient accent glow. Use on
full-screen app shells (EditorLayout, the loading wrapper) that would otherwise
paint a flat bg-background over the body glow and hide it entirely. */
.app-canvas {
background:
radial-gradient(
ellipse 156% 106% at 50% -6%,
var(--glow-color),
transparent 70%
),
var(--background);
background-attachment: fixed;
}
/* Accent wash for floating panels (sheets & large dialogs). Layers the ambient
accent glow as a background image over the panel's own --popover fill, so a
portaled surface carries the same accent personality .app-canvas gives the
page. Image-only (the popover color stays the base) to keep the glass
translucency and backdrop blur; anchored to the panel top so the translucent
header bands let it bleed through. */
.panel-glow {
background-image: radial-gradient(
ellipse 130% 42% at 50% 0%,
var(--glow-color),
transparent 64%
);
}
/* Density: compact mode compresses row/cell padding by ~50%. */
body.density-compact {
--density-scale: 0.75;
--density-row-y: 0.375rem;
--density-row-x: 1rem;
--density-cell-y: 0.25rem;
--density-tile-y: 0.625rem;
--density-gap: 0.75rem;
}
/* ─────────────────────────────────────────────────────────────
TYPOGRAPHY & RENDERING
───────────────────────────────────────────────────────────── */
html,
body,
#root {
height: 100%;
width: 100%;
overflow: hidden;
}
/* Text-size control: one root multiplier scales the whole rem-based type ramp.
Layout px is untouched; only rem-sized text grows/shrinks. */
html {
font-size: calc(16px * var(--type-scale));
}
body {
font-family: var(--font-sans), system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ─────────────────────────────────────────────────────────────
SCROLLBAR
───────────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: oklch(0.40 0 0 / 0.5);
border-radius: 4px;
transition: background var(--duration-fast) ease;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.55 0 0 / 0.7);
}
.dark ::-webkit-scrollbar-thumb {
background: oklch(0.35 0 0 / 0.6);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: oklch(0.50 0 0 / 0.8);
}
/* ─────────────────────────────────────────────────────────────
GLASS UTILITIES
───────────────────────────────────────────────────────────── */
.glass {
background: var(--glass);
border: 1px solid var(--glass-border);
}
.glass-strong {
background: var(--glass);
border: 1px solid var(--glass-border);
}
.glass-float {
background: var(--popover);
backdrop-filter: blur(10px) saturate(1.15);
-webkit-backdrop-filter: blur(10px) saturate(1.15);
border: 1px solid var(--glass-border);
}
/* ─────────────────────────────────────────────────────────────
ANIMATION KEYFRAMES
───────────────────────────────────────────────────────────── */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fade-down {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slide-in-left {
from { opacity: 0; transform: translateX(-12px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes shimmer {
0% { transform: translateY(-100%); }
100% { transform: translateY(200%); }
}
@keyframes masthead-rail-glow {
0%, 100% { opacity: 0.15; }
50% { opacity: 0.55; }
}
@keyframes whats-new-breathe {
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.08); opacity: 1; }
}
/* ─────────────────────────────────────────────────────────────
ANIMATION UTILITIES
───────────────────────────────────────────────────────────── */
.animate-fade-in {
animation: fade-in var(--duration-base) var(--ease-out-expo) both;
}
.animate-fade-up {
animation: fade-up var(--duration-base) var(--ease-out-expo) both;
}
.animate-fade-down {
animation: fade-down var(--duration-base) var(--ease-out-expo) both;
}
.animate-slide-in-left {
animation: slide-in-left var(--duration-base) var(--ease-out-expo) both;
}
.animate-scale-in {
animation: scale-in var(--duration-fast) var(--ease-spring) both;
}
.animate-shimmer {
animation: shimmer 4.5s ease-in-out infinite alternate;
}
.masthead-rail-shimmer {
animation: shimmer 5s ease-in-out infinite alternate;
}
.masthead-rail-glow {
animation: masthead-rail-glow 4s ease-in-out infinite alternate;
}
.animate-whats-new-breathe {
animation: whats-new-breathe 2.6s ease-in-out infinite;
}
/* Stagger delay helpers */
.animate-delay-50 { animation-delay: 50ms; }
.animate-delay-100 { animation-delay: 100ms; }
.animate-delay-150 { animation-delay: 150ms; }
.animate-delay-200 { animation-delay: 200ms; }
.animate-delay-300 { animation-delay: 300ms; }
/* ─────────────────────────────────────────────────────────────
PREFERS REDUCED MOTION - zero-cost accessibility
───────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* The "Reduced motion" appearance setting (data-motion="reduced" on <html>)
clamps CSS animations/transitions, mirroring the OS preference above. Sonner
toasts animate via inline transform/opacity (React state), not CSS, so they
are unaffected. framer-motion is handled separately by MotionConfig. */
[data-motion="reduced"] *,
[data-motion="reduced"] *::before,
[data-motion="reduced"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* ─────────────────────────────────────────────────────────────
XTERM.JS
───────────────────────────────────────────────────────────── */
.xterm {
padding: 0;
}