diff --git a/cmd/chvt/main.c b/cmd/chvt/main.c index aa78fc6..08a9b99 100644 --- a/cmd/chvt/main.c +++ b/cmd/chvt/main.c @@ -19,13 +19,14 @@ // guest cannot detect this: a key the host swallowed is indistinguishable from // a key nobody pressed. So it is a setting. // -// chvt -c altgr+F AltGr+F1..F6 (the default) -// chvt -c ctrl+shift+F Ctrl+Shift+F1..F6 -// chvt -c alt+1 Alt+1..6 +// chvt -c ctrl+alt+F Ctrl+Alt+F1..F12 (the default) +// chvt -c altgr+F AltGr+F1..F12 +// chvt -c ctrl+shift+F Ctrl+Shift+F1..F12 +// chvt -c alt+1 Alt+1..9 // chvt -c off no chord at all // // Whatever the modifiers are, they also bind minus and equals to the previous -// and next terminal in use -- AltGr+- and AltGr+= by default. +// and next terminal in use -- Ctrl+Alt+- and Ctrl+Alt+= by default. #include "core/types.h" #include "uapi/user.h" diff --git a/docs/Website/download.html b/docs/Website/download.html index 784f639..54efd76 100644 --- a/docs/Website/download.html +++ b/docs/Website/download.html @@ -243,9 +243,9 @@ 4
Step four -

Six terminals

+

Twelve terminals

- AltGr+F1…F6 + Ctrl+Alt+F1…F12 switches between them — the one chord a Linux host does not take for itself — or chvt N from a shell.

