site: rebuilt on Niveus, and the palette stops being a copy

Niveus is the interface language the project uses from here on -- this site
first, applications after it. Rebuilding the site on it is what proves the
system is one, rather than a folder of CSS with a README.

**`style.css` is gone.** 21 KB that transcribed include/theme.h by hand:
palette, type scale, shadows, spacing, every component. The intent was right --
"a site describing an operating system should look like the operating system"
-- and the mechanism was a copy, which means two files that have to be kept in
step by remembering to. What replaced it is `app.css`, which is layout and
nothing else, and one declaration in each page:

    :root { --nv-brand: #7C5CFF; }

That is SCH_ACCENT. Niveus derives the accent and its states, the link colour,
the focus ring, the tint in the greys and how far the status colours lean, for
light and dark both. One number to keep true instead of forty.

**The site is now adaptive.** Dark by default, because the OS is dark, with a
Light/Dark/Auto group in the bar that is real. The choice is kept in
localStorage and re-applied by an inline script before first paint, so the page
never renders in one appearance and corrects itself while you watch.

**PSOGM keeps its one purple, and now says so structurally.** #4B1D9A is
spelled the way Niveus spells a subtree with its own colour -- `data-nv-brand`
with its own `--nv-brand` on that section -- so every role inside re-derives
from it rather than being painted on. Still exactly one place, which is what
branding/README.md asks for.

Content is unchanged; what changed is what it is made of. Cards are `.nv-card`,
tags are `.nv-badge`, the support table is `.nv-table`, the crash section is a
`.nv-bed`, and the docs reader's two rails are `.nv-nav--vertical` with the
current document marked by `aria-current` rather than a class of our own.
`docs.js` emits `.nv-table` and `.nv-code`, so a change to the system reaches
the documentation too.

Three things were found by looking at it in a browser rather than by reading
it, and all three are the same mistake: **a `.nv-card` carries no padding of
its own -- its parts do.** The stat tiles, the "One ISO, one stick" band and
the card headers were all built from bare children, so numbers ran to the card
edge, the USB mark hung off it, and a title and its badge sat at opposite ends
of a row meant for a title and a trailing slot. Where the component had a right
way to say it, the markup now says it that way; where it did not -- a tile has
no parts -- `app.css` supplies the padding and says why.

Checked: every nv- class used across the three pages and docs.js exists in the
CSS, every token referenced by app.css is defined, and app.css contains no hex
value at all. Niveus's own tools/check.py passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-09-23 14:40:09 +02:00
commit 6490e5c759
89 changed files with 20804 additions and 1363 deletions

View file

@ -0,0 +1,101 @@
/* ==========================================================================
Niveus — Badge
--------------------------------------------------------------------------
<span class="nv-badge nv-badge--success">In stock</span>
<span class="nv-badge nv-badge--danger nv-badge--solid">3 failing</span>
<span class="nv-badge"><span class="nv-badge__dot"></span>Draft</span>
A small, non-interactive statement of state. Subtle by default, because a
badge is almost always beside something more important than itself; --solid
is for the one badge in a view that must be seen before anything else.
If it can be clicked it is not a badge — that is a button, probably
.nv-button--pill --sm.
========================================================================== */
.nv-badge {
--_bg: var(--nv-color-surface-inset);
--_fg: var(--nv-color-text-secondary);
--_border: var(--nv-color-border);
--_solid-bg: var(--nv-color-text-secondary);
--_solid-fg: var(--nv-color-surface);
display: inline-flex;
align-items: center;
gap: var(--nv-space-1-5);
block-size: var(--nv-space-5);
padding-inline: var(--nv-space-2);
background-color: var(--_bg);
color: var(--_fg);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--nv-radius-full);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
white-space: nowrap;
transition: var(--nv-transition-colors);
}
.nv-badge--accent {
--_bg: var(--nv-color-accent-subtle);
--_fg: var(--nv-color-accent-text);
--_border: var(--nv-color-accent-border);
--_solid-bg: var(--nv-color-accent);
--_solid-fg: var(--nv-color-on-accent);
}
.nv-badge--success {
--_bg: var(--nv-color-success-subtle);
--_fg: var(--nv-color-success-text);
--_border: var(--nv-color-success-border);
--_solid-bg: var(--nv-color-success);
--_solid-fg: var(--nv-color-on-success);
}
.nv-badge--warning {
--_bg: var(--nv-color-warning-subtle);
--_fg: var(--nv-color-warning-text);
--_border: var(--nv-color-warning-border);
--_solid-bg: var(--nv-color-warning);
--_solid-fg: var(--nv-color-on-warning);
}
.nv-badge--danger {
--_bg: var(--nv-color-danger-subtle);
--_fg: var(--nv-color-danger-text);
--_border: var(--nv-color-danger-border);
--_solid-bg: var(--nv-color-danger);
--_solid-fg: var(--nv-color-on-danger);
}
/* Declared after the colors so it wins for --_bg; the color modifier is still
what decides which solid pair it reads. */
.nv-badge--solid {
--_bg: var(--_solid-bg);
--_fg: var(--_solid-fg);
--_border: transparent;
}
.nv-badge--square {
border-radius: var(--nv-radius-xs);
}
/* Counts read better without a border, and with room for two digits. */
.nv-badge--count {
min-inline-size: var(--nv-space-5);
padding-inline: var(--nv-space-1-5);
justify-content: center;
border-color: transparent;
font-size: var(--nv-font-size-2xs);
font-variant-numeric: tabular-nums;
}
.nv-badge__dot {
inline-size: var(--nv-space-1-5);
block-size: var(--nv-space-1-5);
border-radius: var(--nv-radius-full);
background-color: currentColor;
flex: none;
}

