Niveus: a tactile, adaptive interface language

Bevelled controls with light falling across them, fields as wells, solid
windows, and glass kept for the chrome that floats over everything else.
Warm graphite in dark, soft paper in light, one brand color driving both.
Ships its own fonts (Instrument Sans, JetBrains Mono; OFL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-03 17:47:03 +02:00
commit 23b8dd376d
51 changed files with 15664 additions and 0 deletions

103
niveus/components/badge.css Normal file
View file

@ -0,0 +1,103 @@
/* ==========================================================================
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;
background-image: var(--nv-sheen-filled);
box-shadow: var(--nv-highlight-top);
}
.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;
}

101
niveus/components/bed.css Normal file
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,296 @@
/* ==========================================================================
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
A button is a key: a bevelled face with light falling across it, a contact
line where it meets the surface, and a press that sinks it into an inset.
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);
--_sheen: var(--nv-sheen-raised);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
--_shadow-active: var(--nv-elevation-inset-sm), var(--nv-outline);
/* 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);
background-image: var(--_sheen);
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);
}
}
/* Pressed, the face is pushed below the light: the sheen goes and the inset
takes over. */
.nv-button:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-active);
background-image: none;
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);
background-image: none;
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;
--_sheen: var(--nv-sheen-filled);
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;
--_sheen: var(--nv-sheen-filled);
--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;
--_sheen: none;
--_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);
--_sheen: none;
--_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);
--_sheen: none;
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-bevel), var(--nv-outline), var(--nv-elevation-3);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4);
--_shadow-active: var(--nv-bevel), var(--nv-outline), 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;
}

175
niveus/components/card.css Normal file
View file

@ -0,0 +1,175 @@
/* ==========================================================================
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-bevel), var(--nv-outline), var(--nv-elevation-1);
--_sheen: var(--nv-sheen-surface);
--_padding: var(--nv-space-5);
position: relative;
display: flex;
flex-direction: column;
min-inline-size: 0;
background-color: var(--_bg);
background-image: var(--_sheen);
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-bevel), var(--nv-outline), var(--nv-elevation-2);
}
.nv-card--floating {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
}
.nv-card--inset {
--_bg: var(--nv-color-surface-inset);
--_shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
--_sheen: none;
--_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);
--_sheen: none;
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-bevel), var(--nv-outline), var(--nv-elevation-3);
translate: 0 calc(var(--nv-lift-hover) * 2);
}
}
.nv-card--interactive:active {
box-shadow: var(--nv-bevel), var(--nv-outline), 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,191 @@
/* ==========================================================================
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);
background-image: var(--nv-sheen-surface);
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-bevel), var(--nv-outline), 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,131 @@
/* ==========================================================================
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);
background-image: var(--nv-sheen-surface);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-lg);
box-shadow: var(--nv-bevel), var(--nv-outline), 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;
}

407
niveus/components/input.css Normal file
View file

@ -0,0 +1,407 @@
/* ==========================================================================
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 wells cut into the
surface that holds them — shaded inside, lit along the lower lip — 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-md), var(--nv-ledge);
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-md), 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), var(--nv-ledge);
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);
background-image: var(--nv-sheen-filled);
border-color: transparent;
box-shadow: var(--nv-bevel), var(--nv-outline), 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 a slot cut into the surface and the thumb is a knob riding in
it: the physical relationship does the explaining, so the control reads as
on or off without a label. The slot stays recessed when it fills with color
— switching on lights the slot, it does not raise it. */
.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-md), var(--nv-ledge);
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-knob);
background-image: var(--nv-sheen-knob);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), 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;
box-shadow: var(--nv-elevation-inset-md), var(--nv-outline);
}
.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%;
}

175
niveus/components/menu.css Normal file
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-lg);
box-shadow: var(--nv-material-frosted-shadow);
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,358 @@
/* ==========================================================================
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 and bevelled — 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-bevel);
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-bevel), 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);
background-image: var(--nv-sheen-raised);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: inherit;
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
view-transition-class: nv-travelling;
}
/* The marker is named only while its own group is changing (niveus.js sets
data-nv-travelling for the length of the transition). A permanent name
would lift every marker on the page out of the page snapshot on every
transition, theme changes included. */
[data-nv-travelling] .nv-nav__item[aria-current]::before,
[data-nv-travelling] .nv-nav__item--active::before,
[data-nv-travelling] .nv-nav__item[aria-selected="true"]::before {
view-transition-name: var(--nv-selection-name);
}
/* 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);
}
/* Engraved, like .nv-overline: a section marks a region, it is not read. */
.nv-nav__section {
padding: var(--nv-space-4) var(--nv-space-3) var(--nv-space-1-5);
font: var(--nv-font-engraved);
letter-spacing: var(--nv-letter-spacing-engraved);
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-md), var(--nv-ledge);
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. nv.selectTab() gives the outgoing and incoming
panels one shared name for the length of the change, so they hand over
inside the same box — and slide with the direction of travel. The class is
what picks up the content animation in continuity.css. */
.nv-tab-panel {
view-transition-class: 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);
}

View file