diff --git a/docs/Website/index.html b/docs/Website/index.html index 09feebf..31fecef 100644 --- a/docs/Website/index.html +++ b/docs/Website/index.html @@ -347,7 +347,7 @@
Six of them, each with its own cell grid, scrollback and foreground - job — switched with AltGr+F1…F6, + job — switched with Ctrl+Alt+F1…F12, which is the one chord a Linux host does not take for itself. The console draws to whichever framebuffer is present, from the kernel's first line.
diff --git a/docs/Website/releases/schos.iso b/docs/Website/releases/schos.iso new file mode 100644 index 0000000..16f9c0e Binary files /dev/null and b/docs/Website/releases/schos.iso differ diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7e46a09..8f1687c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -18,7 +18,7 @@ its distribution. | API | **SchAPI** | The native programming interface — *designed, not built* | | Compositor | **Schcom** | Owns the display; v0 runs | -Six virtual terminals sit under all of it -- `/dev/tty1`..`tty6`, AltGr+F1..F6 +Twelve virtual terminals sit under all of it -- `/dev/tty1`..`tty12`, Ctrl+Alt+F1..F12 or `chvt N` -- each with its own screen, line discipline and foreground process group. See [terminals.md](terminals.md). diff --git a/docs/architecture/terminals.md b/docs/architecture/terminals.md index 1df3878..5beb717 100644 --- a/docs/architecture/terminals.md +++ b/docs/architecture/terminals.md @@ -1,12 +1,13 @@ # Virtual terminals -Six of them, `/dev/tty1` through `/dev/tty6`. +Twelve of them, `/dev/tty1` through `/dev/tty12`, one per function key. | | | |---|---| -| **AltGr+F1 .. F6** | go to that terminal | -| **AltGr+-** / **AltGr+=** | previous / next terminal *in use* | +| **Ctrl+Alt+F1 .. F12** | go to that terminal — on the console and under X alike | +| **Alt+F1 .. F12** | the same, on the text console only (X keeps plain Alt) | +| **Ctrl+Alt+-** / **Ctrl+Alt+=** | previous / next terminal *in use* | | `chvt N` | the same, from a script or a serial console | The chord is settable — `chvt -c` — for the reasons below. Each one is an independent terminal: its own screen, its own @@ -14,8 +15,9 @@ cursor, its own line discipline, its own foreground process group and its own session. Two shells on two terminals are two unrelated processes that happen to share a keyboard and a screen. -A seventh is deliberately not bound. That is where a display server goes, -and it will claim switching with `VT_SETMODE` rather than a chord. +A display server takes one of them with `VT_SETMODE` and does its own +switching; X switches with Ctrl+Alt+F-key, which is why that is the default +here too. --- @@ -202,8 +204,9 @@ So the kernel ships a default and lets it be moved: ```sh chvt # report the active terminal and the current chord -chvt -c altgr+F # AltGr+F1..F6 (the default) -chvt -c ctrl+shift+F # Ctrl+Shift+F1..F6 +chvt -c ctrl+alt+F # Ctrl+Alt+F1..F12 (the default) +chvt -c altgr+F # AltGr+F1..F12 (the default until 2026-10) +chvt -c ctrl+shift+F # Ctrl+Shift+F1..F12 chvt -c alt+1 # Alt+1..6 chvt -c off # no chord; chvt N is then the only way ``` @@ -211,23 +214,23 @@ chvt -c off # no chord; chvt N is then the only way `off` is a legitimate setting, not a failure mode. On a machine whose host grabs everything worth binding, `chvt N` from a shell is the honest answer. -The default is **AltGr+F1..F6**, and the `AltGr+`letter row above needs reading -carefully: what a Polish layout wants is AltGr with a **letter**. AltGr with a -**function key** produces nothing on any layout, no window manager binds it, -and no application asks for it. It is the one combination all three parties -leave alone. +The default is **Ctrl+Alt+F1..F12**. It was AltGr+F-key for a long time — +AltGr with a **function key** produces nothing on any layout, no window manager +binds it, and no application asks for it — but X owns the keyboard while it +runs and switches only with Ctrl+Alt+F-key, so the console and the desktop +answered different keys for the same thing. One chord everywhere wins. Inside +a VM whose host grabs Ctrl+Alt+F-key, `chvt -c altgr+F` brings the old one +back. That distinction is only expressible because Right Alt is tracked as its own modifier bit. It used to share `ALT`, which is harmless for typing — neither is used to look up a character — and made this impossible to say. -Verified rather than argued: with the default set, `AltGr+F2` switches, -`AltGr+F1` returns to a pixel-identical tty1, and `AltGr+a` still reaches the -shell, so ą is untouched. +AltGr+letter still reaches the shell untouched, so ą is unaffected either way. Whatever the modifiers are, they also bind **minus** and **equals** to the previous and next terminal. Those step only through terminals that are *in -use* — the same `opened` count `VT_GETSTATE` reports — because six terminals +use* — the same `opened` count `VT_GETSTATE` reports — because twelve terminals with two shells on them means four blank screens in between, and cycling past them is worse than not having the key. diff --git a/docs/niveus/dist/niveus.css b/docs/niveus/dist/niveus.css new file mode 100644 index 0000000..c446fee --- /dev/null +++ b/docs/niveus/dist/niveus.css @@ -0,0 +1,3705 @@ +/* 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: + + + — optional, see below + — 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
. 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 ------------------------------------------------------------------- +
+ @ + +
+ 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 ---------------------------------------------------------------------- +
*/ + + .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 element, so focus trapping, the top layer, + Escape-to-close and inertness come from the platform rather than from + script. + + … 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 + -------------------------------------------------------------------------- + + +
+ +
+ +
+ + 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 + -------------------------------------------------------------------------- +
+ … +
…
+
+ + 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
. + ========================================================================== */ + + .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
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 + -------------------------------------------------------------------------- +
+
…cards…
+ + +
+ + 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 + -------------------------------------------------------------------------- +
…
+ + 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 + -------------------------------------------------------------------------- + In stock + 3 failing + Draft + + 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 + -------------------------------------------------------------------------- +
+ …
+
+ + 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); + } +} diff --git a/include/core/defs.h b/include/core/defs.h index eb8f639..bd604a4 100644 --- a/include/core/defs.h +++ b/include/core/defs.h @@ -546,6 +546,7 @@ int sockbind_in(struct socket*, uint addr, ushort port); int sockconnect_in(struct socket*, uint addr, ushort port); int sock_domain(struct socket*); int sock_peercred(struct socket*, int*, uint*, uint*); +int sock_passcred(struct socket*); // Socket options. The syscall layer does the user-memory marshalling and these // do the work, because struct socket's fields live in kernel/net/unix.c and the // syscall layer deliberately cannot see them. @@ -733,6 +734,7 @@ int net_eth_detach(struct device *); int net_detach(struct device *); int net_attach_late(struct device *); int net_eth_poll(void); +void net_eth_link_check(void); int net_eth_count(void); void net_eth_counts(int i, uint64 *rx, uint64 *tx); struct netif *net_eth_netif(int i); @@ -805,6 +807,8 @@ void inotify_file(struct file *f, uint32 mask); uint32 inotify_cookie(void); int ev_fionread(struct evobj *o); int pipe_avail(struct pipe *p); +int vm_fault(addr_t va); +pte_t* walkpte(pml4e_t *pgdir, const void *va); int path_identity(char *path, int follow, int onlydir, uint *dev, uint64 *inum); int memfd_rw(int id, int user, uint64 buf, uint64 off, int n, int writing); void shm_detach(int id); diff --git a/include/dev/kbd.h b/include/dev/kbd.h index 7364e48..9d60e02 100644 --- a/include/dev/kbd.h +++ b/include/dev/kbd.h @@ -92,12 +92,10 @@ // leaves `chvt N` as the only way to switch, which is a legitimate choice on // a machine whose host takes everything. // -// The default is AltGr+F1..F6, and the "AltGr wants it" line above needs -// reading carefully: what a Polish layout wants is AltGr+*letter*. AltGr with -// a function key produces nothing on any layout, no window manager binds it, -// and no application asks for it. Right Alt being a separate modifier bit -// here is what makes the distinction expressible at all -- it used to share -// ALT, which is harmless for typing and useless for this. +// The default is Ctrl+Alt+F1..F12: the keys X switches with, so the console +// and the desktop answer the same chord. It used to be AltGr+F-key, which +// nothing else claims -- Right Alt being a separate modifier bit here is what +// makes that expressible, and `chvt -c altgr+F` still sets it. #define KBD_VT_SWITCH (KBD_SPECIAL + 0x20) // Previous / next terminal, on the same modifiers as the chord above. The diff --git a/include/dev/vt.h b/include/dev/vt.h index 13a5453..f7b5bab 100644 --- a/include/dev/vt.h +++ b/include/dev/vt.h @@ -26,10 +26,10 @@ #include "core/param.h" #include "uapi/posix.h" -// Six, and Linux's numbering: /dev/tty1 through /dev/tty6, reached with -// Alt+1..Alt+6. A seventh is deliberately not bound -- that is where a -// display server goes, and Schcom will want it. -#define NVT 6 +// Twelve, one per function key, and Linux's numbering: /dev/tty1 through +// /dev/tty12, reached with Ctrl+Alt+F1..F12 (and plain Alt on the text +// console). The key codes leave room for sixteen; see KBD_VT_SWITCH. +#define NVT 12 // A cell. // diff --git a/include/net/socket.h b/include/net/socket.h index 8a309b2..7c8dd81 100644 --- a/include/net/socket.h +++ b/include/net/socket.h @@ -349,6 +349,7 @@ struct socket { int o_keepalive; int o_broadcast; int o_nodelay; + int o_passcred; // SO_PASSCRED: SCM_CREDENTIALS on every recvmsg // Descriptors sent to this socket and not yet received. They live on the // *receiving* side, next to the bytes they were sent with, because that is diff --git a/include/uapi/posix.h b/include/uapi/posix.h index bf6e3f9..c416f4c 100644 --- a/include/uapi/posix.h +++ b/include/uapi/posix.h @@ -432,6 +432,7 @@ struct statfs { #define MAP_PRIVATE 0x02 #define MAP_FIXED 0x10 #define MAP_ANONYMOUS 0x20 +#define MAP_NORESERVE 0x4000 // waitpid() options. The kernel tests `options & 1`; this is that bit, named. #define WNOHANG 0x001 diff --git a/kernel/arch/x86_64/trap.c b/kernel/arch/x86_64/trap.c index 46ecde5..c98f44d 100644 --- a/kernel/arch/x86_64/trap.c +++ b/kernel/arch/x86_64/trap.c @@ -160,6 +160,11 @@ trap(struct trapframe *tf) } unexpected: + // A user page fault on anonymous memory that is reserved but not backed + // yet: the page is made and the instruction runs again. See vm_fault(). + if(tf->trapno == T_PGFLT && myproc() && (tf->cs&3) == DPL_USER && + !(tf->err & 1) && vm_fault(rcr2()) == 0) + break; if(myproc() == 0 || (tf->cs&3) == 0){ // A kernel-mode trap ends in panic() a dozen lines down, so the console // can be put into its panic mode *now* -- and it has to be, because the diff --git a/kernel/arch/x86_64/vm.c b/kernel/arch/x86_64/vm.c index 20a20e5..e61f784 100644 --- a/kernel/arch/x86_64/vm.c +++ b/kernel/arch/x86_64/vm.c @@ -1134,6 +1134,13 @@ copyuvm(pml4e_t *pgdir, uint64 sz) return 0; } +// The PTE for va, if its page tables exist. For vm_fault(). +pte_t * +walkpte(pml4e_t *pgdir, const void *va) +{ + return walkpgdir(pgdir, va, 0); +} + char* uva2ka(pml4e_t *pgdir, char *uva) { @@ -1142,6 +1149,11 @@ uva2ka(pml4e_t *pgdir, char *uva) pte_t *pte; pte = walkpgdir(pgdir, uva, 0); + // Anonymous memory not touched yet: backed now, as the program's own + // access would have done. Only for the running process's own table. + if((pte == 0 || (*pte & PTE_P) == 0) && myproc() && pgdir == myproc()->pgdir && + vm_fault((addr_t)uva) == 0) + pte = walkpgdir(pgdir, uva, 0); if(pte == 0 || (*pte & PTE_P) == 0) return 0; if((*pte & PTE_U) == 0) diff --git a/kernel/core/console.c b/kernel/core/console.c index 3cc6901..60474f4 100644 --- a/kernel/core/console.c +++ b/kernel/core/console.c @@ -336,7 +336,7 @@ static struct spinlock input_lock; // 1 /dev/console the active terminal, so kernel messages and a // shell started on the console both follow a switch // 10 /dev/tty the caller's controlling terminal -// 11..16 /dev/tty1..6 that terminal, whether or not it is on screen +// 11..22 /dev/tty1..12 that terminal, whether or not it is on screen // // Anything else is the console, which is what an unrecognised minor meant // before there was more than one terminal to get wrong. @@ -461,7 +461,7 @@ erase_one(struct vt *v) // Keyboard input // --------------------------------------------------------------------------- -// Alt+F1..F6, Ctrl+Alt+F1..F6 and the settable chord (AltGr+F1..F6 by +// Alt+F-key, Ctrl+Alt+F-key and the settable chord (Ctrl+Alt+F1..F12 by // default) switch terminals. kbd.c reports them as // KBD_VT_SWITCH+n so that the // decision of what a chord *means* is made here, where the terminals are, diff --git a/kernel/dev/kbd.c b/kernel/dev/kbd.c index f7cc3b5..09a0f67 100644 --- a/kernel/dev/kbd.c +++ b/kernel/dev/kbd.c @@ -9,19 +9,40 @@ // The chord, settable at run time through VT_SETCHORD. // -// AltGr+F1..F6 by default. Every other candidate is taken by the host, by -// applications, or by the keyboard layout -- see include/dev/kbd.h -- and AltGr -// with a function key is taken by none of the three: a Polish layout wants -// AltGr+*letter*, which still reaches the shell untouched, and no window -// manager binds AltGr with an F-key. +// Ctrl+Alt+F1..F12 by default, the same keys X switches with. It used to be +// AltGr+F-key, chosen because nothing else claimed it -- but under X the +// server owns the keyboard and only Ctrl+Alt+F-key works, so the console and +// the desktop answered different keys for the same thing. // -// AltGr+minus and AltGr+equals step to the previous and next terminal on the -// same modifiers, for when you have two and do not care which number they are. +// Ctrl+Alt+minus and Ctrl+Alt+equals step to the previous and next terminal, +// for when you have two and do not care which number they are. // -// Zero modifiers disables all of it; `chvt N` still works. -uint vt_chord_mods = RALT; +// Zero modifiers disables the chord; `chvt N` still works. +uint vt_chord_mods = CTL | ALT; uint vt_chord_base = SCAN_F1; +// Which terminal an F-key scancode names: F1..F10 are 0x3B..0x44 and F11 and +// F12 sit apart, at 0x57 and 0x58. -1 for anything else. +static int +fkey_index(uint data) +{ + if (data >= SCAN_F1 && data < SCAN_F1 + 10) return (int)(data - SCAN_F1); + if (data == 0x57) return 10; + if (data == 0x58) return 11; + return -1; +} + +// The terminal a chord key names, from `base`: the F-keys by fkey_index, any +// other base as a run of ten consecutive scancodes -- a digit row is 1..9, 0, +// and the minus and equals after it are already the previous and next keys. +static int +chord_index(uint data, uint base) +{ + int i = base == SCAN_F1 ? fkey_index(data) + : (data >= base && data < base + 10 ? (int)(data - base) : -1); + return i < NVT ? i : -1; +} + // Which scancodes are modifiers, and which are locks. Hardware facts rather // than layout ones -- every layout agrees that 0x2A is a Shift key -- so they // stay here while the character tables live in keymap.c. @@ -146,21 +167,20 @@ kbdgetc(void) // modifiers named, and nothing else held. if (vt_chord_mods != 0 && (shift & (CTL | SHIFT | ALT | RALT)) == vt_chord_mods) { - if (data >= vt_chord_base && data < vt_chord_base + NVT) - return KBD_VT_SWITCH + (data - vt_chord_base); + int i = chord_index(data, vt_chord_base); + if (i >= 0) + return KBD_VT_SWITCH + i; if (data == SCAN_MINUS) return KBD_VT_PREV; if (data == SCAN_EQUALS) return KBD_VT_NEXT; } - // And Linux's own: Alt+F1..F6 and Ctrl+Alt+F1..F6, whatever the chord is set - // to. These are what anyone who has used Linux presses, on a real machine, - // and nothing reached them: SchOS answered only AltGr+F-keys, which no one - // thinks to try. Under X the terminal is in VT_PROCESS, console_vt_hotkey() - // lets the keys through, and X switches with them itself -- as on Linux. - if (data >= SCAN_F1 && data < SCAN_F1 + NVT && + // And Linux's own: Alt+F-key and Ctrl+Alt+F-key, whatever the chord is set + // to. Under X the terminal is in VT_PROCESS, console_vt_hotkey() lets the + // keys through, and X switches with them itself -- as on Linux. + if (chord_index(data, SCAN_F1) >= 0 && ((shift & (CTL | SHIFT | ALT | RALT)) == ALT || (shift & (CTL | SHIFT | ALT | RALT)) == (CTL | ALT))) - return KBD_VT_SWITCH + (data - SCAN_F1); + return KBD_VT_SWITCH + chord_index(data, SCAN_F1); cp = keymap_lookup(data, shift); if (cp == 0) diff --git a/kernel/drivers/net/r8169/r8169.c b/kernel/drivers/net/r8169/r8169.c index ddc75dd..522625b 100644 --- a/kernel/drivers/net/r8169/r8169.c +++ b/kernel/drivers/net/r8169/r8169.c @@ -51,6 +51,13 @@ #define R_CONFIG1 0x52 #define R_PHYAR 0x60 // the MII window #define R_PHYSTATUS 0x6C +#define R_PMCH 0x6F // PLL power +#define R_ERIDR 0x70 // extended register window: data +#define R_ERIAR 0x74 // and address +#define R_GPHY_OCP 0xB8 // the PHY's registers, 8168G and later +#define R_MAC_OCP 0xB0 // the MAC's own OCP registers +#define R_MCU 0xD3 +#define R_MISC 0xF0 #define R_RMS 0xDA // largest frame the chip will accept #define R_CPCR 0xE0 // "C+" command #define R_RDSAR 0xE4 // Rx descriptors, 64-bit @@ -77,6 +84,15 @@ #define PHYSTATUS_LINK 0x02 +#define MCU_NOW_IS_OOB 0x80 // the firmware, not a driver, owns the MAC +#define MCU_LL_READY 0x02 // link-list FIFO ready +#define MISC_RXDV_GATE 0x00080000 +#define OCP_FLAG 0x80000000 +#define OCP_STD_PHY_BASE 0xA400 +#define BMCR_PDOWN 0x0800 +#define BMCR_ANENABLE 0x1000 +#define BMCR_ANRESTART 0x0200 + // Rx config: accept frames addressed to us, broadcast and multicast, with the // largest DMA burst and Rx FIFO threshold the chip offers. Promiscuous is // deliberately not set -- a NIC that hands the stack every frame on the wire @@ -128,6 +144,8 @@ struct r8169 { int tx_next; uint8 mac[6]; int ready; + int gen_g; // 8168G or later: see r8169_gen_g() + uint16 ocp_base; // the PHY page selected through register 0x1F uint64 rx_packets, tx_packets; struct spinlock lock; @@ -168,7 +186,7 @@ wr64(struct r8169 *r, int o, uint64 v) #define PHYAR_FLAG 0x80000000 static void -mii_write(struct r8169 *r, int reg, uint16 val) +phyar_write(struct r8169 *r, int reg, uint16 val) { wr32(r, R_PHYAR, PHYAR_FLAG | ((reg & 0x1F) << 16) | val); for (int i = 0; i < 1000; i++) { @@ -179,7 +197,7 @@ mii_write(struct r8169 *r, int reg, uint16 val) } static int -mii_read(struct r8169 *r, int reg) +phyar_read(struct r8169 *r, int reg) { wr32(r, R_PHYAR, (reg & 0x1F) << 16); for (int i = 0; i < 1000; i++) { @@ -191,6 +209,164 @@ mii_read(struct r8169 *r, int reg) return -1; } +// From the 8168G on, the PHY sits behind an OCP window instead: its standard +// registers are at 0xA400 + 2*reg, and "select page N" through register 0x1F +// becomes a base address. PHYAR still answers reads on some of these chips, +// which is how this driver got away without it -- until a write mattered. +static void +ocp_phy_write(struct r8169 *r, uint16 addr, uint16 val) +{ + wr32(r, R_GPHY_OCP, OCP_FLAG | ((uint32)(addr >> 1) << 16) | val); + for (int i = 0; i < 1000; i++) { + microdelay(20); + if (!(rd32(r, R_GPHY_OCP) & OCP_FLAG)) + return; + } +} + +static int +ocp_phy_read(struct r8169 *r, uint16 addr) +{ + wr32(r, R_GPHY_OCP, (uint32)(addr >> 1) << 16); + for (int i = 0; i < 1000; i++) { + microdelay(20); + uint32 v = rd32(r, R_GPHY_OCP); + if (v & OCP_FLAG) + return (int)(v & 0xFFFF); + } + return -1; +} + +static void +mii_write(struct r8169 *r, int reg, uint16 val) +{ + if (!r->gen_g) { + phyar_write(r, reg, val); + return; + } + if (reg == 0x1F) { + r->ocp_base = val ? (uint16)(val << 4) : OCP_STD_PHY_BASE; + return; + } + if (r->ocp_base != OCP_STD_PHY_BASE) + reg -= 0x10; + ocp_phy_write(r, (uint16)(r->ocp_base + reg * 2), val); +} + +static int +mii_read(struct r8169 *r, int reg) +{ + if (!r->gen_g) + return phyar_read(r, reg); + if (r->ocp_base != OCP_STD_PHY_BASE) + reg -= 0x10; + return ocp_phy_read(r, (uint16)(r->ocp_base + reg * 2)); +} + +// The MAC's OCP registers. No completion flag to wait on. +static uint16 +mac_ocp_read(struct r8169 *r, uint16 reg) +{ + wr32(r, R_MAC_OCP, (uint32)(reg >> 1) << 16); + return (uint16)rd32(r, R_MAC_OCP); +} + +static void +mac_ocp_write(struct r8169 *r, uint16 reg, uint16 val) +{ + wr32(r, R_MAC_OCP, OCP_FLAG | ((uint32)(reg >> 1) << 16) | val); +} + +// The extended (ERI) registers, all four bytes at once. +static uint32 +eri_read(struct r8169 *r, int addr) +{ + wr32(r, R_ERIAR, (0xFu << 12) | addr); + for (int i = 0; i < 100; i++) { + microdelay(10); + if (rd32(r, R_ERIAR) & OCP_FLAG) + return rd32(r, R_ERIDR); + } + return 0; +} + +static void +eri_write(struct r8169 *r, int addr, uint32 val) +{ + wr32(r, R_ERIDR, val); + wr32(r, R_ERIAR, OCP_FLAG | (0xFu << 12) | addr); + for (int i = 0; i < 100; i++) { + microdelay(10); + if (!(rd32(r, R_ERIAR) & OCP_FLAG)) + return; + } +} + +// Which XIDs are the 8168G generation and later: 8168G, GU, 8411B, EP, H. +// They share the OCP windows and the firmware handover below. +static int +r8169_gen_g(uint32 xid) +{ + switch (xid) { + case 0x4C0: case 0x4C1: case 0x5C8: case 0x509: + case 0x500: case 0x501: case 0x502: + case 0x540: case 0x541: + return 1; + } + return 0; +} + +static void +wait_ll_ready(struct r8169 *r) +{ + for (int i = 0; i < 42; i++) { + if (rd8(r, R_MCU) & MCU_LL_READY) + return; + microdelay(100); + } +} + +// Taking the chip from the firmware. A laptop that was last running Windows, +// or that keeps Wake-on-LAN armed, hands it over in out-of-band mode with the +// receive path gated off: the MAC answers its registers and moves no frames. +static void +r8169_takeover_g(struct r8169 *r) +{ + wr32(r, R_MISC, rd32(r, R_MISC) | MISC_RXDV_GATE); + wr8(r, R_CR, rd8(r, R_CR) & ~(CR_TE | CR_RE)); + microdelay(1000); + wr8(r, R_MCU, rd8(r, R_MCU) & ~MCU_NOW_IS_OOB); + mac_ocp_write(r, 0xE8DE, mac_ocp_read(r, 0xE8DE) & ~(1u << 14)); + wait_ll_ready(r); + mac_ocp_write(r, 0xE8DE, mac_ocp_read(r, 0xE8DE) | (1u << 15)); + wait_ll_ready(r); +} + +// The PHY up and negotiating: page 0, out of power-down, autoneg on and +// restarted. Power-down is what firmware leaves set after a shutdown, and +// the old code ORed autoneg into BMCR and kept it. +static void +r8169_phy_up(struct r8169 *r) +{ + mii_write(r, 0x1F, 0); + int bmcr = mii_read(r, 0); + if (bmcr < 0) + return; + bmcr &= ~BMCR_PDOWN; + mii_write(r, 0, (uint16)bmcr); + if (r->gen_g) { + // Wait for the PHY to report it is on: its state, low three bits of + // OCP 0xA420, reads 3 once it is. + for (int i = 0; i < 100; i++) { + int st = ocp_phy_read(r, 0xA420); + if (st >= 0 && (st & 7) == 3) + break; + microdelay(1000); + } + } + mii_write(r, 0, (uint16)(bmcr | BMCR_ANENABLE | BMCR_ANRESTART)); +} + // --- the class ops ---------------------------------------------------------- static const unsigned char * @@ -392,6 +568,7 @@ r8169_probe(struct pci_dev *d) } r->pdev = d; + r->ocp_base = OCP_STD_PHY_BASE; initlock(&r->lock, "r8169"); pci_enable(d, PCI_CMD_MEMORY | PCI_CMD_BUS_MASTER); @@ -404,6 +581,10 @@ r8169_probe(struct pci_dev *d) return -1; } + r->gen_g = r8169_gen_g((rd32(r, R_TCR) >> 20) & 0xFCF); + if (r->gen_g) + r8169_takeover_g(r); + // Soft reset, and wait for the chip to say it finished. wr8(r, R_CR, CR_RST); for (int i = 0; i < 1000; i++) { @@ -426,6 +607,16 @@ r8169_probe(struct pci_dev *d) wr8(r, R_9346CR, C9346_UNLOCK); // config registers are writable now + if (r->gen_g) { + // The PLL powered, the extra ERI bits the 8168H wants with it, and the + // receive gate opened again. + wr8(r, R_PMCH, rd8(r, R_PMCH) | 0xC0); + uint32 xid = (rd32(r, R_TCR) >> 20) & 0xFCF; + if (xid == 0x540 || xid == 0x541) + eri_write(r, 0x1A8, eri_read(r, 0x1A8) | 0xFC000000); + wr32(r, R_MISC, rd32(r, R_MISC) & ~MISC_RXDV_GATE); + } + // CPCR is deliberately left alone. // // An earlier version set bit 3 here with a comment calling it "PCI multiple @@ -455,14 +646,7 @@ r8169_probe(struct pci_dev *d) wr8(r, R_CR, CR_RE | CR_TE); wr8(r, R_9346CR, C9346_LOCK); - // Restart autonegotiation. The PHY normally does this out of reset, and - // asking again costs nothing and covers the case where firmware left it - // parked -- BMCR bit 12 is autoneg enable, bit 9 restarts it. - { - int bmcr = mii_read(r, 0); - if (bmcr >= 0) - mii_write(r, 0, (uint16)(bmcr | 0x1200)); - } + r8169_phy_up(r); wr16(r, R_ISR, 0xFFFF); // clear anything left over wr16(r, R_IMR, ISR_ROK | ISR_RER | ISR_TOK | ISR_TER | diff --git a/kernel/drivers/usb/hid/usb_hid.c b/kernel/drivers/usb/hid/usb_hid.c index f5b0bc5..1f9a593 100644 --- a/kernel/drivers/usb/hid/usb_hid.c +++ b/kernel/drivers/usb/hid/usb_hid.c @@ -164,9 +164,10 @@ usb_kbd_getc(void) static int hid_special(uchar usage, uchar mods) { - // F1..F6 with Alt, AltGr or Ctrl+Alt: switch terminals, as on Linux. + // F1..F12 with left Alt or Ctrl+Alt: switch terminals, as on Linux and as + // the PS/2 path does. AltGr alone is a layout's third level, not this. if (usage >= 0x3A && usage < 0x3A + NVT && (mods & HID_MOD_ALT) && - !(mods & HID_MOD_SHIFT)) + !(mods & HID_MOD_SHIFT) && ((mods & 0x04) || (mods & HID_MOD_CTRL))) return KBD_VT_SWITCH + (usage - 0x3A); if (usage >= 0x3A && usage <= 0x45) return KBD_F1 + (usage - 0x3A); diff --git a/kernel/fs/devfs.c b/kernel/fs/devfs.c index 0703d29..cca520c 100644 --- a/kernel/fs/devfs.c +++ b/kernel/fs/devfs.c @@ -101,6 +101,12 @@ static const struct devnode devnodes[] = { { "tty4", 1, 14, D_ROOT }, { "tty5", 1, 15, D_ROOT }, { "tty6", 1, 16, D_ROOT }, + { "tty7", 1, 17, D_ROOT }, + { "tty8", 1, 18, D_ROOT }, + { "tty9", 1, 19, D_ROOT }, + { "tty10", 1, 20, D_ROOT }, + { "tty11", 1, 21, D_ROOT }, + { "tty12", 1, 22, D_ROOT }, { "stdin", 1, 1, D_ROOT }, { "stdout", 1, 1, D_ROOT }, { "stderr", 1, 1, D_ROOT }, diff --git a/kernel/net/lwip/netif_eth.c b/kernel/net/lwip/netif_eth.c index 356b12b..a16b5dd 100644 --- a/kernel/net/lwip/netif_eth.c +++ b/kernel/net/lwip/netif_eth.c @@ -259,3 +259,28 @@ net_eth_poll(void) } return moved; } + +// Follow each device's link into lwIP. +// +// The link was read once, at attach, and never again. Under QEMU it is up +// before the driver asks; on a real network autonegotiation takes two or +// three seconds, so attach saw it down and the interface stayed down for +// good -- no DHCP, no traffic, the cable plugged in and nothing to show for +// it. Bringing the link up also restarts DHCP (lwIP does that itself). +void +net_eth_link_check(void) +{ + for (int k = 0; k < nifs; k++) { + struct eth_if *e = &ifs[k]; + if (!e->used) + continue; + int up = netdev_link_up(e->dev) != 0; + if (up == (netif_is_link_up(&e->netif) != 0)) + continue; + if (up) + netif_set_link_up(&e->netif); + else + netif_set_link_down(&e->netif); + klog("net: %s link %s\n", e->dev->name, up ? "up" : "down"); + } +} diff --git a/kernel/net/net.c b/kernel/net/net.c index 095274a..5fbc2b7 100644 --- a/kernel/net/net.c +++ b/kernel/net/net.c @@ -292,12 +292,19 @@ net_thread(void) // One bit per interface, so a second one binding later is announced too. + uint link_checked = 0; for (;;) { int moved; lwip_sys_lock(); moved = net_eth_poll(); sys_check_timeouts(); + // A quarter of a second is quick enough to notice a cable, and a + // register read per interface that often costs nothing. + if (ticks - link_checked >= 25) { + link_checked = ticks; + net_eth_link_check(); + } // Loopback has no driver and no interrupt: a packet sent to 127.0.0.1 // sits on the loopif's queue until something drains it. With NO_SYS=1 // that something is this thread. diff --git a/kernel/net/unix.c b/kernel/net/unix.c index f4dce89..ff1b1aa 100644 --- a/kernel/net/unix.c +++ b/kernel/net/unix.c @@ -271,6 +271,7 @@ sockopt_set(struct socket *s, int level, int name, int val) case SO_REUSEADDR: s->o_reuseaddr = !!val; break; case SO_KEEPALIVE: s->o_keepalive = !!val; break; case SO_BROADCAST: s->o_broadcast = !!val; break; + case 16: s->o_passcred = !!val; break; // SO_PASSCRED // Accepted and left at the size we have. Not the same lie as before: // Linux is explicitly free to clamp these, and sockopt_get below reports // the real figure rather than the requested one. @@ -313,6 +314,7 @@ sockopt_get(struct socket *s, int level, int name, int *out) case SO_REUSEADDR: *out = s->o_reuseaddr; break; case SO_KEEPALIVE: *out = s->o_keepalive; break; case SO_BROADCAST: *out = s->o_broadcast; break; + case 16: *out = s->o_passcred; break; // SO_PASSCRED // The real buffer, not a number somebody asked for. case SO_SNDBUF: case SO_RCVBUF: *out = SOCKBUF; break; @@ -1025,6 +1027,12 @@ socksendto(struct socket *s, char *buf, int n, uint addr, ushort port) return sockwrite(s, buf, n); } +int +sock_passcred(struct socket *s) +{ + return s->domain != AF_INET && s->o_passcred; +} + // SO_PEERCRED: the credentials recorded for the far end of a connected Unix // socket. -1 if there is no far end. int diff --git a/kernel/sys/sys_mem.c b/kernel/sys/sys_mem.c index 3908ee7..b57e84e 100644 --- a/kernel/sys/sys_mem.c +++ b/kernel/sys/sys_mem.c @@ -123,6 +123,81 @@ mmap_va(struct proc *p, uint64 len, int mergeable) return va; } +// --- demand paging --------------------------------------------------------- +// +// Anonymous memory may be recorded without being backed: the page appears, +// zeroed, the first time it is touched -- by the program, through trap(), or +// by the kernel on its behalf, through uva2ka(). Large mappings are made this +// way, and so are the ones that say outright they will not all be used. +// WebKit reserves 4 GB of address space at start for its JIT and heaps and +// touches a sliver of it; backing all of it at mmap time failed with ENOMEM +// and Epiphany aborted. Go, Java and Firefox reserve the same way. +// +// fork and munmap already skip the holes in anonymous memory, so a page that +// was never touched is simply one they do not see, and a child that touches +// it gets zeroes -- which is what it would have read in the parent. + +#define LAZY_MIN (1024 * 1024) + +static int +mmap_lazy(int prot, int flags, uint64 length) +{ + return (flags & MAP_NORESERVE) || prot == PROT_NONE || length >= LAZY_MIN; +} + +static struct spinlock faultlock; +static int faultlock_ready; + +// Back the page at va if it lies in this process's anonymous memory and is +// not there yet. 0 if it is there now, -1 if this is a real fault. Takes a +// spinlock and nothing that sleeps, so uva2ka() may call it from anywhere; +// the lock is what keeps two threads of one process from both mapping it. +int +vm_fault(addr_t va) +{ + struct proc *p = myproc(); + pte_t *pte; + char *mem; + int found = 0; + + if (!p || !p->mm || va >= KERNBASE) + return -1; + va = PGROUNDDOWN(va); + for (int i = 0; i < NVMA; i++) { + struct vma *v = &p->mm->vma[i]; + if (vma_anon(v) && v->addr <= va && va < v->addr + v->length) { + found = 1; + break; + } + } + if (!found) + return -1; + if ((mem = kalloc()) == 0) + return -1; + memset(mem, 0, PGSIZE); + if (!faultlock_ready) { + initlock(&faultlock, "vmfault"); + faultlock_ready = 1; + } + acquire(&faultlock); + pte = walkpte(p->pgdir, (void *)va); + if (pte && (*pte & PTE_P)) { + // Another thread got here first, or the page was never missing and + // this is a protection fault: either way, not ours to fix. + int ours = (*pte & PTE_U) != 0; + release(&faultlock); + kfree(mem); + return ours ? 0 : -1; + } + if (mappages(p->pgdir, (void *)va, PGSIZE, V2P((uint64)mem), PTE_W | PTE_U) < 0) { + release(&faultlock); + kfree(mem); + return -1; + } + release(&faultlock); + return 0; +} + // Record [va, va+len) as anonymous memory wherever no mapping already covers // it, for a MAP_FIXED that may land anywhere: inside an earlier mapping, across // the end of one, or in a gap. Below sz is the program image and its heap, @@ -205,8 +280,16 @@ mmap_locked(void) if (bad_user_range(va, length) || (va % PGSIZE)) return -EINVAL; vm_unmap_range(myproc()->pgdir, va, length); - if (prot == PROT_NONE) + int fixed_file = f && f->type == FD_INODE && f->ip->type != T_DEV && + !(flags & MAP_ANONYMOUS); + // Reserved, not backed. PROT_NONE is recorded too: mprotect() does not + // change pages here, so a range reserved PROT_NONE and opened up later + // has to be memory that can appear when it is touched. + if (!fixed_file && mmap_lazy(prot, flags, length)) { + if (vma_cover_anon(myproc(), va, length) < 0) + return -ENOMEM; return va; + } for (uint64 a = va; a < va + length; a += PGSIZE) { char *mem = kalloc(); if (!mem) return -ENOMEM; @@ -407,6 +490,11 @@ mmap_locked(void) // to the text console. fbclose() puts it back. fb_mapped(); } else { + if (mmap_lazy(prot, flags, length)) { + if (vma_note_anon(myproc(), va, length) < 0) + return -ENOMEM; + return va; + } for (uint64 a = va; a < va + length; a += PGSIZE) { char *mem = kalloc(); if (!mem) { diff --git a/kernel/sys/sys_net.c b/kernel/sys/sys_net.c index 359bda6..179bf28 100644 --- a/kernel/sys/sys_net.c +++ b/kernel/sys/sys_net.c @@ -1030,7 +1030,7 @@ SYSCALL_DEFINE(recvmsg) struct file *file; struct msghdr m; struct file *fs[SCM_MAXFD]; - char cbuf[CMSG_SPACE(SCM_MAXFD * sizeof(int))]; + char cbuf[CMSG_SPACE(SCM_MAXFD * sizeof(int)) + CMSG_SPACE(12)]; int ufd[SCM_MAXFD]; addr_t umsg; uint64 i, clen = 0; @@ -1141,6 +1141,35 @@ SYSCALL_DEFINE(recvmsg) } } + // SO_PASSCRED: who sent it, after any descriptors. On a connected Unix + // socket that is always the peer, so its recorded credentials are the + // answer. WebKit sets the option on its IPC sockets and aborts if it is + // refused. + if (sock_passcred(s) && got > 0) { + struct { int pid; uint uid, gid; } cr; + uint64 off = clen ? CMSG_SPACE(clen - CMSG_LEN(0)) : 0; + if (sock_peercred(s, &cr.pid, &cr.uid, &cr.gid) == 0) { + if (m.msg_control && off + CMSG_LEN(sizeof(cr)) <= m.msg_controllen) { + struct cmsghdr c; + char tmp[CMSG_SPACE(sizeof(cr))]; + memset(tmp, 0, sizeof(tmp)); + c.cmsg_len = CMSG_LEN(sizeof(cr)); + c.cmsg_level = SOL_SOCKET; + c.cmsg_type = 2; // SCM_CREDENTIALS + memmove(tmp, &c, sizeof(c)); + memmove(CMSG_DATA((struct cmsghdr *)tmp), &cr, sizeof(cr)); + uint64 n2 = CMSG_SPACE(sizeof(cr)); + if (off + n2 > m.msg_controllen) + n2 = CMSG_LEN(sizeof(cr)); + if (copy_to_user((addr_t)m.msg_control + off, tmp, n2) < 0) + return -EFAULT; + clen = off + n2; + } else { + mflags |= MSG_CTRUNC; + } + } + } + // The address, for the family that has one. Same rule as recvfrom: a Unix // socket reports a length of zero rather than a sockaddr full of nothing. if (m.msg_name) {