View file

@ -0,0 +1,101 @@
/* ==========================================================================
Niveus — Bed
--------------------------------------------------------------------------
<div class="nv-bed"> … </div>
A bed is ground that is visibly *not* ordinary background. Where a card says
"this content belongs together", a bed says "what sits here is different in
kind" — a feature being shown off, a preview, a sandbox, an empty state, a
region under explanation.
It reads that way because of what it does to the materials on top of it: the
hatch and the soft color blooms give translucent surfaces something to be
translucent *against*, and give shadows something to fall on. A frosted panel
over a flat grey proves nothing; over a bed it is obvious.
That is also the rule for using one. A bed is for regions the user should
look *at*. Put content they need to work *through* on a plain surface —
patterning behind a form or a table is noise, and the calm is the point.
Everything it is made of derives from the accent, so it follows the brand.
Set --nv-bed-tint to lead with a different color.
========================================================================== */
.nv-bed {
--nv-bed-tint: var(--nv-color-accent);
--_bloom: 16%;
--_hatch: var(--nv-color-border-subtle);
position: relative;
padding: var(--nv-space-6);
background-color: var(--nv-color-background-subtle);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-xl);
/* The hatch sits over the blooms, and both sit under the content: three
background layers rather than a pseudo-element, so nothing has to be
lifted out of the way. */
background-image:
repeating-linear-gradient(
45deg,
var(--_hatch) 0 2px,
transparent 2px 14px
),
radial-gradient(
60% 80% at 15% 20%,
color-mix(in oklab, var(--nv-bed-tint) var(--_bloom), transparent),
transparent 70%
),
radial-gradient(
50% 70% at 85% 25%,
color-mix(in oklab, var(--nv-color-danger) calc(var(--_bloom) * 0.7), transparent),
transparent 70%
),
radial-gradient(
70% 90% at 50% 100%,
color-mix(in oklab, var(--nv-color-success) calc(var(--_bloom) * 0.7), transparent),
transparent 70%
);
overflow: hidden;
isolation: isolate;
transition: var(--nv-transition-surface);
}
/* Just the ground, no weather: for grouping a region without drawing the eye
to it. */
.nv-bed--quiet {
--_bloom: 8%;
--_hatch: transparent;
}
.nv-bed--vivid {
--_bloom: 26%;
}
/* One color instead of three — a bed that belongs to a single state. */
.nv-bed--single {
background-image:
repeating-linear-gradient(45deg, var(--_hatch) 0 2px, transparent 2px 14px),
radial-gradient(
80% 100% at 30% 0%,
color-mix(in oklab, var(--nv-bed-tint) var(--_bloom), transparent),
transparent 75%
);
}
.nv-bed--flush {
border-radius: 0;
border-inline: 0;
}
.nv-bed__label {
display: block;
margin-block-end: var(--nv-space-4);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}

View file