@ -0,0 +1,189 @@
/* ==========================================================================
Niveus — Window
--------------------------------------------------------------------------
.nv-window a framed application window
.nv-window--inactive a window that does not have focus
.nv-window__titlebar controls · title · trailing actions
.nv-window__controls the three lights, at the start of the bar
.nv-window__control one light: --close, --minimize or --zoom
.nv-window__title the window's name, centered on the bar
.nv-window__actions trailing slot for a few ghost buttons
.nv-window__body the content area
<div class="nv-window">
<div class="nv-window__titlebar">
<div class="nv-window__controls">
<button class="nv-window__control nv-window__control--close" aria-label="Close"></button>
<button class="nv-window__control nv-window__control--minimize" aria-label="Minimize"></button>
<button class="nv-window__control nv-window__control--zoom" aria-label="Zoom"></button>
</div>
<span class="nv-window__title">Settings</span>
</div>
<div class="nv-window__body">…</div>
</div>
A window is a solid object: the content it holds never sits on glass. Its
titlebar is a raised strip lit like a control, and the controls are three
lights at its start — close, minimize, zoom — always in that order and in
those colors, because people find them by position and color before they
read anything. Their glyphs appear only while the pointer is over the
group. An inactive window greys its lights and steps back one elevation,
and the lights regain their color the moment they are hovered.
========================================================================== */
.nv-window {
display: flex;
flex-direction: column;
min-inline-size: 0;
background-color: var(--nv-color-surface);
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-outline), var(--nv-elevation-4);
overflow: hidden;
transition: var(--nv-transition-surface);
}
.nv-window--inactive {
box-shadow: var(--nv-highlight-top), var(--nv-outline), var(--nv-elevation-3);
}
/* --- Titlebar ---------------------------------------------------------------
Three columns with the outer two sharing the slack, so the title stays on
the window's center line whatever the controls and actions measure. */
.nv-window__titlebar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--nv-space-3);
min-block-size: var(--nv-control-height-lg);
padding-inline: var(--nv-space-4);
background-color: var(--nv-color-surface-elevated);
background-image: var(--nv-sheen-raised);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: var(--nv-highlight-top);
user-select: none;
}
.nv-window__title {
grid-column: 2;
min-inline-size: 0;
overflow: hidden;
font: var(--nv-font-label);
font-weight: var(--nv-font-weight-semibold);
color: var(--nv-color-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.nv-window--inactive .nv-window__title {
color: var(--nv-color-text-tertiary);
}
.nv-window__actions {
grid-column: 3;
justify-self: end;
display: flex;
align-items: center;
gap: var(--nv-space-1);
}
/* --- Controls ----------------------------------------------------------------- */
.nv-window__controls {
grid-column: 1;
justify-self: start;
display: flex;
align-items: center;
gap: var(--nv-space-2);
}
.nv-window__control {
--_light: var(--nv-color-window-close);
--_c: var(--_light);
position: relative;
flex: none;
inline-size: var(--nv-space-3);
block-size: var(--nv-space-3);
margin: 0;
padding: 0;
background-color: var(--_c);
background-image: var(--nv-sheen-filled);
border: 0;
border-radius: var(--nv-radius-full);
box-shadow: inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring);
cursor: pointer;
appearance: none;
-webkit-tap-highlight-color: transparent;
transition: var(--nv-transition-colors);
}
.nv-window__control--minimize { --_light: var(--nv-color-window-minimize); }
.nv-window__control--zoom { --_light: var(--nv-color-window-zoom); }
/* The glyph is two bars drawn from the light's center: crossed for close, one
for minimize, a plus for zoom. They are always present and fade in, so the
whole group reveals at once instead of one glyph at a time. */
.nv-window__control::before,
.nv-window__control::after {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
inline-size: 55%;
block-size: calc(var(--nv-border-width) * 1.5);
translate: -50% -50%;
background-color: var(--nv-color-window-glyph);
border-radius: var(--nv-radius-full);
opacity: 0;
transition: opacity var(--nv-duration-fast) var(--nv-ease-standard);
}
.nv-window__control--close::before { rotate: 45deg; }
.nv-window__control--close::after { rotate: -45deg; }
.nv-window__control--minimize::after { display: none; }
.nv-window__control--zoom::after { rotate: 90deg; }
.nv-window__controls:hover .nv-window__control::before,
.nv-window__controls:hover .nv-window__control::after,
.nv-window__controls:focus-within .nv-window__control::before,
.nv-window__controls:focus-within .nv-window__control::after {
opacity: 1;
}
.nv-window__control:active {
background-image: none;
box-shadow:
inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring),
var(--nv-elevation-inset-sm);
}
.nv-window__control:focus-visible {
outline: none;
box-shadow:
inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring),
var(--nv-focus-ring);
}
.nv-window--inactive .nv-window__control {
--_c: var(--nv-color-window-inactive);
}
.nv-window--inactive .nv-window__controls:hover .nv-window__control {
--_c: var(--_light);
}
/* --- Body --------------------------------------------------------------------- */
.nv-window__body {
flex: 1;
min-block-size: 0;
overflow: auto;
}