SchOS/docs/niveus/dist/niveus.css
Nexoniarz 54b25708c3 Demand paging, the RTL8168H, links that come up late, and Ctrl+Alt+F1..F12
- Anonymous memory can be reserved without being backed: large mappings,
  MAP_NORESERVE and PROT_NONE are recorded and each page appears, zeroed,
  on first touch -- from trap() for the program, from uva2ka() for the
  kernel on its behalf. WebKit reserves 4 GB at start and touches a sliver;
  backing it all failed with ENOMEM and Epiphany aborted.
- r8169: the 8168G generation and later (the laptop's 8168H, XID 541) are
  taken from the firmware's out-of-band mode, get their PLL powered, the
  receive gate opened, and their PHY reached through the OCP window. The
  PHY's power-down bit is cleared; the old code kept it while restarting
  autonegotiation. Untested on the hardware so far.
- The network thread follows each device's link into lwIP. The link was
  read once at attach, 0.02 s into boot; real autonegotiation takes
  seconds, so on hardware the interface stayed down for good.
- Twelve terminals, switched with Ctrl+Alt+F1..F12 by default: the keys X
  uses, so the console and the desktop agree. AltGr+F-key is gone from
  the defaults (chvt -c altgr+F brings it back).
- SO_PASSCRED on Unix sockets, with SCM_CREDENTIALS delivered by recvmsg.
  WebKit sets it on its IPC sockets and aborted when refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
2026-10-25 11:27:48 +01:00

3705 lines
136 KiB
CSS

/* Niveus — generated bundle. Do not edit.
Source: niveus/niveus.css and the files it imports.
Rebuild: python3 tools/build.py
*/
/* ==========================================================================
Niveus — a calm, adaptive interface language.
Made by Nexoniarz.
--------------------------------------------------------------------------
The single entry point. An application links this file and nothing else:
<link rel="stylesheet" href="niveus/niveus.css">
<script src="niveus/niveus.js"></script> — optional, see below
<html data-theme="dark"> — or omit data-theme to follow the system
niveus.js is not required for anything to render. It exists only so state
changes can be *continuous*: it wraps an update in a view transition so the
browser can animate between the two states. Without it, every component
still works — changes simply arrive rather than travel.
Everything ships inside the `niveus` cascade layer, so unlayered application
styles always win without needing !important or selector escalation. The
sub-layers are ordered below; that statement is what fixes their precedence,
independently of the order the files happen to load in.
========================================================================== */
@layer niveus.tokens, niveus.theme, niveus.base, niveus.continuity,
niveus.components, niveus.utilities;
/* --- Tokens: the values, appearance-agnostic ------------------------------- */
@layer niveus.tokens {
/* ==========================================================================
Niveus — Color
--------------------------------------------------------------------------
Two levels of tokens live here:
1. Palette Raw, appearance-agnostic values. Never referenced by
components. Prefixed --nv-palette-*.
2. Semantic The roles components actually consume (background, surface,
text-primary, accent, danger, …). Each role resolves with
light-dark(), pulling its value from the matching appearance
file: themes/light.css (--nv-light-*) or themes/dark.css
(--nv-dark-*).
The appearance is chosen by `color-scheme`, which themes/*.css bind to the
[data-theme] attribute. A component therefore never knows — and must never
ask — which appearance is active.
========================================================================== */
/* ==========================================================================
Brand
--------------------------------------------------------------------------
The whole color system has one input. Set --nv-brand and the accent, its
hover and active states, the text that lands on it, the focus ring, the
selection highlight, the tint in the greys and the lean of the status colors
are all re-derived from it — in both appearances, with no second value to
keep in sync.
:root { --nv-brand: #d92e3f; }
Set it on any element with [data-nv-brand] to re-brand a subtree.
========================================================================== */
:root {
--nv-brand: #4f6cff;
/* How much of the brand bleeds into the greys. Enough that the neutrals
belong to the same family as the accent; not enough to read as colored.
0% gives strictly neutral greys. */
--nv-brand-tint: 4%;
/* How far the status colors lean towards the brand. Red still reads as
danger and green still reads as success — they just stop looking borrowed
from another system. 0% keeps them literal. */
--nv-brand-harmony: 12%;
/* Where "too mid-toned to carry a label" begins, and how far either side a
color is pushed to escape it. Raising the pivot sends more brands dark
(white labels); lowering it sends more of them light (dark labels). */
--nv-contrast-pivot: 0.7;
--nv-contrast-dark: 0.53;
--nv-contrast-light: 0.78;
}
:root {
/* --- Neutral ---------------------------------------------------------- */
--nv-palette-neutral-0: #ffffff;
--nv-palette-neutral-25: #fbfcfe;
--nv-palette-neutral-50: #f7f8fb;
--nv-palette-neutral-100: #f1f3f8;
--nv-palette-neutral-150: #e8ebf2;
--nv-palette-neutral-200: #dfe3ec;
--nv-palette-neutral-300: #ccd2de;
--nv-palette-neutral-400: #a7b0c0;
--nv-palette-neutral-500: #7c8797;
--nv-palette-neutral-600: #5b6574;
--nv-palette-neutral-700: #434b59;
--nv-palette-neutral-800: #2d3340;
--nv-palette-neutral-850: #232936;
--nv-palette-neutral-900: #1a1f29;
--nv-palette-neutral-925: #151a22;
--nv-palette-neutral-950: #10141b;
--nv-palette-neutral-975: #0b0e14;
--nv-palette-neutral-1000: #06080c;
/* --- Accent (indigo) --------------------------------------------------- */
--nv-palette-accent-50: #eef2ff;
--nv-palette-accent-100: #dfe6ff;
--nv-palette-accent-200: #c4d0ff;
--nv-palette-accent-300: #9fb3ff;
--nv-palette-accent-400: #7691ff;
--nv-palette-accent-500: #4f6cff;
--nv-palette-accent-600: #3f5cf0;
--nv-palette-accent-700: #3450d6;
--nv-palette-accent-800: #2a3fa8;
--nv-palette-accent-900: #22347f;
/* --- Success (green) --------------------------------------------------- */
--nv-palette-success-50: #e8f9ef;
--nv-palette-success-100: #ccf0dc;
--nv-palette-success-200: #9ee2bc;
--nv-palette-success-300: #62cd96;
--nv-palette-success-400: #33b478;
--nv-palette-success-500: #189a60;
--nv-palette-success-600: #0f7d4d;
--nv-palette-success-700: #0c6440;
--nv-palette-success-800: #0a5034;
--nv-palette-success-900: #08412b;
/* --- Warning (amber) --------------------------------------------------- */
--nv-palette-warning-50: #fff6e6;
--nv-palette-warning-100: #ffe9c2;
--nv-palette-warning-200: #ffd68d;
--nv-palette-warning-300: #fbbe52;
--nv-palette-warning-400: #eda520;
--nv-palette-warning-500: #cf8809;
--nv-palette-warning-600: #a96d05;
--nv-palette-warning-700: #855505;
--nv-palette-warning-800: #6a4406;
--nv-palette-warning-900: #563806;
/* --- Danger (red) ------------------------------------------------------ */
--nv-palette-danger-50: #ffeff1;
--nv-palette-danger-100: #ffdbde;
--nv-palette-danger-200: #ffb9bf;
--nv-palette-danger-300: #ff8b96;
--nv-palette-danger-400: #f95b6c;
--nv-palette-danger-500: #e2334a;
--nv-palette-danger-600: #c01f36;
--nv-palette-danger-700: #9c172b;
--nv-palette-danger-800: #7e1524;
--nv-palette-danger-900: #67141f;
}
/* ==========================================================================
Semantic roles
========================================================================== */
/* The subtree selector has to be repeated here, not just on the theme files:
var() inside a custom property is substituted where that property is
*declared*, so a --nv-color-* declared only on :root would bake in the root
brand and inherit the result unchanged. Re-declaring on [data-nv-brand]
re-runs the substitution against the subtree's own brand. */
:root,
[data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */
--nv-color-background: light-dark(var(--nv-light-background), var(--nv-dark-background));
--nv-color-background-subtle: light-dark(var(--nv-light-background-subtle), var(--nv-dark-background-subtle));
--nv-color-surface: light-dark(var(--nv-light-surface), var(--nv-dark-surface));
--nv-color-surface-elevated: light-dark(var(--nv-light-surface-elevated), var(--nv-dark-surface-elevated));
--nv-color-surface-inset: light-dark(var(--nv-light-surface-inset), var(--nv-dark-surface-inset));
--nv-color-surface-hover: light-dark(var(--nv-light-surface-hover), var(--nv-dark-surface-hover));
--nv-color-surface-active: light-dark(var(--nv-light-surface-active), var(--nv-dark-surface-active));
--nv-color-scrim: light-dark(var(--nv-light-scrim), var(--nv-dark-scrim));
/* --- Text --------------------------------------------------------------- */
--nv-color-text-primary: light-dark(var(--nv-light-text-primary), var(--nv-dark-text-primary));
--nv-color-text-secondary: light-dark(var(--nv-light-text-secondary), var(--nv-dark-text-secondary));
--nv-color-text-tertiary: light-dark(var(--nv-light-text-tertiary), var(--nv-dark-text-tertiary));
--nv-color-text-disabled: light-dark(var(--nv-light-text-disabled), var(--nv-dark-text-disabled));
--nv-color-text-inverted: light-dark(var(--nv-light-text-inverted), var(--nv-dark-text-inverted));
/* --- Borders ------------------------------------------------------------ */
--nv-color-border-subtle: light-dark(var(--nv-light-border-subtle), var(--nv-dark-border-subtle));
--nv-color-border: light-dark(var(--nv-light-border), var(--nv-dark-border));
--nv-color-border-strong: light-dark(var(--nv-light-border-strong), var(--nv-dark-border-strong));
/* --- Accent ------------------------------------------------------------- */
--nv-color-accent: light-dark(var(--nv-light-accent), var(--nv-dark-accent));
--nv-color-accent-hover: light-dark(var(--nv-light-accent-hover), var(--nv-dark-accent-hover));
--nv-color-accent-active: light-dark(var(--nv-light-accent-active), var(--nv-dark-accent-active));
--nv-color-accent-subtle: light-dark(var(--nv-light-accent-subtle), var(--nv-dark-accent-subtle));
--nv-color-accent-subtle-hover: light-dark(var(--nv-light-accent-subtle-hover), var(--nv-dark-accent-subtle-hover));
--nv-color-accent-border: light-dark(var(--nv-light-accent-border), var(--nv-dark-accent-border));
--nv-color-accent-text: light-dark(var(--nv-light-accent-text), var(--nv-dark-accent-text));
--nv-color-on-accent: light-dark(var(--nv-light-on-accent), var(--nv-dark-on-accent));
/* --- Success ------------------------------------------------------------ */
--nv-color-success: light-dark(var(--nv-light-success), var(--nv-dark-success));
--nv-color-success-hover: light-dark(var(--nv-light-success-hover), var(--nv-dark-success-hover));
--nv-color-success-subtle: light-dark(var(--nv-light-success-subtle), var(--nv-dark-success-subtle));
--nv-color-success-border: light-dark(var(--nv-light-success-border), var(--nv-dark-success-border));
--nv-color-success-text: light-dark(var(--nv-light-success-text), var(--nv-dark-success-text));
--nv-color-on-success: light-dark(var(--nv-light-on-success), var(--nv-dark-on-success));
/* --- Warning ------------------------------------------------------------ */
--nv-color-warning: light-dark(var(--nv-light-warning), var(--nv-dark-warning));
--nv-color-warning-hover: light-dark(var(--nv-light-warning-hover), var(--nv-dark-warning-hover));
--nv-color-warning-subtle: light-dark(var(--nv-light-warning-subtle), var(--nv-dark-warning-subtle));
--nv-color-warning-border: light-dark(var(--nv-light-warning-border), var(--nv-dark-warning-border));
--nv-color-warning-text: light-dark(var(--nv-light-warning-text), var(--nv-dark-warning-text));
--nv-color-on-warning: light-dark(var(--nv-light-on-warning), var(--nv-dark-on-warning));
/* --- Danger ------------------------------------------------------------- */
--nv-color-danger: light-dark(var(--nv-light-danger), var(--nv-dark-danger));
--nv-color-danger-hover: light-dark(var(--nv-light-danger-hover), var(--nv-dark-danger-hover));
--nv-color-danger-active: light-dark(var(--nv-light-danger-active), var(--nv-dark-danger-active));
--nv-color-danger-subtle: light-dark(var(--nv-light-danger-subtle), var(--nv-dark-danger-subtle));
--nv-color-danger-border: light-dark(var(--nv-light-danger-border), var(--nv-dark-danger-border));
--nv-color-danger-text: light-dark(var(--nv-light-danger-text), var(--nv-dark-danger-text));
--nv-color-on-danger: light-dark(var(--nv-light-on-danger), var(--nv-dark-on-danger));
/* --- Depth and material ------------------------------------------------- */
--nv-color-shadow-ambient: light-dark(var(--nv-light-shadow-ambient), var(--nv-dark-shadow-ambient));
--nv-color-shadow-penumbra: light-dark(var(--nv-light-shadow-penumbra), var(--nv-dark-shadow-penumbra));
--nv-color-shadow-umbra: light-dark(var(--nv-light-shadow-umbra), var(--nv-dark-shadow-umbra));
--nv-color-shadow-inset: light-dark(var(--nv-light-shadow-inset), var(--nv-dark-shadow-inset));
--nv-color-highlight: light-dark(var(--nv-light-highlight), var(--nv-dark-highlight));
--nv-color-highlight-strong: light-dark(var(--nv-light-highlight-strong), var(--nv-dark-highlight-strong));
/* Elevation tint: on a dark canvas a shadow has nothing to darken, so a
raised surface also has to gain light. Transparent in light, where the
shadow does all the work. */
--nv-color-elevation-tint-1: light-dark(var(--nv-light-elevation-tint-1), var(--nv-dark-elevation-tint-1));
--nv-color-elevation-tint-2: light-dark(var(--nv-light-elevation-tint-2), var(--nv-dark-elevation-tint-2));
--nv-color-elevation-tint-3: light-dark(var(--nv-light-elevation-tint-3), var(--nv-dark-elevation-tint-3));
--nv-color-elevation-tint-4: light-dark(var(--nv-light-elevation-tint-4), var(--nv-dark-elevation-tint-4));
--nv-color-elevation-tint-5: light-dark(var(--nv-light-elevation-tint-5), var(--nv-dark-elevation-tint-5));
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
/* --- Interaction --------------------------------------------------------- */
--nv-color-focus-ring: light-dark(var(--nv-light-focus-ring), var(--nv-dark-focus-ring));
--nv-color-focus-ring-contrast: light-dark(var(--nv-light-focus-ring-contrast), var(--nv-dark-focus-ring-contrast));
--nv-color-selection: light-dark(var(--nv-light-selection), var(--nv-dark-selection));
}
}
@layer niveus.tokens {
/* ==========================================================================
Niveus — Typography
--------------------------------------------------------------------------
One type scale, used everywhere. Sizes are in rem so they respect the user's
browser font size; line heights are unitless so they inherit correctly.
========================================================================== */
:root {
/* --- Families ----------------------------------------------------------- */
--nv-font-family-sans:
"Inter var", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Helvetica Neue", Arial, sans-serif;
--nv-font-family-mono:
"SF Mono", "JetBrains Mono", "IBM Plex Mono", ui-monospace, "Menlo",
"Consolas", monospace;
/* --- Size scale ---------------------------------------------------------- */
--nv-font-size-2xs: 0.6875rem; /* 11px — dense metadata, never body text */
--nv-font-size-xs: 0.75rem; /* 12px */
--nv-font-size-sm: 0.8125rem; /* 13px */
--nv-font-size-md: 0.9375rem; /* 15px — default body size */
--nv-font-size-lg: 1.0625rem; /* 17px */
--nv-font-size-xl: 1.25rem; /* 20px */
--nv-font-size-2xl: 1.5rem; /* 24px */
--nv-font-size-3xl: 1.875rem; /* 30px */
--nv-font-size-4xl: 2.375rem; /* 38px */
--nv-font-size-5xl: 3rem; /* 48px */
/* --- Weight -------------------------------------------------------------- */
--nv-font-weight-regular: 400;
--nv-font-weight-medium: 500;
--nv-font-weight-semibold: 600;
--nv-font-weight-bold: 700;
/* --- Line height --------------------------------------------------------- */
--nv-line-height-tight: 1.18; /* display and large headings */
--nv-line-height-snug: 1.32; /* headings, single-line controls */
--nv-line-height-normal: 1.5; /* body copy */
--nv-line-height-relaxed: 1.68; /* long-form reading */
/* --- Letter spacing -------------------------------------------------------
Large text needs negative tracking to stay optically even; small caps-ish
labels need positive tracking to stay readable. */
--nv-letter-spacing-tighter: -0.022em;
--nv-letter-spacing-tight: -0.012em;
--nv-letter-spacing-normal: 0em;
--nv-letter-spacing-wide: 0.02em;
--nv-letter-spacing-wider: 0.06em;
/* --- Composite roles ------------------------------------------------------
Applied as classes in base.css (.nv-display, .nv-heading, …). Kept as
tokens so applications can compose their own elements from the same
values instead of guessing. */
--nv-font-display: var(--nv-font-weight-semibold) var(--nv-font-size-4xl) /
var(--nv-line-height-tight) var(--nv-font-family-sans);
--nv-font-heading: var(--nv-font-weight-semibold) var(--nv-font-size-2xl) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-title: var(--nv-font-weight-semibold) var(--nv-font-size-lg) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-body: var(--nv-font-weight-regular) var(--nv-font-size-md) /
var(--nv-line-height-normal) var(--nv-font-family-sans);
--nv-font-label: var(--nv-font-weight-medium) var(--nv-font-size-sm) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-caption: var(--nv-font-weight-regular) var(--nv-font-size-xs) /
var(--nv-line-height-normal) var(--nv-font-family-sans);
--nv-font-code: var(--nv-font-weight-regular) var(--nv-font-size-sm) /
var(--nv-line-height-normal) var(--nv-font-family-mono);
/* --- Measure --------------------------------------------------------------- */
--nv-measure-narrow: 44ch;
--nv-measure: 68ch;
--nv-measure-wide: 82ch;
}
}
@layer niveus.tokens {
/* ==========================================================================
Niveus — Spacing and size
--------------------------------------------------------------------------
A 4px rhythm. The numeric suffix is the value in quarter-steps, so
--nv-space-4 is 16px and --nv-space-6 is 24px. Anything not on the scale is
application layout, not design-system spacing.
========================================================================== */
:root {
--nv-space-0: 0;
--nv-space-px: 1px;
--nv-space-0-5: 0.125rem; /* 2px */
--nv-space-1: 0.25rem; /* 4px */
--nv-space-1-5: 0.375rem; /* 6px */
--nv-space-2: 0.5rem; /* 8px */
--nv-space-2-5: 0.625rem; /* 10px */
--nv-space-3: 0.75rem; /* 12px */
--nv-space-4: 1rem; /* 16px */
--nv-space-5: 1.25rem; /* 20px */
--nv-space-6: 1.5rem; /* 24px */
--nv-space-7: 1.75rem; /* 28px */
--nv-space-8: 2rem; /* 32px */
--nv-space-10: 2.5rem; /* 40px */
--nv-space-12: 3rem; /* 48px */
--nv-space-14: 3.5rem; /* 56px */
--nv-space-16: 4rem; /* 64px */
--nv-space-20: 5rem; /* 80px */
--nv-space-24: 6rem; /* 96px */
--nv-space-32: 8rem; /* 128px */
/* --- Control sizing -------------------------------------------------------
Shared by every interactive control so a button, an input and a select
line up on the same row without per-component tuning. */
--nv-control-height-sm: 1.75rem; /* 28px */
--nv-control-height-md: 2.25rem; /* 36px */
--nv-control-height-lg: 2.75rem; /* 44px */
--nv-control-padding-sm: var(--nv-space-2-5);
--nv-control-padding-md: var(--nv-space-3);
--nv-control-padding-lg: var(--nv-space-4);
--nv-control-gap-sm: var(--nv-space-1-5);
--nv-control-gap-md: var(--nv-space-2);
--nv-control-gap-lg: var(--nv-space-2-5);
/* Minimum pointer target, independent of visual control height. */
--nv-target-min: 2.75rem; /* 44px */
/* --- Layout ---------------------------------------------------------------- */
--nv-layout-gutter: var(--nv-space-6);
--nv-layout-max-width: 72rem;
--nv-border-width: 1px;
--nv-border-width-strong: 2px;
}
}
@layer niveus.tokens {
/* ==========================================================================
Niveus — Radius
--------------------------------------------------------------------------
Corner softness carries as much of the Niveus character as shadow does.
Radii grow with the surface: controls stay modest, containers get rounder,
and only pills and avatars go fully round.
========================================================================== */
:root {
--nv-radius-none: 0;
--nv-radius-xs: 0.25rem; /* 4px — tags, swatches, nested chips */
--nv-radius-sm: 0.5rem; /* 8px — small controls, menu items */
--nv-radius-md: 0.75rem; /* 12px — buttons, inputs, default control radius */
--nv-radius-lg: 1rem; /* 16px — cards, panels */
--nv-radius-xl: 1.375rem; /* 22px — dialogs, sheets, large containers */
--nv-radius-2xl: 1.75rem; /* 28px — hero surfaces */
--nv-radius-full: 999rem; /* — pills, avatars, switches */
/* Nested radius: an inner surface inset by N should be N smaller than its
parent, or the corners read as off-center. */
--nv-radius-nested-md: calc(var(--nv-radius-lg) - var(--nv-space-2));
--nv-radius-nested-lg: calc(var(--nv-radius-xl) - var(--nv-space-3));
}
}
@layer niveus.tokens {
/* ==========================================================================
Niveus — Elevation
--------------------------------------------------------------------------
Depth is a scale, not a free choice. Each level describes one relationship
between a surface and what sits behind it:
0 flush painted on the canvas, no separation
1 resting cards, list rows, static panels
2 raised buttons, controls, anything that reacts to a pointer
3 floating popovers, menus, dropdowns, tooltips
4 lifted dialogs, sheets, detached panels
5 overlay the topmost layer, used sparingly
Every level is built from the same three shadow colors so depth stays
consistent between appearances: an ambient contact shadow that keeps the
edge crisp, a soft penumbra for the spread, and an umbra that only appears
once a surface is genuinely far from the canvas.
========================================================================== */
:root {
--nv-elevation-0: none;
--nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient),
0 1px 3px -1px var(--nv-color-shadow-penumbra);
--nv-elevation-2:
0 1px 1px var(--nv-color-shadow-ambient),
0 2px 4px -1px var(--nv-color-shadow-penumbra),
0 4px 8px -4px var(--nv-color-shadow-umbra);
--nv-elevation-3:
0 1px 1px var(--nv-color-shadow-ambient),
0 4px 8px -2px var(--nv-color-shadow-penumbra),
0 10px 20px -6px var(--nv-color-shadow-umbra);
--nv-elevation-4:
0 1px 2px var(--nv-color-shadow-ambient),
0 8px 16px -4px var(--nv-color-shadow-penumbra),
0 20px 40px -10px var(--nv-color-shadow-umbra);
--nv-elevation-5:
0 1px 2px var(--nv-color-shadow-ambient),
0 14px 28px -6px var(--nv-color-shadow-penumbra),
0 32px 64px -14px var(--nv-color-shadow-umbra);
/* --- Inset ---------------------------------------------------------------
Recessed surfaces: fields, wells, tracks, pressed controls. */
--nv-elevation-inset-sm: inset 0 1px 2px var(--nv-color-shadow-inset);
--nv-elevation-inset-md:
inset 0 1px 1px var(--nv-color-shadow-inset),
inset 0 2px 5px -1px var(--nv-color-shadow-inset);
--nv-elevation-inset-lg:
inset 0 2px 3px var(--nv-color-shadow-inset),
inset 0 6px 12px -3px var(--nv-color-shadow-inset);
/* --- Highlight -----------------------------------------------------------
The thin catch of light along the top edge of a raised surface. This is
what makes Niveus read as material rather than as a rectangle with a
shadow. Always paired with an elevation, never used alone. */
--nv-highlight-top: inset 0 1px 0 var(--nv-color-highlight);
--nv-highlight-top-strong: inset 0 1px 0 var(--nv-color-highlight-strong);
--nv-highlight-edge:
inset 0 1px 0 var(--nv-color-highlight),
inset 0 0 0 1px var(--nv-color-border-subtle);
/* --- Focus ---------------------------------------------------------------
A contrast ring sits under the accent ring so focus stays visible on both
light and dark surfaces, and on accent-filled controls. */
--nv-focus-ring-width: 3px;
--nv-focus-ring:
0 0 0 1px var(--nv-color-focus-ring-contrast),
0 0 0 calc(1px + var(--nv-focus-ring-width)) var(--nv-color-focus-ring);
}
}
@layer niveus.tokens {
/* ==========================================================================
Niveus — Motion
--------------------------------------------------------------------------
Motion explains state and spatial relationship. It is short enough to feel
immediate and eased enough to feel physical.
Rule of thumb: the smaller the element, the faster it moves. A button's
hover is 120ms; a dialog's entrance is 240ms.
========================================================================== */
:root {
/* --- Duration ------------------------------------------------------------ */
--nv-duration-instant: 0ms;
--nv-duration-fast: 120ms; /* hover, press, color change */
--nv-duration-quick: 160ms; /* small elements entering */
--nv-duration-normal: 220ms; /* popovers, menus, expansion */
--nv-duration-slow: 320ms; /* dialogs, sheets, large surfaces */
--nv-duration-slower: 480ms; /* full-screen transitions */
/* --- Easing ---------------------------------------------------------------
`standard` covers almost everything. Entrance decelerates into place, exit
accelerates away, and `spring` is reserved for confirmations — never for
anything a user triggers repeatedly. */
--nv-ease-standard: cubic-bezier(0.2, 0.05, 0, 1);
--nv-ease-entrance: cubic-bezier(0.05, 0.7, 0.1, 1);
--nv-ease-exit: cubic-bezier(0.3, 0, 0.8, 0.15);
--nv-ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
--nv-ease-linear: linear;
/* --- Continuity -------------------------------------------------------------
Durations for changes the user is meant to *follow*, not merely notice.
They are longer than an interaction response because the eye has further
to travel: emphasis crossing a navigation bar, a panel growing open, a
whole surface changing appearance. */
--nv-duration-travel: 260ms; /* emphasis moving from one element to another */
--nv-duration-expand: 280ms; /* content growing out of, or back into, place */
--nv-duration-theme: 400ms; /* the entire surface changing appearance */
/* Leaves quickly, lands softly. The right curve for something travelling a
distance the user is meant to follow with their eye. */
--nv-ease-emphasized: cubic-bezier(0.32, 0.04, 0.06, 1);
/* --- Composites ------------------------------------------------------------
`translate` and `scale` are listed rather than `transform`, because the
components animate the individual properties — a transition on `transform`
does not cover them, and the movement snaps. */
--nv-transition-colors:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-depth:
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-control:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard),
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
/* Containers: the same as depth, plus the colors, so a surface glides into
its new appearance when the theme changes instead of snapping. */
--nv-transition-surface:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard),
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-expand:
block-size var(--nv-duration-expand) var(--nv-ease-standard),
grid-template-rows var(--nv-duration-expand) var(--nv-ease-standard),
opacity var(--nv-duration-expand) var(--nv-ease-standard);
/* --- Displacement ----------------------------------------------------------
How far things move. Presses sink by 1px; surfaces enter from 8px away. */
--nv-lift-hover: -1px;
--nv-press-depth: 1px;
--nv-travel-sm: 4px;
--nv-travel-md: 8px;
--nv-travel-lg: 16px;
}
/* Reduced motion is a system-wide contract, not a per-component opt-in: cut
duration to near-zero and neutralise displacement, but keep transitions
alive so state changes still resolve rather than snapping mid-flight. */
@media (prefers-reduced-motion: reduce) {
:root {
--nv-duration-fast: 1ms;
--nv-duration-quick: 1ms;
--nv-duration-normal: 1ms;
--nv-duration-slow: 1ms;
--nv-duration-slower: 1ms;
--nv-duration-travel: 1ms;
--nv-duration-expand: 1ms;
--nv-duration-theme: 1ms;
--nv-ease-spring: var(--nv-ease-standard);
--nv-lift-hover: 0px;
--nv-press-depth: 0px;
--nv-travel-sm: 0px;
--nv-travel-md: 0px;
--nv-travel-lg: 0px;
}
}
}
@layer niveus.tokens {
/* ==========================================================================
Niveus — Materials
--------------------------------------------------------------------------
A material is a surface treatment: what it is made of, not where it sits.
Elevation answers "how far from the canvas"; material answers "what does
light do when it hits this".
solid opaque, stable. The default. Content lives here.
elevated solid with depth and a top highlight. Separated from context.
inset recessed into its parent. Fields, wells, tracks.
frosted translucent, heavily blurred. Chrome that sits over content:
navigation bars, toolbars, sticky headers.
glass more translucent, less blurred. Contextual layers where the
content behind should stay legible: overlays, hover cards.
Translucency has a cost — it tints whatever is behind it and forces the GPU
to composite. Use frosted and glass for layers that move over content, not
for content itself.
========================================================================== */
:root {
/* --- Blur ---------------------------------------------------------------- */
--nv-blur-sm: 8px;
--nv-blur-md: 16px;
--nv-blur-lg: 28px;
--nv-blur-xl: 44px;
/* Backdrops lose saturation when blurred; a touch of saturate() puts the
color back so frosted surfaces do not read as gray films. */
--nv-backdrop-saturate: 180%;
--nv-backdrop-frosted: blur(var(--nv-blur-lg)) saturate(var(--nv-backdrop-saturate));
--nv-backdrop-glass: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
--nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(120%);
/* --- Material recipes -----------------------------------------------------
Each recipe is the full set of properties that defines the material.
Applied by the .nv-material-* classes in components/surface.css. */
--nv-material-solid-bg: var(--nv-color-surface);
--nv-material-solid-border: var(--nv-color-border);
--nv-material-solid-shadow: var(--nv-elevation-0);
--nv-material-elevated-bg: var(--nv-color-surface-elevated);
--nv-material-elevated-border: var(--nv-color-border-subtle);
--nv-material-elevated-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
--nv-material-inset-bg: var(--nv-color-surface-inset);
--nv-material-inset-border: var(--nv-color-border-subtle);
--nv-material-inset-shadow: var(--nv-elevation-inset-md);
--nv-material-frosted-bg: var(--nv-color-material-frosted);
--nv-material-frosted-border: var(--nv-color-material-border);
--nv-material-frosted-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
--nv-material-glass-bg: var(--nv-color-material-glass);
--nv-material-glass-border: var(--nv-color-material-border);
--nv-material-glass-shadow: var(--nv-highlight-top-strong), var(--nv-elevation-4);
}
/* Where translucency cannot be honoured — reduced transparency, or a browser
without backdrop-filter — materials fall back to their solid equivalents
rather than becoming unreadable washes. */
@media (prefers-reduced-transparency: reduce) {
:root {
--nv-material-frosted-bg: var(--nv-color-surface-elevated);
--nv-material-glass-bg: var(--nv-color-surface-elevated);
--nv-backdrop-frosted: none;
--nv-backdrop-glass: none;
--nv-backdrop-scrim: none;
}
}
@supports not (backdrop-filter: blur(1px)) {
:root {
--nv-material-frosted-bg: var(--nv-color-surface-elevated);
--nv-material-glass-bg: var(--nv-color-surface-elevated);
}
}
}
/* --- Themes: what those values become in each appearance -------------------- */
@layer niveus.theme {
/* ==========================================================================
Niveus — Light appearance
--------------------------------------------------------------------------
Values for the light side of every semantic role. Nothing here is consumed
by components directly; tokens/color.css resolves these through light-dark().
Almost nothing below is a literal color. Each role is *derived* from
--nv-brand, so setting one value re-colours the entire system: the accent
and its states, what text goes on top of it, how the status colors lean, and
how much of the brand bleeds into the greys. See tokens/color.css for the
inputs.
Light Niveus reads as paper on a cool, slightly recessed canvas: surfaces are
near-white, depth comes from soft shadow rather than from contrast, and a
crisp top highlight gives raised elements their edge.
========================================================================== */
:root {
/* Follow the environment unless the application states an appearance. */
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
/* [data-nv-brand] re-derives the whole set for a subtree, so one region can
carry a different brand without a second theme. */
:root,
[data-nv-brand] {
/* --- Canvas and surfaces -----------------------------------------------
Paper stays paper: the two surfaces that hold content are left untinted so
text keeps its contrast. Everything the surfaces sit *in* picks up a trace
of the brand, which is what stops an accent from looking stuck on. */
--nv-light-background: color-mix(in oklab, #e9ecf3, var(--nv-brand) var(--nv-brand-tint));
--nv-light-background-subtle: color-mix(in oklab, #eff1f6, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface: var(--nv-palette-neutral-0);
--nv-light-surface-elevated: var(--nv-palette-neutral-0);
--nv-light-surface-inset: color-mix(in oklab, #e4e8f0, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-hover: color-mix(in oklab, #f4f6fa, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-active: color-mix(in oklab, #e8ebf2, var(--nv-brand) var(--nv-brand-tint));
--nv-light-scrim: rgb(16 21 30 / 0.32);
/* --- Text ---------------------------------------------------------------
Primary text stays neutral — it carries the reading contrast. The quieter
two lean towards the brand, where a tint reads as warmth rather than as a
loss of legibility. */
--nv-light-text-primary: var(--nv-palette-neutral-900);
--nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%);
--nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%);
--nv-light-text-disabled: var(--nv-palette-neutral-400);
--nv-light-text-inverted: var(--nv-palette-neutral-0);
/* --- Borders ------------------------------------------------------------ */
--nv-light-border-subtle: rgb(16 21 30 / 0.06);
--nv-light-border: rgb(16 21 30 / 0.11);
--nv-light-border-strong: rgb(16 21 30 / 0.2);
/* --- Accent --------------------------------------------------------------
Every filled color is pushed away from the middle of the lightness range
before it is used. A mid-tone is the one place where neither white nor
black text reaches 4.5:1, so a brand that lands there is darkened until
white works — or, if it is already light, lightened until black does. The
brand's hue and chroma are untouched; only its lightness moves, and only
as far as it has to. */
--nv-light-accent: oklch(from var(--nv-brand) calc(
clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
(1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
) clamp(0.02, c, 0.25) h);
--nv-light-accent-hover: oklch(from var(--nv-light-accent) calc(l - 0.045) c h);
--nv-light-accent-active: oklch(from var(--nv-light-accent) calc(l - 0.09) c h);
--nv-light-accent-subtle: oklch(from var(--nv-light-accent) 0.965 calc(c * 0.16) h);
--nv-light-accent-subtle-hover: oklch(from var(--nv-light-accent) 0.93 calc(c * 0.22) h);
--nv-light-accent-border: oklch(from var(--nv-light-accent) l c h / 0.32);
--nv-light-accent-text: oklch(from var(--nv-light-accent) clamp(0.3, calc(l - 0.12), 0.5) c h);
/* Text that lands on the color itself. The clamp is a step function: below
the lightness threshold it resolves to 1 and the text goes near-white,
above it to 0 and the text goes near-black — so a yellow brand gets dark
labels and a navy one gets light labels, with no second token to set. */
--nv-light-on-accent: oklch(from var(--nv-light-accent)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Success ------------------------------------------------------------- */
--nv-light-success: oklch(from
color-mix(in oklab, var(--nv-palette-success-500), var(--nv-brand) var(--nv-brand-harmony))
calc(
clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
(1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
) clamp(0.02, c, 0.25) h);
--nv-light-success-hover: oklch(from var(--nv-light-success) calc(l - 0.05) c h);
--nv-light-success-subtle: oklch(from var(--nv-light-success) 0.965 calc(c * 0.16) h);
--nv-light-success-border: oklch(from var(--nv-light-success) l c h / 0.3);
--nv-light-success-text: oklch(from var(--nv-light-success) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-success: oklch(from var(--nv-light-success)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Warning -------------------------------------------------------------- */
/* Amber starts above the pivot on purpose. Pushed the other way it lands as
brown, which reads as neither a warning nor a color anyone chose. */
--nv-light-warning: oklch(from
color-mix(in oklab, var(--nv-palette-warning-400), var(--nv-brand) var(--nv-brand-harmony))
calc(
clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
(1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
) clamp(0.02, c, 0.25) h);
--nv-light-warning-hover: oklch(from var(--nv-light-warning) calc(l - 0.05) c h);
--nv-light-warning-subtle: oklch(from var(--nv-light-warning) 0.965 calc(c * 0.16) h);
--nv-light-warning-border: oklch(from var(--nv-light-warning) l c h / 0.32);
--nv-light-warning-text: oklch(from var(--nv-light-warning) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-warning: oklch(from var(--nv-light-warning)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Danger ---------------------------------------------------------------- */
--nv-light-danger: oklch(from
color-mix(in oklab, var(--nv-palette-danger-500), var(--nv-brand) var(--nv-brand-harmony))
calc(
clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
(1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
) clamp(0.02, c, 0.25) h);
--nv-light-danger-hover: oklch(from var(--nv-light-danger) calc(l - 0.05) c h);
--nv-light-danger-active: oklch(from var(--nv-light-danger) calc(l - 0.1) c h);
--nv-light-danger-subtle: oklch(from var(--nv-light-danger) 0.965 calc(c * 0.16) h);
--nv-light-danger-border: oklch(from var(--nv-light-danger) l c h / 0.3);
--nv-light-danger-text: oklch(from var(--nv-light-danger) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-danger: oklch(from var(--nv-light-danger)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Depth and material -------------------------------------------------
Three shadow colors build every elevation level: an ambient contact
shadow, a soft penumbra, and a deeper umbra for high elevations. */
--nv-light-shadow-ambient: rgb(16 21 30 / 0.05);
--nv-light-shadow-penumbra: rgb(16 21 30 / 0.08);
--nv-light-shadow-umbra: rgb(16 21 30 / 0.06);
--nv-light-shadow-inset: rgb(16 21 30 / 0.12);
--nv-light-highlight: rgb(255 255 255 / 0.85);
--nv-light-highlight-strong: rgb(255 255 255 / 1);
/* Light surfaces are already lighter than their canvas; the shadow alone
carries elevation, so there is nothing to add. */
--nv-light-elevation-tint-1: transparent;
--nv-light-elevation-tint-2: transparent;
--nv-light-elevation-tint-3: transparent;
--nv-light-elevation-tint-4: transparent;
--nv-light-elevation-tint-5: transparent;
--nv-light-material-frosted: rgb(255 255 255 / 0.72);
--nv-light-material-glass: rgb(255 255 255 / 0.5);
--nv-light-material-border: rgb(255 255 255 / 0.55);
/* --- Interaction ----------------------------------------------------------- */
--nv-light-focus-ring: oklch(from var(--nv-light-accent) l c h / 0.4);
--nv-light-focus-ring-contrast: rgb(255 255 255 / 0.9);
--nv-light-selection: oklch(from var(--nv-light-accent) l c h / 0.2);
}
}
@layer niveus.theme {
/* ==========================================================================
Niveus — Dark appearance
--------------------------------------------------------------------------
Values for the dark side of every semantic role, resolved through
light-dark() in tokens/color.css. Like the light appearance, almost nothing
here is a literal color — every role is derived from --nv-brand.
Dark Niveus is not the light theme inverted. Surfaces get *lighter* as they
rise, shadows deepen rather than disappear, and the top highlight thins to a
faint rim so elevation still reads without glare. The accent moves up its
own ramp too: a mid-tone that reads as vivid on paper reads as muddy on a
dark canvas.
========================================================================== */
:root[data-theme="dark"] {
color-scheme: dark;
}
:root,
[data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */
--nv-dark-background: color-mix(in oklab, #0b0e14, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-background-subtle: color-mix(in oklab, #10141b, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface: color-mix(in oklab, #171c24, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-elevated: color-mix(in oklab, #1e242e, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-inset: color-mix(in oklab, #10141b, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-hover: color-mix(in oklab, #222835, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-active: color-mix(in oklab, #2a3140, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-scrim: rgb(3 5 9 / 0.62);
/* --- Text --------------------------------------------------------------- */
--nv-dark-text-primary: #e9edf4;
--nv-dark-text-secondary: color-mix(in oklab, #a3aebf, var(--nv-brand) 8%);
--nv-dark-text-tertiary: color-mix(in oklab, #7a8494, var(--nv-brand) 8%);
--nv-dark-text-disabled: #58616f;
--nv-dark-text-inverted: var(--nv-palette-neutral-975);
/* --- Borders ------------------------------------------------------------ */
--nv-dark-border-subtle: rgb(255 255 255 / 0.06);
--nv-dark-border: rgb(255 255 255 / 0.1);
--nv-dark-border-strong: rgb(255 255 255 / 0.2);
/* --- Accent -------------------------------------------------------------
Lifted up the ramp and slightly desaturated: saturated mid-tones read as
muddy on dark canvases, and fully saturated light ones read as neon. The
floor also puts every filled color above the pivot, so on a dark canvas
the derived label is always the dark one — which is what makes an accent
button read as lit rather than as a hole. */
--nv-dark-accent: oklch(from var(--nv-brand) clamp(0.72, calc(l + 0.12), 0.86) clamp(0.02, calc(c * 0.92), 0.2) h);
--nv-dark-accent-hover: oklch(from var(--nv-dark-accent) calc(l + 0.05) c h);
--nv-dark-accent-active: oklch(from var(--nv-dark-accent) calc(l + 0.09) c h);
--nv-dark-accent-subtle: oklch(from var(--nv-dark-accent) l c h / 0.15);
--nv-dark-accent-subtle-hover: oklch(from var(--nv-dark-accent) l c h / 0.22);
--nv-dark-accent-border: oklch(from var(--nv-dark-accent) l c h / 0.38);
--nv-dark-accent-text: oklch(from var(--nv-dark-accent) clamp(0.72, calc(l + 0.08), 0.9) calc(c * 0.9) h);
--nv-dark-on-accent: oklch(from var(--nv-dark-accent)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Success ------------------------------------------------------------ */
--nv-dark-success: oklch(from
color-mix(in oklab, var(--nv-palette-success-400), var(--nv-brand) var(--nv-brand-harmony))
clamp(0.72, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
--nv-dark-success-hover: oklch(from var(--nv-dark-success) calc(l + 0.05) c h);
--nv-dark-success-subtle: oklch(from var(--nv-dark-success) l c h / 0.15);
--nv-dark-success-border: oklch(from var(--nv-dark-success) l c h / 0.35);
--nv-dark-success-text: oklch(from var(--nv-dark-success) clamp(0.74, calc(l + 0.08), 0.9) calc(c * 0.9) h);
--nv-dark-on-success: oklch(from var(--nv-dark-success)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Warning ------------------------------------------------------------ */
--nv-dark-warning: oklch(from
color-mix(in oklab, var(--nv-palette-warning-400), var(--nv-brand) var(--nv-brand-harmony))
clamp(0.74, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
--nv-dark-warning-hover: oklch(from var(--nv-dark-warning) calc(l + 0.05) c h);
--nv-dark-warning-subtle: oklch(from var(--nv-dark-warning) l c h / 0.15);
--nv-dark-warning-border: oklch(from var(--nv-dark-warning) l c h / 0.35);
--nv-dark-warning-text: oklch(from var(--nv-dark-warning) clamp(0.76, calc(l + 0.08), 0.9) calc(c * 0.9) h);
--nv-dark-on-warning: oklch(from var(--nv-dark-warning)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Danger ------------------------------------------------------------- */
--nv-dark-danger: oklch(from
color-mix(in oklab, var(--nv-palette-danger-400), var(--nv-brand) var(--nv-brand-harmony))
clamp(0.72, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
--nv-dark-danger-hover: oklch(from var(--nv-dark-danger) calc(l + 0.05) c h);
--nv-dark-danger-active: oklch(from var(--nv-dark-danger) calc(l + 0.09) c h);
--nv-dark-danger-subtle: oklch(from var(--nv-dark-danger) l c h / 0.15);
--nv-dark-danger-border: oklch(from var(--nv-dark-danger) l c h / 0.35);
--nv-dark-danger-text: oklch(from var(--nv-dark-danger) clamp(0.74, calc(l + 0.08), 0.9) calc(c * 0.9) h);
--nv-dark-on-danger: oklch(from var(--nv-dark-danger)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Depth and material -------------------------------------------------- */
--nv-dark-shadow-ambient: rgb(0 0 0 / 0.3);
--nv-dark-shadow-penumbra: rgb(0 0 0 / 0.4);
--nv-dark-shadow-umbra: rgb(0 0 0 / 0.35);
--nv-dark-shadow-inset: rgb(0 0 0 / 0.5);
--nv-dark-highlight: rgb(255 255 255 / 0.07);
--nv-dark-highlight-strong: rgb(255 255 255 / 0.12);
/* The higher a surface sits, the more light reaches it. */
--nv-dark-elevation-tint-1: rgb(255 255 255 / 0.025);
--nv-dark-elevation-tint-2: rgb(255 255 255 / 0.042);
--nv-dark-elevation-tint-3: rgb(255 255 255 / 0.06);
--nv-dark-elevation-tint-4: rgb(255 255 255 / 0.078);
--nv-dark-elevation-tint-5: rgb(255 255 255 / 0.095);
--nv-dark-material-frosted: color-mix(in oklab, rgb(23 28 36 / 0.72), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-material-glass: color-mix(in oklab, rgb(30 36 46 / 0.55), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-material-border: rgb(255 255 255 / 0.1);
/* --- Interaction ---------------------------------------------------------- */
--nv-dark-focus-ring: oklch(from var(--nv-dark-accent) l c h / 0.5);
--nv-dark-focus-ring-contrast: rgb(6 8 12 / 0.9);
--nv-dark-selection: oklch(from var(--nv-dark-accent) l c h / 0.3);
}
}
/* --- Base: elements, before any component ----------------------------------- */
@layer niveus.base {
/* ==========================================================================
Niveus — Base
--------------------------------------------------------------------------
The minimum an application needs before any component renders: a predictable
box model, the canvas, the default type, and the system-wide focus and
selection treatment.
This layer styles elements, never classes with intent. Anything opinionated
enough to need a name belongs in components/.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100svh;
background-color: var(--nv-color-background);
color: var(--nv-color-text-primary);
font: var(--nv-font-body);
letter-spacing: var(--nv-letter-spacing-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-synthesis-weight: none;
/* The canvas glides into a new appearance with everything standing on it,
rather than cutting while the surfaces above it fade. */
transition: var(--nv-transition-colors);
}
/* --- Rhythm ---------------------------------------------------------------
Block elements carry no outer margin of their own; spacing between them is
the layout's decision, set with gap or the spacing tokens. */
h1, h2, h3, h4, h5, h6,
p, figure, blockquote, dl, dd, pre {
margin: 0;
}
h1, h2, h3, h4, h5, h6 {
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-tight);
line-height: var(--nv-line-height-snug);
text-wrap: balance;
}
h1 { font-size: var(--nv-font-size-3xl); letter-spacing: var(--nv-letter-spacing-tighter); }
h2 { font-size: var(--nv-font-size-2xl); }
h3 { font-size: var(--nv-font-size-xl); }
h4 { font-size: var(--nv-font-size-lg); }
h5 { font-size: var(--nv-font-size-md); }
h6 { font-size: var(--nv-font-size-sm); }
p {
text-wrap: pretty;
}
small {
font-size: var(--nv-font-size-xs);
}
code, kbd, samp, pre {
font-family: var(--nv-font-family-mono);
font-size: var(--nv-font-size-sm);
}
/* --- Links ----------------------------------------------------------------- */
a {
color: var(--nv-color-accent-text);
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
text-underline-offset: 0.18em;
transition: var(--nv-transition-colors);
}
a:hover {
text-decoration-color: currentColor;
}
/* --- Media ----------------------------------------------------------------- */
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
max-width: 100%;
}
svg {
flex: none;
}
hr {
border: 0;
border-top: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin: 0;
}
/* --- Forms -----------------------------------------------------------------
Form controls do not inherit type by default; without this every field in
the system would silently fall back to the browser's own font. */
button, input, optgroup, select, textarea {
font: inherit;
color: inherit;
margin: 0;
}
textarea {
resize: vertical;
}
:disabled {
cursor: not-allowed;
}
/* --- Focus -----------------------------------------------------------------
One focus treatment for the whole system. Components that need the ring to
follow their own shape override box-shadow, not outline-color. */
:focus-visible {
outline: var(--nv-focus-ring-width) solid var(--nv-color-focus-ring);
outline-offset: 2px;
border-radius: var(--nv-radius-xs);
}
::selection {
background-color: var(--nv-color-selection);
color: var(--nv-color-text-primary);
}
/* --- Scrollbars ------------------------------------------------------------ */
* {
scrollbar-color: var(--nv-color-border-strong) transparent;
scrollbar-width: thin;
}
/* ==========================================================================
Type roles
--------------------------------------------------------------------------
Named roles from tokens/typography.css, available as classes so hierarchy
stays consistent even when the markup cannot use a heading element.
========================================================================== */
.nv-display {
font: var(--nv-font-display);
letter-spacing: var(--nv-letter-spacing-tighter);
text-wrap: balance;
}
.nv-heading {
font: var(--nv-font-heading);
letter-spacing: var(--nv-letter-spacing-tight);
text-wrap: balance;
}
.nv-title {
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
}
.nv-body {
font: var(--nv-font-body);
text-wrap: pretty;
}
.nv-label {
font: var(--nv-font-label);
}
.nv-caption {
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
}
.nv-overline {
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
.nv-code {
font: var(--nv-font-code);
}
/* Text color roles, for when the role is about emphasis rather than size. */
.nv-text-primary { color: var(--nv-color-text-primary); }
.nv-text-secondary { color: var(--nv-color-text-secondary); }
.nv-text-tertiary { color: var(--nv-color-text-tertiary); }
.nv-text-accent { color: var(--nv-color-accent-text); }
.nv-text-success { color: var(--nv-color-success-text); }
.nv-text-warning { color: var(--nv-color-warning-text); }
.nv-text-danger { color: var(--nv-color-danger-text); }
}
/* --- Continuity: how one state becomes the next ----------------------------- */
@layer niveus.continuity {
/* ==========================================================================
Niveus — Continuity
--------------------------------------------------------------------------
Every interaction should read as a continuation of the previous state, not
as a replacement of it. When something moves, grows, collapses or changes
appearance, the change itself should be perceptible: where it came from,
where it went, and why.
This layer holds the cross-cutting machinery that individual components
then opt into:
· one duration and easing for everything the browser animates between
two states via a view transition
· emphasis that travels between elements instead of disappearing from one
and reappearing on another (--nv-selection-name)
· direction, so moving forward and moving back do not look identical
· appearance changes that cross-fade rather than cut
The goal is not to animate everything. It is that nothing pops into a new
state the user could have watched it reach.
========================================================================== */
:root {
/* Lets block-size: auto participate in transitions, which is what allows
content to expand from where it already is instead of appearing at full
height. Without it, every disclosure in the system would jump. */
interpolate-size: allow-keywords;
/* The default name for travelling emphasis. A page with more than one
selection group must give each group its own name — two visible elements
sharing a view-transition-name abort the transition entirely. */
--nv-selection-name: nv-selection;
}
/* --- View transitions -------------------------------------------------------
Anything the application wraps in document.startViewTransition() inherits
the system's travel duration and easing, so a state change animates like
the rest of Niveus without the application choosing timings. */
::view-transition-group(*) {
animation-duration: var(--nv-duration-travel);
animation-timing-function: var(--nv-ease-emphasized);
}
/* The page behind a travelling element cross-fades quickly — it is context,
not the subject of the change, and a slow fade would make the whole
interface feel as though it were being replaced. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: var(--nv-duration-quick);
animation-timing-function: var(--nv-ease-standard);
}
/* --- Travelling emphasis -------------------------------------------------------
A selection marker moves between two elements, which no single element can
transition — so it is captured and interpolated. The problem that creates is
layering: transition layers paint above the page snapshot, so a marker that
sits *behind* its label at rest would slide over the top of every label it
passed, washing the text as it went.
The fix is to capture the labels too and order the groups explicitly. The
marker travels below; the items ride above it, exactly as they do at rest.
niveus.js assigns each item its name — they have to be unique per document,
which no stylesheet can guarantee — and items it has not named simply stay in
the page snapshot.
Because the layering is now correct, the marker needs no trick to stay out of
the way: it travels at full opacity, like the solid object it is. */
::view-transition-group(.nv-travelling) {
z-index: 0;
}
::view-transition-group(.nv-carried) {
z-index: 1;
}
/* A carried item does not move — only its color changes when selection leaves
or arrives. That resolves quickly so the label does not sit half-way between
two colors for the length of the travel. */
::view-transition-old(.nv-carried),
::view-transition-new(.nv-carried) {
animation-duration: var(--nv-duration-fast);
}
/* --- Appearance ---------------------------------------------------------------
A theme change is the one moment the entire surface is the subject, so the
root cross-fade slows down and every color arrives together. The attribute
is set for the duration of the change by nv.setTheme(). */
:root[data-nv-transition="theme"]::view-transition-old(root),
:root[data-nv-transition="theme"]::view-transition-new(root) {
animation-duration: var(--nv-duration-theme);
animation-timing-function: var(--nv-ease-standard);
}
/* Without the view-transition path — no script, or a browser without the API —
surfaces still glide, because their own transitions cover color. */
/* --- Direction ------------------------------------------------------------------
Moving forward and moving back should not look the same. The application
sets data-nv-direction on the root for the length of the transition; content
then leaves towards where the user came from and enters from where they are
going. Without a direction, content simply rises into place. */
::view-transition-old(nv-content) {
animation: nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both;
}
::view-transition-new(nv-content) {
animation:
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
nv-rise var(--nv-duration-normal) var(--nv-ease-entrance) both;
}
:root[data-nv-direction="forward"]::view-transition-old(nv-content) {
animation:
nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both,
nv-exit-to-start var(--nv-duration-normal) var(--nv-ease-emphasized) both;
}
:root[data-nv-direction="forward"]::view-transition-new(nv-content) {
animation:
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
nv-enter-from-end var(--nv-duration-normal) var(--nv-ease-emphasized) both;
}
:root[data-nv-direction="backward"]::view-transition-old(nv-content) {
animation:
nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both,
nv-exit-to-end var(--nv-duration-normal) var(--nv-ease-emphasized) both;
}
:root[data-nv-direction="backward"]::view-transition-new(nv-content) {
animation:
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
nv-enter-from-start var(--nv-duration-normal) var(--nv-ease-emphasized) both;
}
@keyframes nv-fade-in {
from { opacity: 0; }
}
@keyframes nv-fade-out {
to { opacity: 0; }
}
@keyframes nv-rise {
from { translate: 0 var(--nv-travel-sm); }
}
@keyframes nv-enter-from-end {
from { translate: var(--nv-travel-lg) 0; }
}
@keyframes nv-enter-from-start {
from { translate: calc(var(--nv-travel-lg) * -1) 0; }
}
@keyframes nv-exit-to-start {
to { translate: calc(var(--nv-travel-md) * -1) 0; }
}
@keyframes nv-exit-to-end {
to { translate: var(--nv-travel-md) 0; }
}
/* --- Utilities ---------------------------------------------------------------------
.nv-continuous marks a region whose content should cross-fade in place when
it changes — the "same surface, different content" relationship. */
.nv-continuous {
view-transition-name: var(--nv-content-name, nv-content);
}
/* Collapsible region for markup that is not a <details>. The 0fr -> 1fr grid
row is the one technique that animates to an unknown content height in every
browser, with no measurement and no script. */
.nv-collapse {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows var(--nv-duration-expand) var(--nv-ease-standard),
opacity var(--nv-duration-expand) var(--nv-ease-standard);
opacity: 0;
}
.nv-collapse > * {
min-block-size: 0;
overflow: hidden;
}
.nv-collapse[data-open],
.nv-collapse:not([hidden])[open] {
grid-template-rows: 1fr;
opacity: 1;
}
/* Reduced motion does not mean "no continuity" — a state change still has to
resolve rather than be cut mid-way. The token layer already collapses every
duration to 1ms; this removes the displacement so nothing slides. */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 1ms;
}
}
}
/* --- Components -------------------------------------------------------------- */
@layer niveus.components {
/* ==========================================================================
Niveus — Surface
--------------------------------------------------------------------------
The material vocabulary, expressed as classes. Every other component is
built from these; applications use them directly for anything the system
does not ship as a named component.
.nv-surface a container using the solid material
.nv-material-{type} apply a material to any element
.nv-elevation-{0..5} apply a depth level to any element
A material and an elevation are independent: .nv-material-solid
.nv-elevation-3 is a legitimate combination. What is not legitimate is a
raw box-shadow that belongs to neither scale.
========================================================================== */
.nv-surface {
background-color: var(--nv-color-surface);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: var(--nv-radius-lg);
transition: var(--nv-transition-surface);
}
/* --- Materials ------------------------------------------------------------- */
.nv-material-solid {
background-color: var(--nv-material-solid-bg);
border: var(--nv-border-width) solid var(--nv-material-solid-border);
box-shadow: var(--nv-material-solid-shadow);
backdrop-filter: none;
}
.nv-material-elevated {
background-color: var(--nv-material-elevated-bg);
border: var(--nv-border-width) solid var(--nv-material-elevated-border);
box-shadow: var(--nv-material-elevated-shadow);
backdrop-filter: none;
}
.nv-material-inset {
background-color: var(--nv-material-inset-bg);
border: var(--nv-border-width) solid var(--nv-material-inset-border);
box-shadow: var(--nv-material-inset-shadow);
backdrop-filter: none;
}
.nv-material-frosted {
background-color: var(--nv-material-frosted-bg);
border: var(--nv-border-width) solid var(--nv-material-frosted-border);
box-shadow: var(--nv-material-frosted-shadow);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
.nv-material-glass {
background-color: var(--nv-material-glass-bg);
border: var(--nv-border-width) solid var(--nv-material-glass-border);
box-shadow: var(--nv-material-glass-shadow);
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Elevation -------------------------------------------------------------
Elevation utilities carry three things at once, because a lifted surface is
never only a shadow: the shadow itself, the highlight along its top edge,
and — on a dark canvas, where a shadow has nothing left to darken — a tint
that lets more light reach the surface the higher it sits. The tint is
transparent in light appearance, so the two read as one scale. */
.nv-elevation-0 { box-shadow: var(--nv-elevation-0); }
.nv-elevation-1 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
background-image: linear-gradient(var(--nv-color-elevation-tint-1) 0 0);
}
.nv-elevation-2 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
background-image: linear-gradient(var(--nv-color-elevation-tint-2) 0 0);
}
.nv-elevation-3 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
background-image: linear-gradient(var(--nv-color-elevation-tint-3) 0 0);
}
.nv-elevation-4 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-4);
background-image: linear-gradient(var(--nv-color-elevation-tint-4) 0 0);
}
.nv-elevation-5 {
box-shadow: var(--nv-highlight-top-strong), var(--nv-elevation-5);
background-image: linear-gradient(var(--nv-color-elevation-tint-5) 0 0);
}
.nv-elevation-inset-sm { box-shadow: var(--nv-elevation-inset-sm); }
.nv-elevation-inset-md { box-shadow: var(--nv-elevation-inset-md); }
.nv-elevation-inset-lg { box-shadow: var(--nv-elevation-inset-lg); }
/* --- Divider ---------------------------------------------------------------- */
.nv-divider {
border: 0;
border-top: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block: var(--nv-space-4);
}
.nv-divider--vertical {
border-top: 0;
border-left: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block: 0;
margin-inline: var(--nv-space-4);
align-self: stretch;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Button
--------------------------------------------------------------------------
.nv-button secondary by default — a raised surface
.nv-button--primary the single affirmative action in a view
.nv-button--ghost low-emphasis, no material until hovered
.nv-button--danger destructive, and only when it is
.nv-button--frosted for use over imagery or frosted chrome
.nv-button--sm | --lg 36px by default
.nv-button--icon square, for a lone icon (needs a label)
.nv-button--block fills its container
Every variant is defined purely by reassigning the private --_* properties
below. Interaction — the lift on hover, the sink on press, the focus ring —
lives once in the base rule, so a new variant can never drift from the way
the rest of the system responds to a pointer.
========================================================================== */
.nv-button {
/* Variant surface, overridden per modifier. */
--_bg: var(--nv-color-surface-elevated);
--_bg-hover: var(--nv-color-surface-hover);
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-primary);
--_border: var(--nv-color-border);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
--_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-3);
--_shadow-active: var(--nv-elevation-inset-sm);
/* Size, overridden per size modifier. */
--_height: var(--nv-control-height-md);
--_padding: var(--nv-control-padding-md);
--_gap: var(--nv-control-gap-md);
--_font-size: var(--nv-font-size-sm);
--_radius: var(--nv-radius-md);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--_gap);
block-size: var(--_height);
padding-inline: var(--_padding);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--_radius);
background-color: var(--_bg);
color: var(--_fg);
box-shadow: var(--_shadow);
font-family: var(--nv-font-family-sans);
font-size: var(--_font-size);
font-weight: var(--nv-font-weight-medium);
line-height: var(--nv-line-height-snug);
letter-spacing: var(--nv-letter-spacing-normal);
white-space: nowrap;
text-decoration: none;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: var(--nv-transition-control);
}
/* A control smaller than the minimum target still gets a full-size hit area:
the pseudo-element grows outward without changing the visual size. */
.nv-button::after {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
translate: -50% -50%;
inline-size: max(100%, var(--nv-target-min));
block-size: max(100%, var(--nv-target-min));
}
@media (hover: hover) {
.nv-button:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-hover);
box-shadow: var(--_shadow-hover);
translate: 0 var(--nv-lift-hover);
}
}
.nv-button:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-active);
box-shadow: var(--_shadow-active);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
}
.nv-button:focus-visible {
outline: none;
box-shadow: var(--_shadow), var(--nv-focus-ring);
}
.nv-button:disabled,
.nv-button[aria-disabled="true"] {
background-color: var(--nv-color-surface-inset);
color: var(--nv-color-text-disabled);
border-color: var(--nv-color-border-subtle);
box-shadow: none;
translate: none;
cursor: not-allowed;
}
/* --- Variants --------------------------------------------------------------- */
.nv-button--primary {
--_bg: var(--nv-color-accent);
--_bg-hover: var(--nv-color-accent-hover);
--_bg-active: var(--nv-color-accent-active);
--_fg: var(--nv-color-on-accent);
--_border: transparent;
font-weight: var(--nv-font-weight-semibold);
}
.nv-button--danger {
--_bg: var(--nv-color-danger);
--_bg-hover: var(--nv-color-danger-hover);
--_bg-active: var(--nv-color-danger-active);
--_fg: var(--nv-color-on-danger);
--_border: transparent;
--nv-color-focus-ring: var(--nv-color-danger-border);
font-weight: var(--nv-font-weight-semibold);
}
.nv-button--ghost {
--_bg: transparent;
--_bg-hover: var(--nv-color-surface-hover);
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-secondary);
--_border: transparent;
--_shadow: none;
--_shadow-hover: none;
--_shadow-active: var(--nv-elevation-inset-sm);
}
@media (hover: hover) {
.nv-button--ghost:hover:not(:disabled, [aria-disabled="true"]) {
color: var(--nv-color-text-primary);
translate: none; /* nothing to lift — a ghost has no material to raise */
}
}
.nv-button--subtle {
--_bg: var(--nv-color-accent-subtle);
--_bg-hover: var(--nv-color-accent-subtle-hover);
--_bg-active: var(--nv-color-accent-subtle-hover);
--_fg: var(--nv-color-accent-text);
--_border: var(--nv-color-accent-border);
--_shadow: none;
--_shadow-hover: var(--nv-elevation-1);
}
.nv-button--frosted {
--_bg: var(--nv-material-glass-bg);
--_bg-hover: var(--nv-material-frosted-bg);
--_bg-active: var(--nv-material-frosted-bg);
--_border: var(--nv-material-glass-border);
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Sizes ------------------------------------------------------------------- */
.nv-button--sm {
--_height: var(--nv-control-height-sm);
--_padding: var(--nv-control-padding-sm);
--_gap: var(--nv-control-gap-sm);
--_font-size: var(--nv-font-size-xs);
--_radius: var(--nv-radius-sm);
}
.nv-button--lg {
--_height: var(--nv-control-height-lg);
--_padding: var(--nv-control-padding-lg);
--_gap: var(--nv-control-gap-lg);
--_font-size: var(--nv-font-size-md);
}
.nv-button--icon {
--_padding: 0;
inline-size: var(--_height);
}
.nv-button--pill {
--_radius: var(--nv-radius-full);
}
.nv-button--block {
display: flex;
inline-size: 100%;
}
/* --- Floating ------------------------------------------------------------------
A control that sits *over* content rather than in the flow: a carousel arrow,
a floating action. Two things change.
It is already off the surface, so there is nothing to lift it from — it grows
instead. And it is almost always positioned with `translate`, which the hover
lift would overwrite, throwing the button half its own height down the page
the moment a pointer touched it. --nv-button-offset carries that positioning
through every state. */
.nv-button--float {
--_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
--_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-4);
--_shadow-active: var(--nv-highlight-top), var(--nv-elevation-2);
translate: var(--nv-button-offset, 0 0);
}
@media (hover: hover) {
.nv-button--float:hover:not(:disabled, [aria-disabled="true"]) {
translate: var(--nv-button-offset, 0 0);
scale: 1.06;
}
}
.nv-button--float:active:not(:disabled, [aria-disabled="true"]) {
translate: var(--nv-button-offset, 0 0);
scale: 0.97;
}
/* --- Parts --------------------------------------------------------------------- */
.nv-button__icon {
inline-size: 1em;
block-size: 1em;
font-size: 1.15em;
opacity: 0.9;
}
/* Busy: the label stays in place to keep the button from resizing mid-action,
and the spinner replaces it. */
.nv-button[aria-busy="true"] {
color: transparent;
pointer-events: none;
}
.nv-button[aria-busy="true"]::before {
content: "";
position: absolute;
inline-size: 1em;
block-size: 1em;
border: 2px solid var(--_fg);
border-block-start-color: transparent;
border-radius: var(--nv-radius-full);
animation: nv-spin var(--nv-duration-slower) var(--nv-ease-linear) infinite;
}
@keyframes nv-spin {
to { rotate: 1turn; }
}
/* --- Group ---------------------------------------------------------------------
Segmented controls: buttons share an edge, so only the outer corners round
and the shared border is not drawn twice. */
.nv-button-group {
display: inline-flex;
align-items: center;
}
.nv-button-group > .nv-button:not(:first-child) {
border-start-start-radius: 0;
border-end-start-radius: 0;
margin-inline-start: calc(var(--nv-border-width) * -1);
}
.nv-button-group > .nv-button:not(:last-child) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.nv-button-group > .nv-button:hover,
.nv-button-group > .nv-button:focus-visible {
z-index: 1;
translate: none;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Input
--------------------------------------------------------------------------
.nv-field label + control + hint, correctly spaced
.nv-input text input, textarea and select
.nv-input-group control with a leading or trailing affix
.nv-select wrapper that supplies the chevron
.nv-checkbox / .nv-radio custom marks on the native controls
.nv-switch binary control with an inset track
Inputs are the system's inset material: they read as recessed into the
surface that holds them, which is what distinguishes a place to type from a
thing to press. On focus the recess stays — only the ring and border change,
so the field never appears to jump forward.
========================================================================== */
.nv-field {
display: flex;
flex-direction: column;
gap: var(--nv-space-1-5);
min-inline-size: 0;
}
.nv-field__label {
font: var(--nv-font-label);
color: var(--nv-color-text-primary);
}
.nv-field__label[data-optional]::after {
content: " " attr(data-optional);
color: var(--nv-color-text-tertiary);
font-weight: var(--nv-font-weight-regular);
}
.nv-field__hint {
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
}
.nv-field__error {
display: flex;
align-items: center;
gap: var(--nv-space-1-5);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-medium);
color: var(--nv-color-danger-text);
}
/* --- Control ----------------------------------------------------------------- */
.nv-input {
--_height: var(--nv-control-height-md);
--_padding: var(--nv-control-padding-md);
--_font-size: var(--nv-font-size-md);
--_radius: var(--nv-radius-md);
--_border: var(--nv-color-border);
inline-size: 100%;
min-inline-size: 0;
block-size: var(--_height);
padding-inline: var(--_padding);
background-color: var(--nv-color-surface-inset);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--_radius);
box-shadow: var(--nv-elevation-inset-sm);
font-family: var(--nv-font-family-sans);
font-size: var(--_font-size);
line-height: var(--nv-line-height-snug);
transition: var(--nv-transition-control);
appearance: none;
}
.nv-input::placeholder {
color: var(--nv-color-text-tertiary);
}
@media (hover: hover) {
.nv-input:hover:not(:disabled, [readonly]) {
--_border: var(--nv-color-border-strong);
}
}
.nv-input:focus-visible,
.nv-input:focus {
outline: none;
--_border: var(--nv-color-accent);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-focus-ring);
}
.nv-input[aria-invalid="true"] {
--_border: var(--nv-color-danger);
--nv-color-focus-ring: var(--nv-color-danger-border);
}
.nv-input:disabled {
background-color: var(--nv-color-background-subtle);
color: var(--nv-color-text-disabled);
box-shadow: none;
cursor: not-allowed;
}
.nv-input[readonly] {
background-color: transparent;
box-shadow: none;
}
/* Multi-line controls grow, so height becomes a minimum and the block padding
has to be stated explicitly. */
textarea.nv-input {
block-size: auto;
min-block-size: calc(var(--nv-control-height-lg) * 2);
padding-block: var(--nv-space-2-5);
line-height: var(--nv-line-height-normal);
}
.nv-input--sm {
--_height: var(--nv-control-height-sm);
--_padding: var(--nv-control-padding-sm);
--_font-size: var(--nv-font-size-sm);
--_radius: var(--nv-radius-sm);
}
/* A color well: the swatch fills the control, so the value is the control. */
.nv-input--color {
inline-size: var(--nv-control-height-md);
padding: var(--nv-space-1);
cursor: pointer;
}
.nv-input--color::-webkit-color-swatch-wrapper {
padding: 0;
}
.nv-input--color::-webkit-color-swatch,
.nv-input--color::-moz-color-swatch {
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: calc(var(--nv-radius-md) - var(--nv-space-1));
}
.nv-input--lg {
--_height: var(--nv-control-height-lg);
--_padding: var(--nv-control-padding-lg);
--_font-size: var(--nv-font-size-lg);
}
/* --- Affixes -------------------------------------------------------------------
<div class="nv-input-group">
<span class="nv-input-group__affix">@</span>
<input class="nv-input">
</div>
The affix sits inside the field's recess rather than beside it, so the group
still reads as a single surface. */
.nv-input-group {
position: relative;
display: flex;
align-items: center;
inline-size: 100%;
}
.nv-input-group__affix {
position: absolute;
z-index: 1;
display: flex;
align-items: center;
padding-inline: var(--nv-control-padding-md);
color: var(--nv-color-text-tertiary);
font-size: var(--nv-font-size-sm);
pointer-events: none;
}
.nv-input-group__affix:first-child {
inset-inline-start: 0;
}
.nv-input-group__affix:last-child {
inset-inline-end: 0;
}
.nv-input-group__affix:first-child ~ .nv-input {
padding-inline-start: calc(var(--nv-control-padding-md) * 2 + 1em);
}
.nv-input:has(~ .nv-input-group__affix:last-child) {
padding-inline-end: calc(var(--nv-control-padding-md) * 2 + 1em);
}
/* Interactive affixes — a clear button, a reveal toggle — opt back in. */
.nv-input-group__affix > * {
pointer-events: auto;
}
/* --- Select ----------------------------------------------------------------------
<div class="nv-select"><select class="nv-input">…</select></div> */
.nv-select {
position: relative;
display: flex;
inline-size: 100%;
}
.nv-select > .nv-input {
padding-inline-end: calc(var(--nv-control-padding-md) * 2 + 0.5em);
cursor: pointer;
}
.nv-select::after {
content: "";
position: absolute;
inset-inline-end: var(--nv-control-padding-md);
inset-block-start: 50%;
inline-size: 0.45em;
block-size: 0.45em;
margin-block-start: -0.28em;
border-inline-end: 1.5px solid var(--nv-color-text-tertiary);
border-block-end: 1.5px solid var(--nv-color-text-tertiary);
rotate: 45deg;
pointer-events: none;
}
/* --- Checkbox and radio ------------------------------------------------------------ */
.nv-checkbox,
.nv-radio {
position: relative;
flex: none;
inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
margin: 0;
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border-strong);
border-radius: var(--nv-radius-xs);
box-shadow: var(--nv-elevation-inset-sm);
cursor: pointer;
appearance: none;
transition: var(--nv-transition-control);
}
.nv-radio {
border-radius: var(--nv-radius-full);
}
.nv-checkbox:checked,
.nv-radio:checked,
.nv-checkbox:indeterminate {
background-color: var(--nv-color-accent);
border-color: transparent;
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
}
/* The marks are always present, at zero size and zero opacity, so checking a
box grows the tick out of its centre instead of stamping it there. A mark
that only exists in the :checked state has nothing to animate from. */
.nv-checkbox::after,
.nv-radio::after,
.nv-checkbox::before {
content: "";
position: absolute;
opacity: 0;
scale: 0;
transition:
opacity var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-quick) var(--nv-ease-spring);
}
/* The tick is drawn from two borders so it scales with the control and needs
no asset. */
.nv-checkbox::after {
inset-block-start: 45%;
inset-inline-start: 50%;
inline-size: 0.28em;
block-size: 0.55em;
translate: -50% -50%;
rotate: 45deg;
border-inline-end: 2px solid var(--nv-color-on-accent);
border-block-end: 2px solid var(--nv-color-on-accent);
}
.nv-checkbox:checked:not(:indeterminate)::after {
opacity: 1;
scale: 1;
}
/* Indeterminate is its own mark rather than a variation of the tick: the two
have to be able to cross over each other when the state changes. */
.nv-checkbox::before {
inset: 50% 25% auto;
translate: 0 -50%;
block-size: 2px;
background-color: var(--nv-color-on-accent);
border-radius: var(--nv-radius-full);
}
.nv-checkbox:indeterminate::before {
opacity: 1;
scale: 1;
}
.nv-radio::after {
inset: 50% auto auto 50%;
translate: -50% -50%;
inline-size: 0.4em;
block-size: 0.4em;
background-color: var(--nv-color-on-accent);
border-radius: var(--nv-radius-full);
}
.nv-radio:checked::after {
opacity: 1;
scale: 1;
}
.nv-checkbox:disabled,
.nv-radio:disabled {
background-color: var(--nv-color-background-subtle);
border-color: var(--nv-color-border);
box-shadow: none;
cursor: not-allowed;
}
/* --- Switch --------------------------------------------------------------------------
The track is inset and the thumb is elevated: the physical relationship does
the explaining, so the control reads as on or off without a label. */
.nv-switch {
--_track-inline: calc(var(--nv-space-6) * 1.5);
--_track-block: var(--nv-space-6);
--_thumb: calc(var(--_track-block) - 6px);
--_inset: 2px;
--_travel: calc(
var(--_track-inline) - var(--_thumb) - var(--_inset) * 2 -
var(--nv-border-width) * 2
);
position: relative;
flex: none;
inline-size: var(--_track-inline);
block-size: var(--_track-block);
margin: 0;
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-elevation-inset-sm);
cursor: pointer;
appearance: none;
transition: var(--nv-transition-control);
}
.nv-switch::after {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-start: var(--_inset);
inline-size: var(--_thumb);
block-size: var(--_thumb);
translate: 0 -50%;
background-color: var(--nv-color-surface-elevated);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
transition:
translate var(--nv-duration-travel) var(--nv-ease-emphasized),
background-color var(--nv-duration-fast) var(--nv-ease-standard);
}
.nv-switch:checked {
background-color: var(--nv-color-accent);
border-color: transparent;
}
.nv-switch:checked::after {
translate: var(--_travel) -50%;
}
.nv-switch:disabled {
cursor: not-allowed;
opacity: 0.55;
}
/* --- Inline control + label --------------------------------------------------------- */
.nv-choice {
display: flex;
align-items: center;
gap: var(--nv-space-2-5);
font: var(--nv-font-body);
cursor: pointer;
}
.nv-choice:has(:disabled) {
color: var(--nv-color-text-disabled);
cursor: not-allowed;
}
/* translate has no logical equivalent, so the thumb's direction is flipped
explicitly rather than travelling the wrong way in RTL. */
.nv-switch:checked:dir(rtl)::after {
translate: calc(var(--_travel) * -1) -50%;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Card
--------------------------------------------------------------------------
.nv-card resting surface, elevation 1
.nv-card--elevated separated from its context, elevation 2
.nv-card--inset recessed into its parent
.nv-card--frosted over imagery or scrolling content
.nv-card--interactive the whole card is one target
.nv-card__header / __body / __footer / __media
A card is a container, not a look. If a card needs a shadow that is not on
the elevation scale, the answer is a different elevation — not a different
shadow.
========================================================================== */
.nv-card {
--_bg: var(--nv-color-surface);
--_border: var(--nv-color-border-subtle);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-1);
--_padding: var(--nv-space-5);
position: relative;
display: flex;
flex-direction: column;
min-inline-size: 0;
background-color: var(--_bg);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--nv-radius-lg);
box-shadow: var(--_shadow);
/* Children are clipped to the corner radius so media and inset regions
cannot square off the card. */
overflow: hidden;
transition: var(--nv-transition-surface);
}
.nv-card--elevated {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
}
.nv-card--floating {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
}
.nv-card--inset {
--_bg: var(--nv-color-surface-inset);
--_shadow: var(--nv-elevation-inset-md);
--_border: var(--nv-color-border-subtle);
}
.nv-card--frosted {
--_bg: var(--nv-material-frosted-bg);
--_border: var(--nv-material-frosted-border);
--_shadow: var(--nv-material-frosted-shadow);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
.nv-card--flush {
--_padding: 0;
}
/* --- Interactive ------------------------------------------------------------
Rises on hover and sinks on press, exactly like a button — one physical
language for anything that responds to a pointer. */
.nv-card--interactive {
cursor: pointer;
text-align: start;
text-decoration: none;
}
@media (hover: hover) {
.nv-card--interactive:hover {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
translate: 0 calc(var(--nv-lift-hover) * 2);
}
}
.nv-card--interactive:active {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
}
.nv-card--interactive:focus-visible {
outline: none;
box-shadow: var(--_shadow), var(--nv-focus-ring);
}
/* Cards whose whole area is clickable via a link inside: the link's target is
stretched over the card, so the markup stays semantic and only the link text
is announced. */
.nv-card__link::after {
content: "";
position: absolute;
inset: 0;
}
/* --- Parts ------------------------------------------------------------------- */
.nv-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--nv-space-4);
padding: var(--_padding);
padding-block-end: var(--nv-space-3);
}
.nv-card__title {
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
margin: 0;
}
.nv-card__subtitle {
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
margin-block-start: var(--nv-space-1);
}
.nv-card__body {
flex: 1;
padding: var(--_padding);
color: var(--nv-color-text-secondary);
font: var(--nv-font-body);
}
/* A header already opened the block, so the body should not open it again. */
.nv-card__header + .nv-card__body {
padding-block-start: 0;
}
.nv-card__footer {
display: flex;
align-items: center;
gap: var(--nv-space-2);
padding: var(--_padding);
padding-block-start: var(--nv-space-3);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block-start: auto;
}
.nv-card__footer--plain {
border-block-start: 0;
padding-block-start: 0;
}
.nv-card__media {
display: block;
inline-size: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background-color: var(--nv-color-surface-inset);
}
/* --- Group ---------------------------------------------------------------------
Equal-height cards on one rhythm, collapsing to a single column without a
media query. */
.nv-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: var(--nv-space-4);
align-items: stretch;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Dialog
--------------------------------------------------------------------------
Built on the native <dialog> element, so focus trapping, the top layer,
Escape-to-close and inertness come from the platform rather than from
script.
<dialog class="nv-dialog"> … </dialog> opened with .showModal()
.nv-dialog--sheet enters from the block end
.nv-dialog--wide | --compact width
.nv-dialog__header / __body / __footer / __close
The dialog is the system's highest material: elevation 4 over a blurred
scrim. It enters by rising and settling, and leaves the way it came — the
motion is what tells the user the surface was always there, above the page.
========================================================================== */
.nv-dialog {
--_width: 30rem;
--_padding: var(--nv-space-6);
inline-size: min(var(--_width), calc(100vw - var(--nv-space-8)));
max-block-size: min(42rem, calc(100svh - var(--nv-space-12)));
padding: 0;
background-color: var(--nv-color-surface-elevated);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-xl);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-4);
overflow: hidden;
/* Closed state. [open] below is the resting state; @starting-style replays
these values for the entrance. */
/* A dialog should look as though it came from the control that opened it.
nv.openDialog() sets --nv-origin to that control's position relative to
the dialog, so the entrance scale expands away from the trigger rather
than from the middle of the screen. Centred when nothing sets it. */
transform-origin: var(--nv-origin, 50% 50%);
opacity: 0;
scale: 0.97;
translate: 0 var(--nv-travel-md);
transition:
opacity var(--nv-duration-normal) var(--nv-ease-standard),
scale var(--nv-duration-normal) var(--nv-ease-standard),
translate var(--nv-duration-normal) var(--nv-ease-standard),
display var(--nv-duration-normal) allow-discrete,
overlay var(--nv-duration-normal) allow-discrete;
}
.nv-dialog[open] {
opacity: 1;
scale: 1;
translate: 0;
transition-timing-function: var(--nv-ease-entrance);
}
@starting-style {
.nv-dialog[open] {
opacity: 0;
scale: 0.97;
translate: 0 var(--nv-travel-md);
}
}
.nv-dialog::backdrop {
background-color: var(--nv-color-scrim);
backdrop-filter: var(--nv-backdrop-scrim);
-webkit-backdrop-filter: var(--nv-backdrop-scrim);
opacity: 0;
transition:
opacity var(--nv-duration-normal) var(--nv-ease-standard),
display var(--nv-duration-normal) allow-discrete,
overlay var(--nv-duration-normal) allow-discrete;
}
.nv-dialog[open]::backdrop {
opacity: 1;
}
@starting-style {
.nv-dialog[open]::backdrop {
opacity: 0;
}
}
/* showModal() does not stop the page behind from scrolling. */
html:has(.nv-dialog:modal) {
overflow: hidden;
}
/* --- Widths -------------------------------------------------------------------- */
.nv-dialog--compact { --_width: 24rem; }
.nv-dialog--wide { --_width: 44rem; }
/* --- Sheet ----------------------------------------------------------------------
Anchored to the block end and full width. On small screens this is usually
the right form for anything with more than two actions. */
.nv-dialog--sheet {
--_width: 34rem;
/* Flush to the viewport edges — a sheet with side gaps and square bottom
corners reads as a mistake rather than as a sheet. */
inline-size: min(var(--_width), 100vw);
max-block-size: min(46rem, calc(100svh - var(--nv-space-10)));
margin-block-end: 0;
margin-block-start: auto;
border-start-start-radius: var(--nv-radius-xl);
border-start-end-radius: var(--nv-radius-xl);
border-end-start-radius: 0;
border-end-end-radius: 0;
scale: 1;
translate: 0 var(--nv-travel-lg);
}
@starting-style {
.nv-dialog--sheet[open] {
scale: 1;
translate: 0 var(--nv-travel-lg);
}
}
/* --- Parts ------------------------------------------------------------------------ */
.nv-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--nv-space-4);
padding: var(--_padding);
padding-block-end: var(--nv-space-3);
}
.nv-dialog__title {
font: var(--nv-font-heading);
font-size: var(--nv-font-size-xl);
letter-spacing: var(--nv-letter-spacing-tight);
margin: 0;
}
.nv-dialog__description {
font: var(--nv-font-body);
color: var(--nv-color-text-secondary);
margin-block-start: var(--nv-space-2);
}
.nv-dialog__body {
padding: var(--_padding);
padding-block: var(--nv-space-2);
overflow-y: auto;
overscroll-behavior: contain;
color: var(--nv-color-text-secondary);
}
.nv-dialog__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--nv-space-2);
padding: var(--_padding);
padding-block-start: var(--nv-space-4);
}
/* A footer over scrollable content needs its own ground: the inset tint and
hairline tell the user there is more above it. */
.nv-dialog__footer--divided {
background-color: var(--nv-color-background-subtle);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.nv-dialog__close {
flex: none;
margin: calc(var(--nv-space-1) * -1);
}
/* A dialog laid out as a column keeps its header and footer fixed while only
the body scrolls. */
.nv-dialog:has(.nv-dialog__body) {
display: flex;
flex-direction: column;
}
.nv-dialog:not([open]) {
display: none;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Navigation
--------------------------------------------------------------------------
.nv-nav horizontal bar, frosted, sticky by default
.nv-nav--vertical sidebar
.nv-nav--plain solid instead of frosted
.nv-nav__brand / __list / __item / __section / __spacer
.nv-nav-tabs in-page tab strip with a sliding indicator
Navigation is chrome: it sits above content rather than in it. That is why
the bar is frosted — the blur is not decoration, it is the signal that
content is passing underneath. A sidebar that scrolls with the page is not
chrome, so .nv-nav--vertical drops the translucency by default.
========================================================================== */
.nv-nav {
position: sticky;
inset-block-start: 0;
z-index: 10;
display: flex;
align-items: center;
gap: var(--nv-space-4);
padding: var(--nv-space-2) var(--nv-space-4);
min-block-size: var(--nv-control-height-lg);
background-color: var(--nv-material-frosted-bg);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
/* The travelling selection marker sits behind its item at z-index -1; the
bar has to be its own stacking context or the marker would fall behind
the bar's background and disappear. */
isolation: isolate;
box-shadow: var(--nv-highlight-top);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
transition: var(--nv-transition-surface);
}
/* The bar only earns a shadow once something is behind it. Set data-scrolled
from the application on scroll. */
.nv-nav[data-scrolled] {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
transition: box-shadow var(--nv-duration-normal) var(--nv-ease-standard);
}
.nv-nav--plain {
background-color: var(--nv-color-surface);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-nav--static {
position: static;
}
/* A navigation group used inside content rather than as page chrome: no bar,
no border, no blur — just the items and their travelling selection. */
.nv-nav--inline {
position: static;
display: inline-flex;
min-block-size: 0;
padding: var(--nv-space-1);
background-color: transparent;
border-block-end: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-nav__brand {
display: flex;
align-items: center;
gap: var(--nv-space-2);
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
color: var(--nv-color-text-primary);
text-decoration: none;
padding-inline: var(--nv-space-1);
}
.nv-nav__list {
display: flex;
align-items: center;
gap: var(--nv-space-1);
margin: 0;
padding: 0;
list-style: none;
min-inline-size: 0;
}
.nv-nav__spacer {
flex: 1;
}
/* --- Item --------------------------------------------------------------------
Items are flat until they are touched. The active item is the only one with
material, which keeps the bar quiet while still answering "where am I". */
.nv-nav__item {
/* Rides above a travelling marker once niveus.js has given it a name. */
view-transition-class: nv-carried;
position: relative;
display: flex;
align-items: center;
gap: var(--nv-space-2);
padding: var(--nv-space-2) var(--nv-space-3);
min-block-size: var(--nv-control-height-md);
border: var(--nv-border-width) solid transparent;
border-radius: var(--nv-radius-md);
background-color: transparent;
color: var(--nv-color-text-secondary);
font: var(--nv-font-label);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: var(--nv-transition-control);
}
@media (hover: hover) {
.nv-nav__item:hover {
background-color: var(--nv-color-surface-hover);
color: var(--nv-color-text-primary);
}
}
.nv-nav__item:active {
background-color: var(--nv-color-surface-active);
box-shadow: var(--nv-elevation-inset-sm);
transition-duration: var(--nv-duration-instant);
}
.nv-nav__item:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
/* Selection is a material that *moves*. The emphasis lives in a pseudo-element
rather than on the item itself, and carries a view-transition-name: when the
selection changes inside a transition, the browser interpolates the marker's
old box into its new one, so the highlight slides from the previous item to
the next instead of blinking out and in somewhere else.
Only one item in a group may be selected at a time — that is what keeps the
name unique. A page with several selection groups must give each its own
--nv-selection-name, or the duplicate name aborts the transition. */
/* Selection changes color and gains material — but not weight. Bolding the
selected item re-measures its text, which reflows every item beside it on
each change, and makes the old and new labels cross-fade against each other
at different widths. The pill is the cue; the weight was costing a flicker
for nothing. */
.nv-nav__item[aria-current],
.nv-nav__item--active,
.nv-nav__item[aria-selected="true"] {
color: var(--nv-color-text-primary);
}
.nv-nav__item[aria-current]::before,
.nv-nav__item--active::before,
.nv-nav__item[aria-selected="true"]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-color: var(--nv-color-surface-elevated);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: inherit;
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
view-transition-name: var(--nv-selection-name);
view-transition-class: nv-travelling;
}
/* Hovering an already-selected item should not restate the emphasis. */
@media (hover: hover) {
.nv-nav__item[aria-current]:hover,
.nv-nav__item--active:hover,
.nv-nav__item[aria-selected="true"]:hover {
background-color: transparent;
}
}
.nv-nav__item[aria-disabled="true"] {
color: var(--nv-color-text-disabled);
pointer-events: none;
}
.nv-nav__icon {
inline-size: 1.15em;
block-size: 1.15em;
flex: none;
opacity: 0.85;
}
.nv-nav__badge {
margin-inline-start: auto;
padding-inline: var(--nv-space-1-5);
min-inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--nv-color-accent-subtle);
color: var(--nv-color-accent-text);
border-radius: var(--nv-radius-full);
font: var(--nv-font-caption);
font-size: var(--nv-font-size-2xs);
font-weight: var(--nv-font-weight-semibold);
}
/* --- Vertical ------------------------------------------------------------------ */
.nv-nav--vertical {
position: sticky;
inset-block-start: 0;
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-1);
inline-size: 15rem;
block-size: 100svh;
padding: var(--nv-space-4) var(--nv-space-3);
background-color: var(--nv-color-background-subtle);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border-block-end: 0;
border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: none;
overflow-y: auto;
}
.nv-nav--vertical .nv-nav__list {
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-0-5);
}
.nv-nav--vertical .nv-nav__item {
justify-content: flex-start;
}
.nv-nav--vertical .nv-nav__brand {
padding: var(--nv-space-2) var(--nv-space-3);
margin-block-end: var(--nv-space-3);
}
.nv-nav__section {
padding: var(--nv-space-4) var(--nv-space-3) var(--nv-space-1-5);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
.nv-nav__section:first-child {
padding-block-start: 0;
}
/* --- Tabs ------------------------------------------------------------------------
For switching views inside a page, not for moving between pages. The strip
itself is inset, so the selected tab reads as lifted out of the track. */
.nv-nav-tabs {
display: inline-flex;
align-items: center;
gap: var(--nv-space-0-5);
padding: var(--nv-space-1);
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-elevation-inset-sm);
overflow-x: auto;
scrollbar-width: none;
isolation: isolate;
}
.nv-nav-tabs::-webkit-scrollbar {
display: none;
}
.nv-nav-tabs .nv-nav__item {
border-radius: var(--nv-radius-sm);
min-block-size: var(--nv-control-height-sm);
padding-block: var(--nv-space-1);
}
/* Tabs are a selection group of their own, so they get their own marker name;
sharing the navigation bar's would mean two visible elements with the same
view-transition-name. */
.nv-nav-tabs {
--nv-selection-name: nv-tabs-selection;
}
.nv-nav-tabs .nv-nav__item[aria-selected="true"]::before {
border-radius: var(--nv-radius-sm);
}
/* --- Panels ----------------------------------------------------------------------
Switching tabs should feel like moving through one surface rather than
swapping the interface. The panel carries the shared content name, so the
outgoing and incoming panels cross-fade inside the same box — and slide
with the direction of travel when the application supplies one. */
.nv-tab-panel {
view-transition-name: var(--nv-content-name, nv-content);
}
/* --- Breadcrumbs -------------------------------------------------------------------- */
.nv-breadcrumbs {
display: flex;
align-items: center;
gap: var(--nv-space-2);
margin: 0;
padding: 0;
list-style: none;
font: var(--nv-font-caption);
color: var(--nv-color-text-tertiary);
}
.nv-breadcrumbs li + li::before {
content: "/";
margin-inline-end: var(--nv-space-2);
color: var(--nv-color-border-strong);
}
.nv-breadcrumbs a {
color: var(--nv-color-text-secondary);
text-decoration: none;
}
.nv-breadcrumbs a:hover {
color: var(--nv-color-text-primary);
}
.nv-breadcrumbs [aria-current] {
color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-medium);
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Menu
--------------------------------------------------------------------------
<button class="nv-button nv-menu-trigger" popovertarget="actions"
style="--nv-anchor: --actions">Actions</button>
<div class="nv-menu" popover id="actions" style="--nv-anchor: --actions">
<button class="nv-menu__item">Rename</button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Delete</button>
</div>
A menu has a spatial relationship with the control that opened it: anchor
positioning ties it to the trigger, and it scales out of the trigger's edge
rather than appearing somewhere unrelated. Both sides name the same anchor
through --nv-anchor, so the pair stays obvious in the markup.
Light dismiss, Escape, and top-layer stacking come from the popover
attribute — none of it is scripted.
========================================================================== */
.nv-menu-trigger {
anchor-name: var(--nv-anchor);
}
.nv-menu {
position: fixed;
inset: auto;
margin: 0;
padding: var(--nv-space-1);
min-inline-size: 12rem;
max-inline-size: min(20rem, calc(100vw - var(--nv-space-8)));
max-block-size: min(24rem, calc(100svh - var(--nv-space-12)));
overflow-y: auto;
overscroll-behavior: contain;
/* Below the trigger, aligned to its inline-start edge, flipping to whichever
side has room. */
position-anchor: var(--nv-anchor);
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
margin-block-start: var(--nv-space-2);
background-color: var(--nv-material-frosted-bg);
border: var(--nv-border-width) solid var(--nv-material-frosted-border);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
color: var(--nv-color-text-primary);
/* Grows from the edge nearest the trigger. */
transform-origin: center block-start;
opacity: 0;
scale: 0.96;
translate: 0 calc(var(--nv-travel-sm) * -1);
transition:
opacity var(--nv-duration-quick) var(--nv-ease-standard),
scale var(--nv-duration-quick) var(--nv-ease-entrance),
translate var(--nv-duration-quick) var(--nv-ease-entrance),
display var(--nv-duration-quick) allow-discrete,
overlay var(--nv-duration-quick) allow-discrete;
}
.nv-menu:popover-open {
opacity: 1;
scale: 1;
translate: 0;
}
@starting-style {
.nv-menu:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 calc(var(--nv-travel-sm) * -1);
}
}
/* Without anchor positioning the menu cannot know where its trigger is, so it
falls back to the popover's own centring rather than landing somewhere
arbitrary. The relationship is lost; the usability is not. */
@supports not (anchor-name: --nv-test) {
.nv-menu {
inset: 0;
margin: auto;
transform-origin: center;
}
}
/* --- Items -------------------------------------------------------------------- */
.nv-menu__item {
display: flex;
align-items: center;
gap: var(--nv-space-2-5);
inline-size: 100%;
padding: var(--nv-space-2) var(--nv-space-2-5);
background-color: transparent;
border: 0;
border-radius: var(--nv-radius-sm);
color: var(--nv-color-text-secondary);
font: var(--nv-font-label);
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: var(--nv-transition-colors);
}
@media (hover: hover) {
.nv-menu__item:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--nv-color-surface-hover);
color: var(--nv-color-text-primary);
}
}
.nv-menu__item:focus-visible {
outline: none;
background-color: var(--nv-color-surface-hover);
color: var(--nv-color-text-primary);
box-shadow: var(--nv-focus-ring);
}
.nv-menu__item--danger {
color: var(--nv-color-danger-text);
}
@media (hover: hover) {
.nv-menu__item--danger:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--nv-color-danger-subtle);
color: var(--nv-color-danger-text);
}
}
.nv-menu__item:disabled,
.nv-menu__item[aria-disabled="true"] {
color: var(--nv-color-text-disabled);
cursor: not-allowed;
}
.nv-menu__item[aria-checked="true"]::before {
content: "";
inline-size: 0.28em;
block-size: 0.55em;
margin-inline-end: var(--nv-space-1);
translate: 0 -0.12em;
rotate: 45deg;
border-inline-end: 2px solid var(--nv-color-accent);
border-block-end: 2px solid var(--nv-color-accent);
}
.nv-menu__shortcut {
margin-inline-start: auto;
padding-inline-start: var(--nv-space-4);
color: var(--nv-color-text-tertiary);
font: var(--nv-font-caption);
}
.nv-menu__separator {
border: 0;
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin: var(--nv-space-1) 0;
}
.nv-menu__label {
padding: var(--nv-space-2) var(--nv-space-2-5) var(--nv-space-1);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Disclosure
--------------------------------------------------------------------------
<details class="nv-disclosure">
<summary class="nv-disclosure__summary"> … </summary>
<div class="nv-disclosure__body"> … </div>
</details>
Content expands out of the row that revealed it, at the position it already
occupies, rather than appearing at full height. The height animates because
the continuity layer turns on `interpolate-size`, which is what lets
block-size: auto take part in a transition — no measurement, no script, and
no wrapper whose height has to be kept in sync.
.nv-collapse in continuity.css covers the same relationship for markup that
cannot be a <details>.
========================================================================== */
.nv-disclosure {
--_padding: var(--nv-space-4);
background-color: var(--nv-color-surface);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-lg);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
transition: var(--nv-transition-surface);
}
.nv-disclosure--plain {
background-color: transparent;
border-color: transparent;
box-shadow: none;
}
.nv-disclosure__summary {
display: flex;
align-items: center;
gap: var(--nv-space-3);
padding: var(--nv-space-3) var(--_padding);
font: var(--nv-font-label);
color: var(--nv-color-text-primary);
border-radius: inherit;
cursor: pointer;
user-select: none;
list-style: none;
transition: var(--nv-transition-colors);
}
/* Both spellings are needed to suppress the native marker. */
.nv-disclosure__summary::marker,
.nv-disclosure__summary::-webkit-details-marker {
display: none;
content: "";
}
@media (hover: hover) {
.nv-disclosure__summary:hover {
background-color: var(--nv-color-surface-hover);
}
}
.nv-disclosure__summary:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
/* The chevron turns rather than swapping to a different glyph — the rotation
is what says "this is the same control in its other state". */
.nv-disclosure__chevron {
inline-size: 0.5em;
block-size: 0.5em;
margin-inline-start: auto;
margin-block-start: -0.2em;
flex: none;
border-inline-end: 1.5px solid var(--nv-color-text-tertiary);
border-block-end: 1.5px solid var(--nv-color-text-tertiary);
rotate: 45deg;
transition: rotate var(--nv-duration-normal) var(--nv-ease-standard);
}
.nv-disclosure[open] .nv-disclosure__chevron {
rotate: 225deg;
margin-block-start: 0.15em;
}
.nv-disclosure__body {
padding: 0 var(--_padding) var(--_padding);
color: var(--nv-color-text-secondary);
font: var(--nv-font-body);
}
/* The element the browser creates for a <details> body. Transitioning
content-visibility as a discrete property is what keeps the content
rendered while it collapses, instead of vanishing on the first frame. */
.nv-disclosure::details-content {
block-size: 0;
overflow: hidden;
opacity: 0;
transition:
block-size var(--nv-duration-expand) var(--nv-ease-standard),
opacity var(--nv-duration-expand) var(--nv-ease-standard),
content-visibility var(--nv-duration-expand) allow-discrete;
}
.nv-disclosure[open]::details-content {
block-size: auto;
opacity: 1;
}
/* Stacked disclosures read as one control surface, so only the outer corners
round and neighbours share a single hairline. */
.nv-disclosure-group {
display: flex;
flex-direction: column;
}
/* The negative margin collapses each pair of adjacent borders into a single
hairline, so neighbours are separated once rather than twice. */
.nv-disclosure-group > .nv-disclosure:not(:first-child) {
border-start-start-radius: 0;
border-start-end-radius: 0;
margin-block-start: calc(var(--nv-border-width) * -1);
}
.nv-disclosure-group > .nv-disclosure:not(:last-child) {
border-end-start-radius: 0;
border-end-end-radius: 0;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Scroller
--------------------------------------------------------------------------
<div class="nv-scroller-frame" data-nv-scroller>
<div class="nv-scroller"> …cards… </div>
<button class="nv-button nv-button--icon" data-nv-scroll="prev">…</button>
<button class="nv-button nv-button--icon" data-nv-scroll="next">…</button>
</div>
A row of content that continues past its frame. Two things make that
readable rather than merely clipped: the edges fade, so the row visibly runs
under them instead of being cut, and snapping lands each item at the same
place every time — so moving left and right returns you to positions you
have already seen rather than to arbitrary offsets.
Scrolling works with no script at all: wheel, trackpad, touch and keyboard
are the browser's. The buttons are an affordance for pointers that have no
horizontal axis, and niveus.js wires them.
========================================================================== */
.nv-scroller-frame {
position: relative;
}
.nv-scroller {
--nv-scroller-item: 18rem;
--nv-scroller-fade: var(--nv-space-8);
display: flex;
gap: var(--nv-space-4);
overflow-x: auto;
overscroll-behavior-inline: contain;
/* Room for items to lift on hover without being clipped by the scroll box. */
padding-block: var(--nv-space-2);
scroll-snap-type: inline mandatory;
scroll-padding-inline: var(--nv-space-1);
scroll-behavior: smooth;
scrollbar-width: none;
/* The row runs under its own edges rather than stopping at them — but only
on the side where there is actually more to see. A fade at an end the row
has already reached is a lie about the content. niveus.js marks the ends;
with no script both sides fade, which is the safe default. */
mask-image: linear-gradient(
to right,
transparent,
black var(--nv-scroller-fade),
black calc(100% - var(--nv-scroller-fade)),
transparent
);
}
.nv-scroller-frame[data-nv-at-start] .nv-scroller {
mask-image: linear-gradient(
to right,
black calc(100% - var(--nv-scroller-fade)),
transparent
);
}
.nv-scroller-frame[data-nv-at-end] .nv-scroller {
mask-image: linear-gradient(to right, transparent, black var(--nv-scroller-fade));
}
.nv-scroller-frame[data-nv-at-start][data-nv-at-end] .nv-scroller {
mask-image: none;
}
.nv-scroller::-webkit-scrollbar {
display: none;
}
.nv-scroller > * {
flex: 0 0 auto;
inline-size: min(var(--nv-scroller-item), 78%);
scroll-snap-align: start;
}
/* Items that should size to their content rather than to the track. */
.nv-scroller--auto > * {
inline-size: auto;
}
@media (prefers-reduced-motion: reduce) {
.nv-scroller {
scroll-behavior: auto;
}
}
/* --- Controls ------------------------------------------------------------
Centred on the row's block axis and pulled half outside it, so they read as
belonging to the frame rather than floating over the content. */
.nv-scroller-frame [data-nv-scroll] {
position: absolute;
inset-block-start: 50%;
z-index: 1;
/* Read by .nv-button--float, which keeps it through hover and press. */
--nv-button-offset: 0 -50%;
}
/* Belt and braces: the frame knows these controls float, so their centring
survives even if the markup forgets --float. */
.nv-scroller-frame [data-nv-scroll],
.nv-scroller-frame [data-nv-scroll]:hover,
.nv-scroller-frame [data-nv-scroll]:active {
translate: var(--nv-button-offset);
}
.nv-scroller-frame [data-nv-scroll="prev"] {
inset-inline-start: calc(var(--nv-space-4) * -1);
}
.nv-scroller-frame [data-nv-scroll="next"] {
inset-inline-end: calc(var(--nv-space-4) * -1);
}
/* At either end the control has nothing left to do, and says so. */
.nv-scroller-frame [data-nv-scroll]:disabled {
opacity: 0;
pointer-events: none;
}
@media (hover: none) {
.nv-scroller-frame [data-nv-scroll] {
display: none;
}
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Bed
--------------------------------------------------------------------------
<div class="nv-bed"> … </div>
A bed is ground that is visibly *not* ordinary background. Where a card says
"this content belongs together", a bed says "what sits here is different in
kind" — a feature being shown off, a preview, a sandbox, an empty state, a
region under explanation.
It reads that way because of what it does to the materials on top of it: the
hatch and the soft color blooms give translucent surfaces something to be
translucent *against*, and give shadows something to fall on. A frosted panel
over a flat grey proves nothing; over a bed it is obvious.
That is also the rule for using one. A bed is for regions the user should
look *at*. Put content they need to work *through* on a plain surface —
patterning behind a form or a table is noise, and the calm is the point.
Everything it is made of derives from the accent, so it follows the brand.
Set --nv-bed-tint to lead with a different color.
========================================================================== */
.nv-bed {
--nv-bed-tint: var(--nv-color-accent);
--_bloom: 16%;
--_hatch: var(--nv-color-border-subtle);
position: relative;
padding: var(--nv-space-6);
background-color: var(--nv-color-background-subtle);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-xl);
/* The hatch sits over the blooms, and both sit under the content: three
background layers rather than a pseudo-element, so nothing has to be
lifted out of the way. */
background-image:
repeating-linear-gradient(
45deg,
var(--_hatch) 0 2px,
transparent 2px 14px
),
radial-gradient(
60% 80% at 15% 20%,
color-mix(in oklab, var(--nv-bed-tint) var(--_bloom), transparent),
transparent 70%
),
radial-gradient(
50% 70% at 85% 25%,
color-mix(in oklab, var(--nv-color-danger) calc(var(--_bloom) * 0.7), transparent),
transparent 70%
),
radial-gradient(
70% 90% at 50% 100%,
color-mix(in oklab, var(--nv-color-success) calc(var(--_bloom) * 0.7), transparent),
transparent 70%
);
overflow: hidden;
isolation: isolate;
transition: var(--nv-transition-surface);
}
/* Just the ground, no weather: for grouping a region without drawing the eye
to it. */
.nv-bed--quiet {
--_bloom: 8%;
--_hatch: transparent;
}
.nv-bed--vivid {
--_bloom: 26%;
}
/* One color instead of three — a bed that belongs to a single state. */
.nv-bed--single {
background-image:
repeating-linear-gradient(45deg, var(--_hatch) 0 2px, transparent 2px 14px),
radial-gradient(
80% 100% at 30% 0%,
color-mix(in oklab, var(--nv-bed-tint) var(--_bloom), transparent),
transparent 75%
);
}
.nv-bed--flush {
border-radius: 0;
border-inline: 0;
}
.nv-bed__label {
display: block;
margin-block-end: var(--nv-space-4);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Badge
--------------------------------------------------------------------------
<span class="nv-badge nv-badge--success">In stock</span>
<span class="nv-badge nv-badge--danger nv-badge--solid">3 failing</span>
<span class="nv-badge"><span class="nv-badge__dot"></span>Draft</span>
A small, non-interactive statement of state. Subtle by default, because a
badge is almost always beside something more important than itself; --solid
is for the one badge in a view that must be seen before anything else.
If it can be clicked it is not a badge — that is a button, probably
.nv-button--pill --sm.
========================================================================== */
.nv-badge {
--_bg: var(--nv-color-surface-inset);
--_fg: var(--nv-color-text-secondary);
--_border: var(--nv-color-border);
--_solid-bg: var(--nv-color-text-secondary);
--_solid-fg: var(--nv-color-surface);
display: inline-flex;
align-items: center;
gap: var(--nv-space-1-5);
block-size: var(--nv-space-5);
padding-inline: var(--nv-space-2);
background-color: var(--_bg);
color: var(--_fg);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--nv-radius-full);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
white-space: nowrap;
transition: var(--nv-transition-colors);
}
.nv-badge--accent {
--_bg: var(--nv-color-accent-subtle);
--_fg: var(--nv-color-accent-text);
--_border: var(--nv-color-accent-border);
--_solid-bg: var(--nv-color-accent);
--_solid-fg: var(--nv-color-on-accent);
}
.nv-badge--success {
--_bg: var(--nv-color-success-subtle);
--_fg: var(--nv-color-success-text);
--_border: var(--nv-color-success-border);
--_solid-bg: var(--nv-color-success);
--_solid-fg: var(--nv-color-on-success);
}
.nv-badge--warning {
--_bg: var(--nv-color-warning-subtle);
--_fg: var(--nv-color-warning-text);
--_border: var(--nv-color-warning-border);
--_solid-bg: var(--nv-color-warning);
--_solid-fg: var(--nv-color-on-warning);
}
.nv-badge--danger {
--_bg: var(--nv-color-danger-subtle);
--_fg: var(--nv-color-danger-text);
--_border: var(--nv-color-danger-border);
--_solid-bg: var(--nv-color-danger);
--_solid-fg: var(--nv-color-on-danger);
}
/* Declared after the colors so it wins for --_bg; the color modifier is still
what decides which solid pair it reads. */
.nv-badge--solid {
--_bg: var(--_solid-bg);
--_fg: var(--_solid-fg);
--_border: transparent;
}
.nv-badge--square {
border-radius: var(--nv-radius-xs);
}
/* Counts read better without a border, and with room for two digits. */
.nv-badge--count {
min-inline-size: var(--nv-space-5);
padding-inline: var(--nv-space-1-5);
justify-content: center;
border-color: transparent;
font-size: var(--nv-font-size-2xs);
font-variant-numeric: tabular-nums;
}
.nv-badge__dot {
inline-size: var(--nv-space-1-5);
block-size: var(--nv-space-1-5);
border-radius: var(--nv-radius-full);
background-color: currentColor;
flex: none;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Table
--------------------------------------------------------------------------
<div class="nv-table-frame">
<table class="nv-table"> … </table>
</div>
Rows of the same kind of thing. The frame handles the overflow, because a
table is the one component that legitimately refuses to get narrower — it
scrolls sideways inside its frame rather than forcing the page to.
Numbers belong in .nv-table__number: aligned to the end and set in tabular
figures, so columns of digits line up and can be compared by eye.
========================================================================== */
.nv-table-frame {
inline-size: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.nv-table {
inline-size: 100%;
border-collapse: collapse;
font: var(--nv-font-body);
text-align: start;
}
.nv-table th {
padding: var(--nv-space-2-5) var(--nv-space-4);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wide);
color: var(--nv-color-text-tertiary);
text-align: start;
white-space: nowrap;
border-block-end: var(--nv-border-width) solid var(--nv-color-border);
}
.nv-table td {
padding: var(--nv-space-3) var(--nv-space-4);
color: var(--nv-color-text-secondary);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
vertical-align: middle;
}
/* The first cell in a row names it, so it carries the reading weight. */
.nv-table td:first-child {
color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-medium);
}
.nv-table tbody tr:last-child td {
border-block-end: 0;
}
@media (hover: hover) {
.nv-table tbody tr {
transition: var(--nv-transition-colors);
}
.nv-table tbody tr:hover {
background-color: var(--nv-color-surface-hover);
}
}
.nv-table__number {
text-align: end;
font-variant-numeric: tabular-nums;
}
.nv-table__actions {
inline-size: 1%;
white-space: nowrap;
text-align: end;
}
.nv-table--compact th {
padding-block: var(--nv-space-2);
}
.nv-table--compact td {
padding-block: var(--nv-space-2);
}
/* Rows that lead somewhere. Same physical language as an interactive card:
it responds to a pointer, so it must look like it can be pressed. */
.nv-table--interactive tbody tr {
cursor: pointer;
}
.nv-table--interactive tbody tr:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
}
/* --- Utilities ----------------------------------------------------------------- */
@layer niveus.utilities {
/* ==========================================================================
Niveus — Utilities
--------------------------------------------------------------------------
A deliberately short list. Utilities exist for the handful of patterns that
appear in every application and have exactly one correct implementation —
not as a general-purpose styling language. Anything that needs a second
utility to look right is a component.
========================================================================== */
/* Content for assistive technology only. Not display:none, which removes it
from the accessibility tree as well. */
.nv-visually-hidden:not(:focus):not(:active) {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.nv-container {
inline-size: 100%;
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
/* Vertical rhythm between siblings, on the spacing scale. */
.nv-stack {
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
}
.nv-stack--tight { gap: var(--nv-space-2); }
.nv-stack--loose { gap: var(--nv-space-6); }
/* A row that wraps instead of overflowing. */
.nv-cluster {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--nv-space-3);
}
.nv-cluster--tight { gap: var(--nv-space-2); }
.nv-cluster--between { justify-content: space-between; }
.nv-truncate {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Long-form text at a readable measure. */
.nv-prose {
max-inline-size: var(--nv-measure);
line-height: var(--nv-line-height-relaxed);
color: var(--nv-color-text-secondary);
}
}