@ -0,0 +1,282 @@
/* ==========================================================================
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;
}

View file

@ -0,0 +1,171 @@
/* ==========================================================================
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;
}

View file

@ -0,0 +1,190 @@
/* ==========================================================================
Niveus — Dialog
--------------------------------------------------------------------------
Built on the native <dialog> element, so focus trapping, the top layer,
Escape-to-close and inertness come from the platform rather than from
script.
<dialog class="nv-dialog"> … </dialog> opened with .showModal()
.nv-dialog--sheet enters from the block end
.nv-dialog--wide | --compact width
.nv-dialog__header / __body / __footer / __close
The dialog is the system's highest material: elevation 4 over a blurred
scrim. It enters by rising and settling, and leaves the way it came — the
motion is what tells the user the surface was always there, above the page.
========================================================================== */
.nv-dialog {
--_width: 30rem;
--_padding: var(--nv-space-6);
inline-size: min(var(--_width), calc(100vw - var(--nv-space-8)));
max-block-size: min(42rem, calc(100svh - var(--nv-space-12)));
padding: 0;
background-color: var(--nv-color-surface-elevated);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-xl);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-4);
overflow: hidden;
/* Closed state. [open] below is the resting state; @starting-style replays
these values for the entrance. */
/* A dialog should look as though it came from the control that opened it.
nv.openDialog() sets --nv-origin to that control's position relative to
the dialog, so the entrance scale expands away from the trigger rather
than from the middle of the screen. Centred when nothing sets it. */
transform-origin: var(--nv-origin, 50% 50%);
opacity: 0;
scale: 0.97;
translate: 0 var(--nv-travel-md);
transition:
opacity var(--nv-duration-normal) var(--nv-ease-standard),
scale var(--nv-duration-normal) var(--nv-ease-standard),
translate var(--nv-duration-normal) var(--nv-ease-standard),
display var(--nv-duration-normal) allow-discrete,
overlay var(--nv-duration-normal) allow-discrete;
}
.nv-dialog[open] {
opacity: 1;
scale: 1;
translate: 0;
transition-timing-function: var(--nv-ease-entrance);
}
@starting-style {
.nv-dialog[open] {
opacity: 0;
scale: 0.97;
translate: 0 var(--nv-travel-md);
}
}
.nv-dialog::backdrop {
background-color: var(--nv-color-scrim);
backdrop-filter: var(--nv-backdrop-scrim);
-webkit-backdrop-filter: var(--nv-backdrop-scrim);
opacity: 0;
transition:
opacity var(--nv-duration-normal) var(--nv-ease-standard),
display var(--nv-duration-normal) allow-discrete,
overlay var(--nv-duration-normal) allow-discrete;
}
.nv-dialog[open]::backdrop {
opacity: 1;
}
@starting-style {
.nv-dialog[open]::backdrop {
opacity: 0;
}
}
/* showModal() does not stop the page behind from scrolling. */
html:has(.nv-dialog:modal) {
overflow: hidden;
}
/* --- Widths -------------------------------------------------------------------- */
.nv-dialog--compact { --_width: 24rem; }
.nv-dialog--wide { --_width: 44rem; }
/* --- Sheet ----------------------------------------------------------------------
Anchored to the block end and full width. On small screens this is usually
the right form for anything with more than two actions. */
.nv-dialog--sheet {
--_width: 34rem;
/* Flush to the viewport edges — a sheet with side gaps and square bottom
corners reads as a mistake rather than as a sheet. */
inline-size: min(var(--_width), 100vw);
max-block-size: min(46rem, calc(100svh - var(--nv-space-10)));
margin-block-end: 0;
margin-block-start: auto;
border-start-start-radius: var(--nv-radius-xl);
border-start-end-radius: var(--nv-radius-xl);
border-end-start-radius: 0;
border-end-end-radius: 0;
scale: 1;
translate: 0 var(--nv-travel-lg);
}
@starting-style {
.nv-dialog--sheet[open] {
scale: 1;
translate: 0 var(--nv-travel-lg);
}
}
/* --- Parts ------------------------------------------------------------------------ */
.nv-dialog__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--nv-space-4);
padding: var(--_padding);
padding-block-end: var(--nv-space-3);
}
.nv-dialog__title {
font: var(--nv-font-heading);
font-size: var(--nv-font-size-xl);
letter-spacing: var(--nv-letter-spacing-tight);
margin: 0;
}
.nv-dialog__description {
font: var(--nv-font-body);
color: var(--nv-color-text-secondary);
margin-block-start: var(--nv-space-2);
}
.nv-dialog__body {
padding: var(--_padding);
padding-block: var(--nv-space-2);
overflow-y: auto;
overscroll-behavior: contain;
color: var(--nv-color-text-secondary);
}
.nv-dialog__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--nv-space-2);
padding: var(--_padding);
padding-block-start: var(--nv-space-4);
}
/* A footer over scrollable content needs its own ground: the inset tint and
hairline tell the user there is more above it. */
.nv-dialog__footer--divided {
background-color: var(--nv-color-background-subtle);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.nv-dialog__close {
flex: none;
margin: calc(var(--nv-space-1) * -1);
}
/* A dialog laid out as a column keeps its header and footer fixed while only
the body scrolls. */
.nv-dialog:has(.nv-dialog__body) {
display: flex;
flex-direction: column;
}
.nv-dialog:not([open]) {
display: none;
}

View file

@ -0,0 +1,130 @@
/* ==========================================================================
Niveus — Disclosure
--------------------------------------------------------------------------
<details class="nv-disclosure">
<summary class="nv-disclosure__summary"> … </summary>
<div class="nv-disclosure__body"> … </div>
</details>
Content expands out of the row that revealed it, at the position it already
occupies, rather than appearing at full height. The height animates because
the continuity layer turns on `interpolate-size`, which is what lets
block-size: auto take part in a transition — no measurement, no script, and
no wrapper whose height has to be kept in sync.
.nv-collapse in continuity.css covers the same relationship for markup that
cannot be a <details>.
========================================================================== */
.nv-disclosure {
--_padding: var(--nv-space-4);
background-color: var(--nv-color-surface);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-lg);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
transition: var(--nv-transition-surface);
}
.nv-disclosure--plain {
background-color: transparent;
border-color: transparent;
box-shadow: none;
}
.nv-disclosure__summary {
display: flex;
align-items: center;
gap: var(--nv-space-3);
padding: var(--nv-space-3) var(--_padding);
font: var(--nv-font-label);
color: var(--nv-color-text-primary);
border-radius: inherit;
cursor: pointer;
user-select: none;
list-style: none;
transition: var(--nv-transition-colors);
}
/* Both spellings are needed to suppress the native marker. */
.nv-disclosure__summary::marker,
.nv-disclosure__summary::-webkit-details-marker {
display: none;
content: "";
}
@media (hover: hover) {
.nv-disclosure__summary:hover {
background-color: var(--nv-color-surface-hover);
}
}
.nv-disclosure__summary:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
/* The chevron turns rather than swapping to a different glyph — the rotation
is what says "this is the same control in its other state". */
.nv-disclosure__chevron {
inline-size: 0.5em;
block-size: 0.5em;
margin-inline-start: auto;
margin-block-start: -0.2em;
flex: none;
border-inline-end: 1.5px solid var(--nv-color-text-tertiary);
border-block-end: 1.5px solid var(--nv-color-text-tertiary);
rotate: 45deg;
transition: rotate var(--nv-duration-normal) var(--nv-ease-standard);
}
.nv-disclosure[open] .nv-disclosure__chevron {
rotate: 225deg;
margin-block-start: 0.15em;
}
.nv-disclosure__body {
padding: 0 var(--_padding) var(--_padding);
color: var(--nv-color-text-secondary);
font: var(--nv-font-body);
}
/* The element the browser creates for a <details> body. Transitioning
content-visibility as a discrete property is what keeps the content
rendered while it collapses, instead of vanishing on the first frame. */
.nv-disclosure::details-content {
block-size: 0;
overflow: hidden;
opacity: 0;
transition:
block-size var(--nv-duration-expand) var(--nv-ease-standard),
opacity var(--nv-duration-expand) var(--nv-ease-standard),
content-visibility var(--nv-duration-expand) allow-discrete;
}
.nv-disclosure[open]::details-content {
block-size: auto;
opacity: 1;
}
/* Stacked disclosures read as one control surface, so only the outer corners
round and neighbours share a single hairline. */
.nv-disclosure-group {
display: flex;
flex-direction: column;
}
/* The negative margin collapses each pair of adjacent borders into a single
hairline, so neighbours are separated once rather than twice. */
.nv-disclosure-group > .nv-disclosure:not(:first-child) {
border-start-start-radius: 0;
border-start-end-radius: 0;
margin-block-start: calc(var(--nv-border-width) * -1);
}
.nv-disclosure-group > .nv-disclosure:not(:last-child) {
border-end-start-radius: 0;
border-end-end-radius: 0;
}

View file

@ -0,0 +1,402 @@
/* ==========================================================================
Niveus — Input
--------------------------------------------------------------------------
.nv-field label + control + hint, correctly spaced
.nv-input text input, textarea and select
.nv-input-group control with a leading or trailing affix
.nv-select wrapper that supplies the chevron
.nv-checkbox / .nv-radio custom marks on the native controls
.nv-switch binary control with an inset track
Inputs are the system's inset material: they read as recessed into the
surface that holds them, which is what distinguishes a place to type from a
thing to press. On focus the recess stays — only the ring and border change,
so the field never appears to jump forward.
========================================================================== */
.nv-field {
display: flex;
flex-direction: column;
gap: var(--nv-space-1-5);
min-inline-size: 0;
}
.nv-field__label {
font: var(--nv-font-label);
color: var(--nv-color-text-primary);
}
.nv-field__label[data-optional]::after {
content: " " attr(data-optional);
color: var(--nv-color-text-tertiary);
font-weight: var(--nv-font-weight-regular);
}
.nv-field__hint {
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
}
.nv-field__error {
display: flex;
align-items: center;
gap: var(--nv-space-1-5);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-medium);
color: var(--nv-color-danger-text);
}
/* --- Control ----------------------------------------------------------------- */
.nv-input {
--_height: var(--nv-control-height-md);
--_padding: var(--nv-control-padding-md);
--_font-size: var(--nv-font-size-md);
--_radius: var(--nv-radius-md);
--_border: var(--nv-color-border);
inline-size: 100%;
min-inline-size: 0;
block-size: var(--_height);
padding-inline: var(--_padding);
background-color: var(--nv-color-surface-inset);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--_radius);
box-shadow: var(--nv-elevation-inset-sm);
font-family: var(--nv-font-family-sans);
font-size: var(--_font-size);
line-height: var(--nv-line-height-snug);
transition: var(--nv-transition-control);
appearance: none;
}
.nv-input::placeholder {
color: var(--nv-color-text-tertiary);
}
@media (hover: hover) {
.nv-input:hover:not(:disabled, [readonly]) {
--_border: var(--nv-color-border-strong);
}
}
.nv-input:focus-visible,
.nv-input:focus {
outline: none;
--_border: var(--nv-color-accent);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-focus-ring);
}
.nv-input[aria-invalid="true"] {
--_border: var(--nv-color-danger);
--nv-color-focus-ring: var(--nv-color-danger-border);
}
.nv-input:disabled {
background-color: var(--nv-color-background-subtle);
color: var(--nv-color-text-disabled);
box-shadow: none;
cursor: not-allowed;
}
.nv-input[readonly] {
background-color: transparent;
box-shadow: none;
}
/* Multi-line controls grow, so height becomes a minimum and the block padding
has to be stated explicitly. */
textarea.nv-input {
block-size: auto;
min-block-size: calc(var(--nv-control-height-lg) * 2);
padding-block: var(--nv-space-2-5);
line-height: var(--nv-line-height-normal);
}
.nv-input--sm {
--_height: var(--nv-control-height-sm);
--_padding: var(--nv-control-padding-sm);
--_font-size: var(--nv-font-size-sm);
--_radius: var(--nv-radius-sm);
}
/* A color well: the swatch fills the control, so the value is the control. */
.nv-input--color {
inline-size: var(--nv-control-height-md);
padding: var(--nv-space-1);
cursor: pointer;
}
.nv-input--color::-webkit-color-swatch-wrapper {
padding: 0;
}
.nv-input--color::-webkit-color-swatch,
.nv-input--color::-moz-color-swatch {
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: calc(var(--nv-radius-md) - var(--nv-space-1));
}
.nv-input--lg {
--_height: var(--nv-control-height-lg);
--_padding: var(--nv-control-padding-lg);
--_font-size: var(--nv-font-size-lg);
}
/* --- Affixes -------------------------------------------------------------------
<div class="nv-input-group">
<span class="nv-input-group__affix">@</span>
<input class="nv-input">
</div>
The affix sits inside the field's recess rather than beside it, so the group
still reads as a single surface. */
.nv-input-group {
position: relative;
display: flex;
align-items: center;
inline-size: 100%;
}
.nv-input-group__affix {
position: absolute;
z-index: 1;
display: flex;
align-items: center;
padding-inline: var(--nv-control-padding-md);
color: var(--nv-color-text-tertiary);
font-size: var(--nv-font-size-sm);
pointer-events: none;
}
.nv-input-group__affix:first-child {
inset-inline-start: 0;
}
.nv-input-group__affix:last-child {
inset-inline-end: 0;
}
.nv-input-group__affix:first-child ~ .nv-input {
padding-inline-start: calc(var(--nv-control-padding-md) * 2 + 1em);
}
.nv-input:has(~ .nv-input-group__affix:last-child) {
padding-inline-end: calc(var(--nv-control-padding-md) * 2 + 1em);
}
/* Interactive affixes — a clear button, a reveal toggle — opt back in. */
.nv-input-group__affix > * {
pointer-events: auto;
}
/* --- Select ----------------------------------------------------------------------
<div class="nv-select"><select class="nv-input">…</select></div> */
.nv-select {
position: relative;
display: flex;
inline-size: 100%;
}
.nv-select > .nv-input {
padding-inline-end: calc(var(--nv-control-padding-md) * 2 + 0.5em);
cursor: pointer;
}
.nv-select::after {
content: "";
position: absolute;
inset-inline-end: var(--nv-control-padding-md);
inset-block-start: 50%;
inline-size: 0.45em;
block-size: 0.45em;
margin-block-start: -0.28em;
border-inline-end: 1.5px solid var(--nv-color-text-tertiary);
border-block-end: 1.5px solid var(--nv-color-text-tertiary);
rotate: 45deg;
pointer-events: none;
}
/* --- Checkbox and radio ------------------------------------------------------------ */
.nv-checkbox,
.nv-radio {
position: relative;
flex: none;
inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
margin: 0;
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border-strong);
border-radius: var(--nv-radius-xs);
box-shadow: var(--nv-elevation-inset-sm);
cursor: pointer;
appearance: none;
transition: var(--nv-transition-control);
}
.nv-radio {
border-radius: var(--nv-radius-full);
}
.nv-checkbox:checked,
.nv-radio:checked,
.nv-checkbox:indeterminate {
background-color: var(--nv-color-accent);
border-color: transparent;
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
}
/* The marks are always present, at zero size and zero opacity, so checking a
box grows the tick out of its centre instead of stamping it there. A mark
that only exists in the :checked state has nothing to animate from. */
.nv-checkbox::after,
.nv-radio::after,
.nv-checkbox::before {
content: "";
position: absolute;
opacity: 0;
scale: 0;
transition:
opacity var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-quick) var(--nv-ease-spring);
}
/* The tick is drawn from two borders so it scales with the control and needs
no asset. */
.nv-checkbox::after {
inset-block-start: 45%;
inset-inline-start: 50%;
inline-size: 0.28em;
block-size: 0.55em;
translate: -50% -50%;
rotate: 45deg;
border-inline-end: 2px solid var(--nv-color-on-accent);
border-block-end: 2px solid var(--nv-color-on-accent);
}
.nv-checkbox:checked:not(:indeterminate)::after {
opacity: 1;
scale: 1;
}
/* Indeterminate is its own mark rather than a variation of the tick: the two
have to be able to cross over each other when the state changes. */
.nv-checkbox::before {
inset: 50% 25% auto;
translate: 0 -50%;
block-size: 2px;
background-color: var(--nv-color-on-accent);
border-radius: var(--nv-radius-full);
}
.nv-checkbox:indeterminate::before {
opacity: 1;
scale: 1;
}
.nv-radio::after {
inset: 50% auto auto 50%;
translate: -50% -50%;
inline-size: 0.4em;
block-size: 0.4em;
background-color: var(--nv-color-on-accent);
border-radius: var(--nv-radius-full);
}
.nv-radio:checked::after {
opacity: 1;
scale: 1;
}
.nv-checkbox:disabled,
.nv-radio:disabled {
background-color: var(--nv-color-background-subtle);
border-color: var(--nv-color-border);
box-shadow: none;
cursor: not-allowed;
}
/* --- Switch --------------------------------------------------------------------------
The track is inset and the thumb is elevated: the physical relationship does
the explaining, so the control reads as on or off without a label. */
.nv-switch {
--_track-inline: calc(var(--nv-space-6) * 1.5);
--_track-block: var(--nv-space-6);
--_thumb: calc(var(--_track-block) - 6px);
--_inset: 2px;
--_travel: calc(
var(--_track-inline) - var(--_thumb) - var(--_inset) * 2 -
var(--nv-border-width) * 2
);
position: relative;
flex: none;
inline-size: var(--_track-inline);
block-size: var(--_track-block);
margin: 0;
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-elevation-inset-sm);
cursor: pointer;
appearance: none;
transition: var(--nv-transition-control);
}
.nv-switch::after {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-start: var(--_inset);
inline-size: var(--_thumb);
block-size: var(--_thumb);
translate: 0 -50%;
background-color: var(--nv-color-surface-elevated);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
transition:
translate var(--nv-duration-travel) var(--nv-ease-emphasized),
background-color var(--nv-duration-fast) var(--nv-ease-standard);
}
.nv-switch:checked {
background-color: var(--nv-color-accent);
border-color: transparent;
}
.nv-switch:checked::after {
translate: var(--_travel) -50%;
}
.nv-switch:disabled {
cursor: not-allowed;
opacity: 0.55;
}
/* --- Inline control + label --------------------------------------------------------- */
.nv-choice {
display: flex;
align-items: center;
gap: var(--nv-space-2-5);
font: var(--nv-font-body);
cursor: pointer;
}
.nv-choice:has(:disabled) {
color: var(--nv-color-text-disabled);
cursor: not-allowed;
}
/* translate has no logical equivalent, so the thumb's direction is flipped
explicitly rather than travelling the wrong way in RTL. */
.nv-switch:checked:dir(rtl)::after {
translate: calc(var(--_travel) * -1) -50%;
}

View file

@ -0,0 +1,175 @@
/* ==========================================================================
Niveus — Menu
--------------------------------------------------------------------------
<button class="nv-button nv-menu-trigger" popovertarget="actions"
style="--nv-anchor: --actions">Actions</button>
<div class="nv-menu" popover id="actions" style="--nv-anchor: --actions">
<button class="nv-menu__item">Rename</button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Delete</button>
</div>
A menu has a spatial relationship with the control that opened it: anchor
positioning ties it to the trigger, and it scales out of the trigger's edge
rather than appearing somewhere unrelated. Both sides name the same anchor
through --nv-anchor, so the pair stays obvious in the markup.
Light dismiss, Escape, and top-layer stacking come from the popover
attribute — none of it is scripted.
========================================================================== */
.nv-menu-trigger {
anchor-name: var(--nv-anchor);
}
.nv-menu {
position: fixed;
inset: auto;
margin: 0;
padding: var(--nv-space-1);
min-inline-size: 12rem;
max-inline-size: min(20rem, calc(100vw - var(--nv-space-8)));
max-block-size: min(24rem, calc(100svh - var(--nv-space-12)));
overflow-y: auto;
overscroll-behavior: contain;
/* Below the trigger, aligned to its inline-start edge, flipping to whichever
side has room. */
position-anchor: var(--nv-anchor);
position-area: block-end span-inline-end;
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
margin-block-start: var(--nv-space-2);
background-color: var(--nv-material-frosted-bg);
border: var(--nv-border-width) solid var(--nv-material-frosted-border);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
color: var(--nv-color-text-primary);
/* Grows from the edge nearest the trigger. */
transform-origin: center block-start;
opacity: 0;
scale: 0.96;
translate: 0 calc(var(--nv-travel-sm) * -1);
transition:
opacity var(--nv-duration-quick) var(--nv-ease-standard),
scale var(--nv-duration-quick) var(--nv-ease-entrance),
translate var(--nv-duration-quick) var(--nv-ease-entrance),
display var(--nv-duration-quick) allow-discrete,
overlay var(--nv-duration-quick) allow-discrete;
}
.nv-menu:popover-open {
opacity: 1;
scale: 1;
translate: 0;
}
@starting-style {
.nv-menu:popover-open {
opacity: 0;
scale: 0.96;
translate: 0 calc(var(--nv-travel-sm) * -1);
}
}
/* Without anchor positioning the menu cannot know where its trigger is, so it
falls back to the popover's own centring rather than landing somewhere
arbitrary. The relationship is lost; the usability is not. */
@supports not (anchor-name: --nv-test) {
.nv-menu {
inset: 0;
margin: auto;
transform-origin: center;
}
}
/* --- Items -------------------------------------------------------------------- */
.nv-menu__item {
display: flex;
align-items: center;
gap: var(--nv-space-2-5);
inline-size: 100%;
padding: var(--nv-space-2) var(--nv-space-2-5);
background-color: transparent;
border: 0;
border-radius: var(--nv-radius-sm);
color: var(--nv-color-text-secondary);
font: var(--nv-font-label);
text-align: start;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: var(--nv-transition-colors);
}
@media (hover: hover) {
.nv-menu__item:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--nv-color-surface-hover);
color: var(--nv-color-text-primary);
}
}
.nv-menu__item:focus-visible {
outline: none;
background-color: var(--nv-color-surface-hover);
color: var(--nv-color-text-primary);
box-shadow: var(--nv-focus-ring);
}
.nv-menu__item--danger {
color: var(--nv-color-danger-text);
}
@media (hover: hover) {
.nv-menu__item--danger:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--nv-color-danger-subtle);
color: var(--nv-color-danger-text);
}
}
.nv-menu__item:disabled,
.nv-menu__item[aria-disabled="true"] {
color: var(--nv-color-text-disabled);
cursor: not-allowed;
}
.nv-menu__item[aria-checked="true"]::before {
content: "";
inline-size: 0.28em;
block-size: 0.55em;
margin-inline-end: var(--nv-space-1);
translate: 0 -0.12em;
rotate: 45deg;
border-inline-end: 2px solid var(--nv-color-accent);
border-block-end: 2px solid var(--nv-color-accent);
}
.nv-menu__shortcut {
margin-inline-start: auto;
padding-inline-start: var(--nv-space-4);
color: var(--nv-color-text-tertiary);
font: var(--nv-font-caption);
}
.nv-menu__separator {
border: 0;
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin: var(--nv-space-1) 0;
}
.nv-menu__label {
padding: var(--nv-space-2) var(--nv-space-2-5) var(--nv-space-1);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}

View file

@ -0,0 +1,349 @@
/* ==========================================================================
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);
}

View file

@ -0,0 +1,131 @@
/* ==========================================================================
Niveus — Scroller
--------------------------------------------------------------------------
<div class="nv-scroller-frame" data-nv-scroller>
<div class="nv-scroller"> …cards… </div>
<button class="nv-button nv-button--icon" data-nv-scroll="prev">…</button>
<button class="nv-button nv-button--icon" data-nv-scroll="next">…</button>
</div>
A row of content that continues past its frame. Two things make that
readable rather than merely clipped: the edges fade, so the row visibly runs
under them instead of being cut, and snapping lands each item at the same
place every time — so moving left and right returns you to positions you
have already seen rather than to arbitrary offsets.
Scrolling works with no script at all: wheel, trackpad, touch and keyboard
are the browser's. The buttons are an affordance for pointers that have no
horizontal axis, and niveus.js wires them.
========================================================================== */
.nv-scroller-frame {
position: relative;
}
.nv-scroller {
--nv-scroller-item: 18rem;
--nv-scroller-fade: var(--nv-space-8);
display: flex;
gap: var(--nv-space-4);
overflow-x: auto;
overscroll-behavior-inline: contain;
/* Room for items to lift on hover without being clipped by the scroll box. */
padding-block: var(--nv-space-2);
scroll-snap-type: inline mandatory;
scroll-padding-inline: var(--nv-space-1);
scroll-behavior: smooth;
scrollbar-width: none;
/* The row runs under its own edges rather than stopping at them — but only
on the side where there is actually more to see. A fade at an end the row
has already reached is a lie about the content. niveus.js marks the ends;
with no script both sides fade, which is the safe default. */
mask-image: linear-gradient(
to right,
transparent,
black var(--nv-scroller-fade),
black calc(100% - var(--nv-scroller-fade)),
transparent
);
}
.nv-scroller-frame[data-nv-at-start] .nv-scroller {
mask-image: linear-gradient(
to right,
black calc(100% - var(--nv-scroller-fade)),
transparent
);
}
.nv-scroller-frame[data-nv-at-end] .nv-scroller {
mask-image: linear-gradient(to right, transparent, black var(--nv-scroller-fade));
}
.nv-scroller-frame[data-nv-at-start][data-nv-at-end] .nv-scroller {
mask-image: none;
}
.nv-scroller::-webkit-scrollbar {
display: none;
}
.nv-scroller > * {
flex: 0 0 auto;
inline-size: min(var(--nv-scroller-item), 78%);
scroll-snap-align: start;
}
/* Items that should size to their content rather than to the track. */
.nv-scroller--auto > * {
inline-size: auto;
}
@media (prefers-reduced-motion: reduce) {
.nv-scroller {
scroll-behavior: auto;
}
}
/* --- Controls ------------------------------------------------------------
Centred on the row's block axis and pulled half outside it, so they read as
belonging to the frame rather than floating over the content. */
.nv-scroller-frame [data-nv-scroll] {
position: absolute;
inset-block-start: 50%;
z-index: 1;
/* Read by .nv-button--float, which keeps it through hover and press. */
--nv-button-offset: 0 -50%;
}
/* Belt and braces: the frame knows these controls float, so their centring
survives even if the markup forgets --float. */
.nv-scroller-frame [data-nv-scroll],
.nv-scroller-frame [data-nv-scroll]:hover,
.nv-scroller-frame [data-nv-scroll]:active {
translate: var(--nv-button-offset);
}
.nv-scroller-frame [data-nv-scroll="prev"] {
inset-inline-start: calc(var(--nv-space-4) * -1);
}
.nv-scroller-frame [data-nv-scroll="next"] {
inset-inline-end: calc(var(--nv-space-4) * -1);
}
/* At either end the control has nothing left to do, and says so. */
.nv-scroller-frame [data-nv-scroll]:disabled {
opacity: 0;
pointer-events: none;
}
@media (hover: none) {
.nv-scroller-frame [data-nv-scroll] {
display: none;
}
}

View file

@ -0,0 +1,116 @@
/* ==========================================================================
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;
}

View file

@ -0,0 +1,95 @@
/* ==========================================================================
Niveus — Table
--------------------------------------------------------------------------
<div class="nv-table-frame">
<table class="nv-table"> … </table>
</div>
Rows of the same kind of thing. The frame handles the overflow, because a
table is the one component that legitimately refuses to get narrower — it
scrolls sideways inside its frame rather than forcing the page to.
Numbers belong in .nv-table__number: aligned to the end and set in tabular
figures, so columns of digits line up and can be compared by eye.
========================================================================== */
.nv-table-frame {
inline-size: 100%;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.nv-table {
inline-size: 100%;
border-collapse: collapse;
font: var(--nv-font-body);
text-align: start;
}
.nv-table th {
padding: var(--nv-space-2-5) var(--nv-space-4);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wide);
color: var(--nv-color-text-tertiary);
text-align: start;
white-space: nowrap;
border-block-end: var(--nv-border-width) solid var(--nv-color-border);
}
.nv-table td {
padding: var(--nv-space-3) var(--nv-space-4);
color: var(--nv-color-text-secondary);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
vertical-align: middle;
}
/* The first cell in a row names it, so it carries the reading weight. */
.nv-table td:first-child {
color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-medium);
}
.nv-table tbody tr:last-child td {
border-block-end: 0;
}
@media (hover: hover) {
.nv-table tbody tr {
transition: var(--nv-transition-colors);
}
.nv-table tbody tr:hover {
background-color: var(--nv-color-surface-hover);
}
}
.nv-table__number {
text-align: end;
font-variant-numeric: tabular-nums;
}
.nv-table__actions {
inline-size: 1%;
white-space: nowrap;
text-align: end;
}
.nv-table--compact th {
padding-block: var(--nv-space-2);
}
.nv-table--compact td {
padding-block: var(--nv-space-2);
}
/* Rows that lead somewhere. Same physical language as an interactive card:
it responds to a pointer, so it must look like it can be pressed. */
.nv-table--interactive tbody tr {
cursor: pointer;
}
.nv-table--interactive tbody tr:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}