@@ -1315,7 +1369,7 @@
previous.removeAttribute("aria-current");
});
item.setAttribute("aria-current", "page");
- });
+ }, { scope: group });
});
}
diff --git a/docs/Website/niveus/niveus/base.css b/docs/Website/niveus/niveus/base.css
index 5bbcf1a..5c752e3 100644
--- a/docs/Website/niveus/niveus/base.css
+++ b/docs/Website/niveus/niveus/base.css
@@ -24,6 +24,14 @@ body {
margin: 0;
min-height: 100svh;
background-color: var(--nv-color-background);
+ /* The canvas is lit, not painted: a wash of the accent falls in from the top
+ corner and a fainter one pools at the opposite foot. It is what gives
+ translucent surfaces something to be translucent against, and keeps a
+ page of solid panels from reading as one flat field. */
+ background-image:
+ radial-gradient(120% 80% at 0% 0%, color-mix(in oklab, var(--nv-color-accent) light-dark(10%, 16%), transparent), transparent 60%),
+ radial-gradient(90% 70% at 100% 100%, color-mix(in oklab, var(--nv-color-accent) light-dark(6%, 10%), transparent), transparent 60%);
+ background-attachment: fixed;
color: var(--nv-color-text-primary);
font: var(--nv-font-body);
letter-spacing: var(--nv-letter-spacing-normal);
@@ -175,10 +183,11 @@ textarea {
color: var(--nv-color-text-secondary);
}
+/* Engraved: small mono capitals that mark a region the way markings mark an
+ instrument panel. Not for anything that has to be read as a sentence. */
.nv-overline {
- font: var(--nv-font-caption);
- font-weight: var(--nv-font-weight-semibold);
- letter-spacing: var(--nv-letter-spacing-wider);
+ font: var(--nv-font-engraved);
+ letter-spacing: var(--nv-letter-spacing-engraved);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
@@ -195,3 +204,32 @@ textarea {
.nv-text-success { color: var(--nv-color-success-text); }
.nv-text-warning { color: var(--nv-color-warning-text); }
.nv-text-danger { color: var(--nv-color-danger-text); }
+
+/* Lit text: type cut from the same material as the surfaces — bright along
+ the top, warming towards the accent below, the way light falls across a
+ raised face. Each line is lit on its own. For one display line on a page,
+ not for running text. */
+.nv-text-lit {
+ background-image: linear-gradient(
+ 180deg,
+ var(--nv-color-text-primary) 30%,
+ color-mix(in oklab, var(--nv-color-accent-text) 30%, var(--nv-color-text-primary))
+ );
+ -webkit-box-decoration-break: clone;
+ box-decoration-break: clone;
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ color: var(--nv-color-accent-text);
+}
+
+/* The accent alone, lit from above: for an accented phrase inside a display
+ line, where flat accent text would read as a link. */
+.nv-text-lit--accent {
+ background-image: linear-gradient(
+ 180deg,
+ color-mix(in oklab, var(--nv-color-accent-text) 55%, var(--nv-color-text-primary)),
+ var(--nv-color-accent-text) 60%,
+ var(--nv-color-accent)
+ );
+}
diff --git a/docs/Website/niveus/niveus/components/avatar.css b/docs/Website/niveus/niveus/components/avatar.css
new file mode 100644
index 0000000..20f5f36
--- /dev/null
+++ b/docs/Website/niveus/niveus/components/avatar.css
@@ -0,0 +1,113 @@
+/* ==========================================================================
+ Niveus — Avatar
+ --------------------------------------------------------------------------
+
+
NX
+
+ A person, as a round raised disc: an image when there is one, initials when
+ there is not. The disc is lit like a knob — highlight above, shade below —
+ so a face reads as something set into the interface, not pasted onto it.
+
+ .nv-avatar--ring a ring in the tint, set off by a gap of surface
+ .nv-avatar--glow the ring gives off its tint
+ .nv-avatar--sm / --lg / --xl
+ .nv-avatar__status presence dot; --online lights it
+ .nv-avatar-group overlapping row of avatars
+
+ --nv-avatar-tint picks the ring color; the accent unless set.
+ ========================================================================== */
+
+.nv-avatar {
+ --nv-avatar-tint: var(--nv-color-accent);
+ --_size: 2.5rem;
+ --_ring: 0 0 transparent;
+ --_glow: 0 0 transparent;
+
+ position: relative;
+ display: inline-grid;
+ place-items: center;
+ flex: none;
+ inline-size: var(--_size);
+ block-size: var(--_size);
+
+ background-color: var(--nv-color-surface-elevated);
+ background-image: var(--nv-sheen-raised);
+ color: var(--nv-color-text-secondary);
+ font: var(--nv-font-label);
+ font-weight: var(--nv-font-weight-semibold);
+ border-radius: var(--nv-radius-full);
+ box-shadow: var(--_ring), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow);
+ user-select: none;
+}
+
+.nv-avatar > img {
+ inline-size: 100%;
+ block-size: 100%;
+ border-radius: inherit;
+ object-fit: cover;
+}
+
+/* The gap is drawn in the surface color, so the ring reads as a separate band
+ sitting around the disc rather than as a thick border painted onto it. */
+.nv-avatar--ring,
+.nv-avatar--glow {
+ --_ring:
+ 0 0 0 2px var(--nv-color-surface),
+ 0 0 0 4px var(--nv-avatar-tint);
+}
+
+.nv-avatar--glow {
+ --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm)
+ color-mix(in oklab, var(--nv-avatar-tint) var(--nv-glow-strength), transparent);
+}
+
+.nv-avatar--sm {
+ --_size: 1.75rem;
+ font: var(--nv-font-caption);
+}
+
+.nv-avatar--lg {
+ --_size: 4rem;
+ font: var(--nv-font-title);
+}
+
+.nv-avatar--xl {
+ --_size: 6rem;
+ font: var(--nv-font-heading);
+}
+
+/* --- Status ------------------------------------------------------------------ */
+
+.nv-avatar__status {
+ position: absolute;
+ inset-block-end: 4%;
+ inset-inline-end: 4%;
+ inline-size: max(0.625rem, 24%);
+ block-size: max(0.625rem, 24%);
+ border-radius: var(--nv-radius-full);
+ background-color: var(--nv-color-text-tertiary);
+ box-shadow: 0 0 0 2px var(--nv-color-surface);
+}
+
+.nv-avatar__status--online {
+ background-color: var(--nv-color-success);
+ box-shadow:
+ 0 0 0 2px var(--nv-color-surface),
+ 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm)
+ color-mix(in oklab, var(--nv-color-success) var(--nv-glow-strength), transparent);
+}
+
+/* --- Group ----------------------------------------------------------------------- */
+
+.nv-avatar-group {
+ display: inline-flex;
+ align-items: center;
+}
+
+.nv-avatar-group > .nv-avatar {
+ box-shadow: 0 0 0 2px var(--nv-color-surface), var(--nv-bevel), var(--nv-elevation-1);
+}
+
+.nv-avatar-group > .nv-avatar + .nv-avatar {
+ margin-inline-start: calc(var(--_size) * -0.3);
+}
diff --git a/docs/Website/niveus/niveus/components/badge.css b/docs/Website/niveus/niveus/components/badge.css
index 7cf80a1..600c858 100644
--- a/docs/Website/niveus/niveus/components/badge.css
+++ b/docs/Website/niveus/niveus/components/badge.css
@@ -76,6 +76,8 @@
--_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 {
diff --git a/docs/Website/niveus/niveus/components/button.css b/docs/Website/niveus/niveus/components/button.css
index 1353aab..9c4bb4d 100644
--- a/docs/Website/niveus/niveus/components/button.css
+++ b/docs/Website/niveus/niveus/components/button.css
@@ -10,6 +10,9 @@
.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
@@ -23,9 +26,10 @@
--_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);
+ --_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);
@@ -46,6 +50,7 @@
border-radius: var(--_radius);
background-color: var(--_bg);
+ background-image: var(--_sheen);
color: var(--_fg);
box-shadow: var(--_shadow);
@@ -83,8 +88,11 @@
}
}
+/* 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);
@@ -98,6 +106,7 @@
.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;
@@ -113,6 +122,13 @@
--_bg-active: var(--nv-color-accent-active);
--_fg: var(--nv-color-on-accent);
--_border: transparent;
+ --_sheen: var(--nv-sheen-filled);
+ /* A filled key gives off a little of its own color: the glow is what makes
+ it read as lit rather than printed. */
+ --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2),
+ 0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent);
+ --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3),
+ 0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent);
font-weight: var(--nv-font-weight-semibold);
}
@@ -122,6 +138,11 @@
--_bg-active: var(--nv-color-danger-active);
--_fg: var(--nv-color-on-danger);
--_border: transparent;
+ --_sheen: var(--nv-sheen-filled);
+ --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2),
+ 0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent);
+ --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3),
+ 0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent);
--nv-color-focus-ring: var(--nv-color-danger-border);
font-weight: var(--nv-font-weight-semibold);
}
@@ -132,11 +153,20 @@
--_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);
}
+/* A ghost has no material to lose, so disabling it only dims the label —
+ it does not suddenly grow a well its enabled siblings never had. */
+.nv-button--ghost:disabled,
+.nv-button--ghost[aria-disabled="true"] {
+ background-color: transparent;
+ border-color: transparent;
+}
+
@media (hover: hover) {
.nv-button--ghost:hover:not(:disabled, [aria-disabled="true"]) {
color: var(--nv-color-text-primary);
@@ -150,6 +180,7 @@
--_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);
}
@@ -159,6 +190,7 @@
--_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);
}
@@ -205,9 +237,9 @@
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);
+ --_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);
}
diff --git a/docs/Website/niveus/niveus/components/card.css b/docs/Website/niveus/niveus/components/card.css
index 78a3adb..4dd9646 100644
--- a/docs/Website/niveus/niveus/components/card.css
+++ b/docs/Website/niveus/niveus/components/card.css
@@ -6,6 +6,9 @@
.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--lit lit along its top edge in its tint
+ .nv-card--glow gives off its tint — the one card that is on
+ .nv-card--spotlight catches a light that follows the pointer
.nv-card__header / __body / __footer / __media
A card is a container, not a look. If a card needs a shadow that is not on
@@ -14,10 +17,20 @@
========================================================================== */
.nv-card {
- --_bg: var(--nv-color-surface);
+ /* Content surfaces are mostly solid: enough of the canvas comes through to
+ tie a card to the light around it, never enough to compete with what it
+ holds. The blur keeps anything busy behind it (a bed, an image) from
+ reading through as texture. */
+ --_bg: color-mix(in oklab, var(--nv-color-surface) 90%, transparent);
--_border: var(--nv-color-border-subtle);
- --_shadow: var(--nv-highlight-top), var(--nv-elevation-1);
+ --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
+ --_sheen: var(--nv-sheen-surface);
--_padding: var(--nv-space-5);
+ /* Light layers the variants switch on. Kept separate from --_shadow so a
+ lit or glowing card can still be elevated, floating or interactive. */
+ --_lit: 0 0 transparent;
+ --_glow: 0 0 transparent;
+ --_spot: linear-gradient(transparent, transparent);
position: relative;
display: flex;
@@ -25,10 +38,13 @@
min-inline-size: 0;
background-color: var(--_bg);
+ background-image: var(--_spot), 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);
+ box-shadow: var(--_lit), var(--_shadow), var(--_glow);
+ backdrop-filter: var(--nv-backdrop-glass);
+ -webkit-backdrop-filter: var(--nv-backdrop-glass);
/* Children are clipped to the corner radius so media and inset regions
cannot square off the card. */
@@ -37,18 +53,19 @@
}
.nv-card--elevated {
- --_bg: var(--nv-color-surface-elevated);
- --_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
+ --_bg: color-mix(in oklab, var(--nv-color-surface-elevated) 90%, transparent);
+ --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
}
.nv-card--floating {
- --_bg: var(--nv-color-surface-elevated);
- --_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
+ --_bg: color-mix(in oklab, var(--nv-color-surface-elevated) 88%, transparent);
+ --_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);
+ --_bg: color-mix(in oklab, var(--nv-color-surface-inset) 85%, transparent);
+ --_shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
+ --_sheen: none;
--_border: var(--nv-color-border-subtle);
}
@@ -56,6 +73,7 @@
--_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);
}
@@ -76,20 +94,75 @@
@media (hover: hover) {
.nv-card--interactive:hover {
- box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
+ box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), var(--_glow);
translate: 0 calc(var(--nv-lift-hover) * 2);
}
}
.nv-card--interactive:active {
- box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
+ box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow);
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);
+ box-shadow: var(--_lit), var(--_shadow), var(--_glow), var(--nv-focus-ring);
+}
+
+/* --- Light ------------------------------------------------------------------
+ Three ways a card can carry light, each stated once and combinable with
+ every elevation above. They all take the card's tint — the accent unless
+ --nv-card-tint names another color, the way --nv-bed-tint does for a bed.
+
+ Lit: a strip of light along the top edge, spilling a little way down
+ the face. For a card that belongs to something with a color: a
+ project, a status, a category.
+ Glow: the card gives off its tint. For the one card that is on — the
+ current plan, the live item. Two glowing cards side by side
+ cancel each other out.
+ Spotlight: a soft light follows the pointer across the face (niveus.js
+ tracks it). A showcase effect; a list of work items does not
+ need one. */
+
+.nv-card--lit,
+.nv-card--glow,
+.nv-card--spotlight {
+ --nv-card-tint: var(--nv-color-accent);
+}
+
+.nv-card--lit {
+ --_lit:
+ inset 0 2px 0 color-mix(in oklab, var(--nv-card-tint) 85%, transparent),
+ inset 0 18px 28px -22px color-mix(in oklab, var(--nv-card-tint) 70%, transparent);
+ --_sheen:
+ radial-gradient(
+ 120% 70% at 0% 0%,
+ color-mix(in oklab, var(--nv-card-tint) 12%, transparent),
+ transparent 70%
+ ),
+ var(--nv-sheen-surface);
+}
+
+.nv-card--glow {
+ --_border: color-mix(in oklab, var(--nv-card-tint) 40%, var(--nv-color-border-subtle));
+ --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md)
+ color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent);
+}
+
+@media (hover: hover) {
+ .nv-card--glow.nv-card--interactive:hover {
+ --_glow: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-md)
+ color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent);
+ }
+
+ .nv-card--spotlight:hover {
+ --_spot: radial-gradient(
+ 22rem circle at var(--nv-pointer-x, 50%) var(--nv-pointer-y, 0%),
+ color-mix(in oklab, var(--nv-card-tint) 14%, var(--nv-color-sheen-top)),
+ transparent 70%
+ );
+ }
}
/* Cards whose whole area is clickable via a link inside: the link's target is
diff --git a/docs/Website/niveus/niveus/components/deck.css b/docs/Website/niveus/niveus/components/deck.css
new file mode 100644
index 0000000..3131d0b
--- /dev/null
+++ b/docs/Website/niveus/niveus/components/deck.css
@@ -0,0 +1,55 @@
+/* ==========================================================================
+ Niveus — Deck
+ --------------------------------------------------------------------------
+
+
+ One surface in front of others of its kind: the current item of a set that
+ has more behind it. The layers are cards seen edge-on, stepping back and
+ dimming with distance, so the set has depth without anything having to
+ move. Use it where "there are more of these" is part of the message — a
+ showcase of products, a stack of notifications, a carousel at rest.
+
+ Everything in the deck shares one grid cell. The front surface is any
+ element; the layers are decorative and hidden from assistive technology.
+ Panels that swap (an .nv-tab-panel set) can live in the deck together:
+ the hidden ones take no space.
+ ========================================================================== */
+
+.nv-deck {
+ --_step: var(--nv-space-3);
+
+ display: grid;
+ position: relative;
+ isolation: isolate;
+ /* Room above the front surface for the layers that peek out behind it. */
+ padding-block-start: calc(var(--_step) * 2);
+}
+
+.nv-deck > * {
+ grid-area: 1 / 1;
+ min-inline-size: 0;
+}
+
+.nv-deck__layer {
+ --_depth: 1;
+
+ z-index: -1;
+ pointer-events: none;
+ background-color: color-mix(in oklab, var(--nv-color-surface) 90%, transparent);
+ 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);
+ translate: 0 calc(var(--_step) * var(--_depth) * -1);
+ scale: calc(1 - var(--_depth) * 0.05) 1;
+ opacity: calc(1 - var(--_depth) * 0.3);
+}
+
+.nv-deck__layer + .nv-deck__layer {
+ --_depth: 2;
+ z-index: -2;
+}
diff --git a/docs/Website/niveus/niveus/components/dialog.css b/docs/Website/niveus/niveus/components/dialog.css
index 3c048a0..8bdf3d9 100644
--- a/docs/Website/niveus/niveus/components/dialog.css
+++ b/docs/Website/niveus/niveus/components/dialog.css
@@ -23,11 +23,17 @@
max-block-size: min(42rem, calc(100svh - var(--nv-space-12)));
padding: 0;
- background-color: var(--nv-color-surface-elevated);
+ /* The dialog sits over a dimmed, blurred page, so it can afford to let a
+ little of it through: it reads as a pane lifted off the page rather than
+ a card pasted over it. */
+ background-color: color-mix(in oklab, var(--nv-color-surface-elevated) 86%, transparent);
+ background-image: var(--nv-sheen-surface);
+ backdrop-filter: var(--nv-backdrop-frosted);
+ -webkit-backdrop-filter: var(--nv-backdrop-frosted);
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);
+ box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4);
overflow: hidden;
diff --git a/docs/Website/niveus/niveus/components/disclosure.css b/docs/Website/niveus/niveus/components/disclosure.css
index f5fca76..ecd7e77 100644
--- a/docs/Website/niveus/niveus/components/disclosure.css
+++ b/docs/Website/niveus/niveus/components/disclosure.css
@@ -20,9 +20,10 @@
--_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-highlight-top), var(--nv-elevation-1);
+ box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
transition: var(--nv-transition-surface);
}
diff --git a/docs/Website/niveus/niveus/components/input.css b/docs/Website/niveus/niveus/components/input.css
index d84eae6..cb880f3 100644
--- a/docs/Website/niveus/niveus/components/input.css
+++ b/docs/Website/niveus/niveus/components/input.css
@@ -8,9 +8,9 @@
.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,
+ 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.
========================================================================== */
@@ -59,12 +59,14 @@
min-inline-size: 0;
block-size: var(--_height);
padding-inline: var(--_padding);
-
- background-color: var(--nv-color-surface-inset);
+ /* A well in dark, where recess reads as shade; paper in light, where a grey
+ well reads as a field that has been switched off. The inset shadow and
+ ledge carry the recess either way. */
+ background-color: light-dark(var(--nv-color-surface), 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);
+ box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
font-family: var(--nv-font-family-sans);
font-size: var(--_font-size);
@@ -87,7 +89,7 @@
.nv-input:focus {
outline: none;
--_border: var(--nv-color-accent);
- box-shadow: var(--nv-elevation-inset-sm), var(--nv-focus-ring);
+ box-shadow: var(--nv-elevation-inset-md), var(--nv-focus-ring);
}
.nv-input[aria-invalid="true"] {
@@ -134,7 +136,13 @@ textarea.nv-input {
padding: 0;
}
-.nv-input--color::-webkit-color-swatch,
+.nv-input--color::-webkit-color-swatch {
+ border: var(--nv-border-width) solid var(--nv-color-border);
+ border-radius: calc(var(--nv-radius-md) - var(--nv-space-1));
+}
+
+/* Separate rules on purpose: a selector list that names another engine's
+ pseudo-element is invalid as a whole, and each browser would drop both. */
.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));
@@ -227,13 +235,16 @@ textarea.nv-input {
.nv-radio {
position: relative;
flex: none;
+ /* The marks are drawn in em; pinning the size keeps them the same whatever
+ the label around them is set in. */
+ font-size: var(--nv-font-size-md);
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);
+ box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
cursor: pointer;
appearance: none;
transition: var(--nv-transition-control);
@@ -247,8 +258,9 @@ textarea.nv-input {
.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-highlight-top), var(--nv-elevation-1);
+ 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
@@ -323,8 +335,10 @@ textarea.nv-input {
}
/* --- 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. */
+ 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);
@@ -344,7 +358,7 @@ textarea.nv-input {
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);
+ box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
cursor: pointer;
appearance: none;
transition: var(--nv-transition-control);
@@ -358,9 +372,10 @@ textarea.nv-input {
inline-size: var(--_thumb);
block-size: var(--_thumb);
translate: 0 -50%;
- background-color: var(--nv-color-surface-elevated);
+ background-color: var(--nv-color-knob);
+ background-image: var(--nv-sheen-knob);
border-radius: var(--nv-radius-full);
- box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
+ 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);
@@ -369,6 +384,7 @@ textarea.nv-input {
.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 {
@@ -386,7 +402,10 @@ textarea.nv-input {
display: flex;
align-items: center;
gap: var(--nv-space-2-5);
+ /* Labelled at control size, so a row of switches and checkboxes sits level
+ with the buttons and fields beside it. */
font: var(--nv-font-body);
+ font-size: var(--nv-font-size-sm);
cursor: pointer;
}
diff --git a/docs/Website/niveus/niveus/components/menu.css b/docs/Website/niveus/niveus/components/menu.css
index 35f6636..8bddbef 100644
--- a/docs/Website/niveus/niveus/components/menu.css
+++ b/docs/Website/niveus/niveus/components/menu.css
@@ -43,9 +43,10 @@
margin-block-start: var(--nv-space-2);
background-color: var(--nv-material-frosted-bg);
+ background-image: var(--nv-sheen-surface);
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);
+ 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);
diff --git a/docs/Website/niveus/niveus/components/navigation.css b/docs/Website/niveus/niveus/components/navigation.css
index c9b7f0c..267ed61 100644
--- a/docs/Website/niveus/niveus/components/navigation.css
+++ b/docs/Website/niveus/niveus/components/navigation.css
@@ -8,8 +8,8 @@
.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
+ 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.
========================================================================== */
@@ -20,18 +20,19 @@
display: flex;
align-items: center;
- gap: var(--nv-space-4);
+ flex-wrap: wrap;
+ gap: var(--nv-space-2) 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);
+ background-image: var(--nv-sheen-raised);
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);
+ box-shadow: var(--nv-bevel);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
transition: var(--nv-transition-surface);
@@ -40,12 +41,13 @@
/* 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);
+ 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);
+ background-image: var(--nv-sheen-raised);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
@@ -64,6 +66,7 @@
background-color: transparent;
border-block-end: 0;
+ background-image: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
@@ -88,6 +91,15 @@
padding: 0;
list-style: none;
min-inline-size: 0;
+ /* On a narrow screen a long list scrolls inside the bar instead of pushing
+ the page sideways. */
+ max-inline-size: 100%;
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+
+.nv-nav__list::-webkit-scrollbar {
+ display: none;
}
.nv-nav__spacer {
@@ -169,14 +181,23 @@
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-highlight-top), var(--nv-elevation-1);
-
- view-transition-name: var(--nv-selection-name);
+ 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,
@@ -226,11 +247,15 @@
gap: var(--nv-space-1);
inline-size: 15rem;
block-size: 100svh;
+ flex-wrap: nowrap;
padding: var(--nv-space-4) var(--nv-space-3);
-
- background-color: var(--nv-color-background-subtle);
- backdrop-filter: none;
- -webkit-backdrop-filter: none;
+ /* A sidebar is a pane of the canvas rather than chrome over content, so it
+ stays mostly opaque — but it lets the light of the canvas through, which
+ is what stops it reading as a grey slab beside the page. */
+ background-color: color-mix(in oklab, var(--nv-color-background-subtle) 72%, transparent);
+ background-image: var(--nv-sheen-surface);
+ backdrop-filter: var(--nv-backdrop-frosted);
+ -webkit-backdrop-filter: var(--nv-backdrop-frosted);
border-block-end: 0;
border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: none;
@@ -252,11 +277,11 @@
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-caption);
- font-weight: var(--nv-font-weight-semibold);
- letter-spacing: var(--nv-letter-spacing-wider);
+ font: var(--nv-font-engraved);
+ letter-spacing: var(--nv-letter-spacing-engraved);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
@@ -272,13 +297,13 @@
.nv-nav-tabs {
display: inline-flex;
align-items: center;
+ max-inline-size: 100%;
gap: var(--nv-space-0-5);
padding: var(--nv-space-1);
-
- background-color: var(--nv-color-surface-inset);
+ background-color: color-mix(in oklab, var(--nv-color-surface-inset) 80%, transparent);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-md);
- box-shadow: var(--nv-elevation-inset-sm);
+ box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
overflow-x: auto;
scrollbar-width: none;
isolation: isolate;
@@ -307,12 +332,12 @@
/* --- 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. */
-
+ 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-name: var(--nv-content-name, nv-content);
+ view-transition-class: nv-content;
}
/* --- Breadcrumbs -------------------------------------------------------------------- */
diff --git a/docs/Website/niveus/niveus/components/skeleton.css b/docs/Website/niveus/niveus/components/skeleton.css
new file mode 100644
index 0000000..a79aec6
--- /dev/null
+++ b/docs/Website/niveus/niveus/components/skeleton.css
@@ -0,0 +1,60 @@
+/* ==========================================================================
+ Niveus — Skeleton
+ --------------------------------------------------------------------------
+
+
+ The shape of content that has not arrived yet. A skeleton is a shallow well
+ cut where the content will sit, with a slow band of light crossing it — the
+ surface is recessed and waiting, not broken.
+
+ .nv-skeleton a block; size it with the layout
+ .nv-skeleton--text one line of body text
+ .nv-skeleton--circle an avatar or a tile
+
+ Under reduced motion the light stops crossing and the well stays: the state
+ is still legible, it just does not move.
+ ========================================================================== */
+
+.nv-skeleton {
+ display: block;
+ min-block-size: var(--nv-space-4);
+ border-radius: var(--nv-radius-md);
+ background-color: var(--nv-color-surface-inset);
+ background-image: linear-gradient(
+ 100deg,
+ transparent 30%,
+ var(--nv-color-sheen-top) 50%,
+ transparent 70%
+ );
+ background-size: 220% 100%;
+ box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
+ animation: nv-shimmer var(--nv-duration-shimmer) var(--nv-ease-linear) infinite;
+}
+
+.nv-skeleton--text {
+ inline-size: 100%;
+ min-block-size: 0;
+ block-size: 0.75em;
+ border-radius: var(--nv-radius-full);
+}
+
+/* A paragraph of skeleton lines ends short, like the paragraph it stands for. */
+.nv-skeleton--text:last-child:not(:first-child) {
+ inline-size: 60%;
+}
+
+.nv-skeleton--circle {
+ aspect-ratio: 1;
+ border-radius: var(--nv-radius-full);
+}
+
+@keyframes nv-shimmer {
+ from { background-position: 100% 0; }
+ to { background-position: -120% 0; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .nv-skeleton {
+ animation: none;
+ }
+}
diff --git a/docs/Website/niveus/niveus/components/stat.css b/docs/Website/niveus/niveus/components/stat.css
new file mode 100644
index 0000000..7a140f0
--- /dev/null
+++ b/docs/Website/niveus/niveus/components/stat.css
@@ -0,0 +1,74 @@
+/* ==========================================================================
+ Niveus — Stat
+ --------------------------------------------------------------------------
+
+
+
Servers
+ 1,204
+
+
+
+ A number the user is meant to read at a glance. A group of stats is set into
+ one shallow well, like the readout panel of an instrument: the figures sit
+ behind the surface, separated by hairlines, so a row of numbers reads as one
+ display instead of as four loose labels.
+
+ .nv-stat-group the readout well; wraps without a media query
+ .nv-stat one reading
+ .nv-stat__label what is measured
+ .nv-stat__value the figure, in tabular numerals
+ .nv-stat__delta change since last time; --up / --down
+ ========================================================================== */
+
+.nv-stat-group {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr));
+ /* The hairlines between readings are the well itself showing through a
+ one-pixel gap, so they stay continuous however the row wraps. */
+ gap: var(--nv-border-width);
+ margin: 0;
+ background-color: var(--nv-color-border-subtle);
+ border: var(--nv-border-width) solid var(--nv-color-border-subtle);
+ border-radius: var(--nv-radius-lg);
+ box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
+ overflow: hidden;
+}
+
+.nv-stat {
+ display: flex;
+ flex-direction: column;
+ gap: var(--nv-space-1);
+ min-inline-size: 0;
+ padding: var(--nv-space-4) var(--nv-space-5);
+}
+
+.nv-stat-group > .nv-stat {
+ background-color: var(--nv-color-surface-inset);
+}
+
+.nv-stat__label {
+ font: var(--nv-font-caption);
+ color: var(--nv-color-text-tertiary);
+}
+
+.nv-stat__value {
+ margin: 0;
+ font: var(--nv-font-heading);
+ font-variant-numeric: tabular-nums;
+ letter-spacing: var(--nv-letter-spacing-tight);
+ color: var(--nv-color-text-primary);
+}
+
+.nv-stat__delta {
+ font: var(--nv-font-caption);
+ font-variant-numeric: tabular-nums;
+ color: var(--nv-color-text-secondary);
+}
+
+.nv-stat__delta--up {
+ color: var(--nv-color-success);
+}
+
+.nv-stat__delta--down {
+ color: var(--nv-color-danger);
+}
diff --git a/docs/Website/niveus/niveus/components/steps.css b/docs/Website/niveus/niveus/components/steps.css
new file mode 100644
index 0000000..6aa4598
--- /dev/null
+++ b/docs/Website/niveus/niveus/components/steps.css
@@ -0,0 +1,157 @@
+/* ==========================================================================
+ Niveus — Steps
+ --------------------------------------------------------------------------
+
+
+ 1
+
+
+
+
+ A sequence the user moves through: a process, an onboarding, an order's
+ progress. The steps sit on a rail — a groove cut into the surface — with
+ each marker a raised knob set into it. Finished steps light their stretch
+ of the rail, so progress reads as something filling up rather than as a
+ row of ticks.
+
+ .nv-steps horizontal; becomes vertical on narrow screens
+ .nv-steps--vertical always vertical
+ data-state="done" the step and its stretch of rail are lit
+ aria-current="step" the step in progress: lit marker, rail still dark
+ ========================================================================== */
+
+.nv-steps {
+ --_marker: 2.25rem;
+ --_rail: 6px;
+ --_gap: var(--nv-space-4);
+
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(0, 1fr);
+ gap: var(--_gap);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.nv-steps__item {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: var(--nv-space-3);
+ min-inline-size: 0;
+}
+
+/* Each step draws the rail from its own marker to the next one, so a finished
+ step can light exactly its stretch and the rail never runs past the end. */
+.nv-steps__item:not(:last-child)::before {
+ content: "";
+ position: absolute;
+ inset-block-start: calc((var(--_marker) - var(--_rail)) / 2);
+ inset-inline-start: calc(var(--_marker) / 2);
+ inline-size: calc(100% + var(--_gap));
+ block-size: var(--_rail);
+ border-radius: var(--nv-radius-full);
+ background-color: var(--nv-color-surface-inset);
+ box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
+ transition: var(--nv-transition-surface);
+}
+
+.nv-steps__item[data-state="done"]:not(:last-child)::before {
+ background-color: var(--nv-color-accent);
+ background-image: var(--nv-sheen-filled);
+ box-shadow: var(--nv-glow-sm);
+}
+
+.nv-steps__marker {
+ position: relative;
+ z-index: 1;
+ display: grid;
+ place-items: center;
+ flex: none;
+ inline-size: var(--_marker);
+ block-size: var(--_marker);
+
+ background-color: var(--nv-color-surface-elevated);
+ background-image: var(--nv-sheen-raised);
+ color: var(--nv-color-text-secondary);
+ font: var(--nv-font-label);
+ font-weight: var(--nv-font-weight-semibold);
+ font-variant-numeric: tabular-nums;
+ border-radius: var(--nv-radius-full);
+ box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
+ transition: var(--nv-transition-surface);
+}
+
+.nv-steps__item[data-state="done"] > .nv-steps__marker,
+.nv-steps__item[aria-current="step"] > .nv-steps__marker {
+ background-color: var(--nv-color-accent);
+ background-image: var(--nv-sheen-filled);
+ color: var(--nv-color-on-accent);
+ box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), var(--nv-glow-md);
+}
+
+.nv-steps__body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--nv-space-1);
+ min-inline-size: 0;
+}
+
+.nv-steps__title {
+ margin: 0;
+ font: var(--nv-font-title);
+ letter-spacing: var(--nv-letter-spacing-tight);
+ color: var(--nv-color-text-primary);
+}
+
+.nv-steps__text {
+ margin: 0;
+ font: var(--nv-font-caption);
+ color: var(--nv-color-text-secondary);
+}
+
+/* --- Vertical -------------------------------------------------------------------
+ Marker beside the text and the rail running down between markers. Also what
+ a horizontal sequence becomes once its columns are too narrow to read. */
+
+.nv-steps--vertical {
+ grid-auto-flow: row;
+ grid-auto-columns: auto;
+ --_gap: var(--nv-space-5);
+}
+
+.nv-steps--vertical > .nv-steps__item {
+ flex-direction: row;
+ gap: var(--nv-space-4);
+}
+
+.nv-steps--vertical > .nv-steps__item:not(:last-child)::before {
+ inset-block-start: calc(var(--_marker) / 2);
+ inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2);
+ inline-size: var(--_rail);
+ block-size: calc(100% + var(--_gap));
+}
+
+@media (max-width: 40rem) {
+ .nv-steps {
+ grid-auto-flow: row;
+ grid-auto-columns: auto;
+ --_gap: var(--nv-space-5);
+ }
+
+ .nv-steps > .nv-steps__item {
+ flex-direction: row;
+ gap: var(--nv-space-4);
+ }
+
+ .nv-steps > .nv-steps__item:not(:last-child)::before {
+ inset-block-start: calc(var(--_marker) / 2);
+ inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2);
+ inline-size: var(--_rail);
+ block-size: calc(100% + var(--_gap));
+ }
+}
diff --git a/docs/Website/niveus/niveus/components/tile.css b/docs/Website/niveus/niveus/components/tile.css
new file mode 100644
index 0000000..f8083b7
--- /dev/null
+++ b/docs/Website/niveus/niveus/components/tile.css
@@ -0,0 +1,83 @@
+/* ==========================================================================
+ Niveus — Tile
+ --------------------------------------------------------------------------
+
+
+ A tile is a small raised key that holds one glyph: the icon beside a
+ feature, the mark of a product, the symbol on a stat. It is the physical
+ version of an icon — something with an edge you could press — which is why
+ a row of features reads as a set of objects rather than as clip art.
+
+ .nv-tile soft key: tinted face, the glyph in its tint
+ .nv-tile--solid filled key: saturated face, a light glyph
+ .nv-tile--glow the key is lit from inside
+ .nv-tile--sm / --lg / --xl
+
+ Everything derives from --nv-tile-tint, the accent unless set. A tile is
+ not a button: if it does something, it is an .nv-button--icon.
+ ========================================================================== */
+
+.nv-tile {
+ --nv-tile-tint: var(--nv-color-accent);
+ --_size: 2.75rem;
+ --_radius: var(--nv-radius-md);
+ --_bg: color-mix(in oklab, var(--nv-tile-tint) 16%, var(--nv-color-surface-elevated));
+ --_ink: color-mix(in oklab, var(--nv-tile-tint) 70%, var(--nv-color-text-primary));
+ --_sheen: var(--nv-sheen-raised);
+ --_glow: 0 0 transparent;
+
+ position: relative;
+ display: inline-grid;
+ place-items: center;
+ flex: none;
+ inline-size: var(--_size);
+ block-size: var(--_size);
+
+ background-color: var(--_bg);
+ background-image: var(--_sheen);
+ color: var(--_ink);
+ border: var(--nv-border-width) solid color-mix(in oklab, var(--nv-tile-tint) 30%, var(--nv-color-border-subtle));
+ border-radius: var(--_radius);
+ box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow);
+ transition: var(--nv-transition-surface);
+}
+
+/* Glyphs take half the key; a mark that is itself a picture (an image, a
+ logo) fills it, clipped to the key's corners. */
+.nv-tile > svg {
+ inline-size: 50%;
+ block-size: 50%;
+}
+
+.nv-tile > img {
+ inline-size: 100%;
+ block-size: 100%;
+ border-radius: inherit;
+ object-fit: cover;
+}
+
+.nv-tile--solid {
+ --_bg: var(--nv-tile-tint);
+ --_ink: var(--nv-color-on-accent);
+ --_sheen: var(--nv-sheen-filled);
+}
+
+.nv-tile--glow {
+ --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm)
+ color-mix(in oklab, var(--nv-tile-tint) var(--nv-glow-strength), transparent);
+}
+
+.nv-tile--sm {
+ --_size: 2rem;
+ --_radius: var(--nv-radius-sm);
+}
+
+.nv-tile--lg {
+ --_size: 3.5rem;
+ --_radius: var(--nv-radius-lg);
+}
+
+.nv-tile--xl {
+ --_size: 4.5rem;
+ --_radius: var(--nv-radius-xl);
+}
diff --git a/docs/Website/niveus/niveus/components/window.css b/docs/Website/niveus/niveus/components/window.css
new file mode 100644
index 0000000..9551359
--- /dev/null
+++ b/docs/Website/niveus/niveus/components/window.css
@@ -0,0 +1,192 @@
+/* ==========================================================================
+ 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
+
+
+
+ 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);
+ 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-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);
+ /* Slim, as titlebars on the desktop are: tall enough for the lights and
+ a label, not a toolbar's height. */
+ min-block-size: calc(var(--nv-control-height-sm) + var(--nv-space-1));
+ padding-inline: var(--nv-space-3);
+
+ 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;
+}
diff --git a/docs/Website/niveus/niveus/continuity.css b/docs/Website/niveus/niveus/continuity.css
index 772a754..98fde02 100644
--- a/docs/Website/niveus/niveus/continuity.css
+++ b/docs/Website/niveus/niveus/continuity.css
@@ -60,9 +60,9 @@
The fix is to capture the labels too and order the groups explicitly. The
marker travels below; the items ride above it, exactly as they do at rest.
- niveus.js assigns each item its name — they have to be unique per document,
- which no stylesheet can guarantee — and items it has not named simply stay in
- the page snapshot.
+ niveus.js names the items of the group that is changing, and only while it
+ changes — names have to be unique per document, which no stylesheet can
+ guarantee — and every other item simply stays in the page snapshot.
Because the layering is now correct, the marker needs no trick to stay out of
the way: it travels at full opacity, like the solid object it is. */
@@ -71,6 +71,16 @@
z-index: 0;
}
+/* The marker changes shape as it travels — a short label's pill becomes a
+ long one's. By default the browser scales each snapshot to the group's
+ width and keeps its aspect ratio, so mid-way a 40×26 pill stretched to
+ 80px wide is drawn 52px tall, hanging out below its strip. The marker is a
+ plain surface, so its snapshots fill the box instead. */
+::view-transition-old(.nv-travelling),
+::view-transition-new(.nv-travelling) {
+ block-size: 100%;
+}
+
::view-transition-group(.nv-carried) {
z-index: 1;
}
@@ -101,40 +111,56 @@
Moving forward and moving back should not look the same. The application
sets data-nv-direction on the root for the length of the transition; content
then leaves towards where the user came from and enters from where they are
- going. Without a direction, content simply rises into place. */
+ going. Without a direction, content simply rises into place.
-::view-transition-old(nv-content) {
- animation: nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both;
+ Content hands over; it does not cross-fade. Two blocks of text at half
+ opacity on top of each other read as a rendering fault, not as motion, so
+ the outgoing content is gone (fast) before the incoming arrives (delayed by
+ that same beat). The box itself never empties for longer than a blink.
+
+ Any element whose content changes in place takes part by carrying the
+ nv-content class: .nv-continuous and .nv-tab-panel both do.
+
+ The box clips what it holds. When a tall panel gives way to a short one,
+ the page around it has already reflowed to the new height, so unclipped
+ outgoing content would spill out below its own card. */
+
+::view-transition-group(.nv-content) {
+ overflow: clip;
}
-::view-transition-new(nv-content) {
- animation:
- nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
- nv-rise var(--nv-duration-normal) var(--nv-ease-entrance) both;
+::view-transition-old(.nv-content) {
+ animation: nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both;
}
-:root[data-nv-direction="forward"]::view-transition-old(nv-content) {
+::view-transition-new(.nv-content) {
animation:
- nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both,
- nv-exit-to-start var(--nv-duration-normal) var(--nv-ease-emphasized) both;
+ nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both,
+ nv-rise var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both;
}
-:root[data-nv-direction="forward"]::view-transition-new(nv-content) {
+:root[data-nv-direction="forward"]::view-transition-old(.nv-content) {
animation:
- nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
- nv-enter-from-end var(--nv-duration-normal) var(--nv-ease-emphasized) both;
+ nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both,
+ nv-exit-to-start var(--nv-duration-fast) var(--nv-ease-exit) both;
}
-:root[data-nv-direction="backward"]::view-transition-old(nv-content) {
+:root[data-nv-direction="forward"]::view-transition-new(.nv-content) {
animation:
- nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both,
- nv-exit-to-end var(--nv-duration-normal) var(--nv-ease-emphasized) both;
+ nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both,
+ nv-enter-from-end var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both;
}
-:root[data-nv-direction="backward"]::view-transition-new(nv-content) {
+:root[data-nv-direction="backward"]::view-transition-old(.nv-content) {
animation:
- nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
- nv-enter-from-start var(--nv-duration-normal) var(--nv-ease-emphasized) both;
+ nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both,
+ nv-exit-to-end var(--nv-duration-fast) var(--nv-ease-exit) both;
+}
+
+:root[data-nv-direction="backward"]::view-transition-new(.nv-content) {
+ animation:
+ nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both,
+ nv-enter-from-start var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both;
}
@keyframes nv-fade-in {
@@ -171,6 +197,34 @@
.nv-continuous {
view-transition-name: var(--nv-content-name, nv-content);
+ view-transition-class: nv-content;
+}
+
+/* --- Reveal ------------------------------------------------------------------------
+ Content arriving as it scrolls into view: it rises a short way and fades in,
+ once, so a long page is read in the order it was written instead of all at
+ once. .nv-reveal-group does the same for its children one after another,
+ --nv-stagger apart.
+
+ niveus.js marks what is waiting. Without the script, and under reduced
+ motion, nothing is ever marked — so nothing is ever hidden. */
+
+.nv-reveal[data-nv-reveal="pending"],
+.nv-reveal-group > [data-nv-reveal="pending"] {
+ opacity: 0;
+}
+
+.nv-reveal[data-nv-reveal="shown"],
+.nv-reveal-group > [data-nv-reveal="shown"] {
+ animation: nv-arrive var(--nv-duration-slower) var(--nv-ease-entrance)
+ calc(var(--nv-reveal-index, 0) * var(--nv-stagger)) both;
+}
+
+@keyframes nv-arrive {
+ from {
+ opacity: 0;
+ translate: 0 var(--nv-travel-lg);
+ }
}
/* Collapsible region for markup that is not a
. The 0fr -> 1fr grid
diff --git a/docs/Website/niveus/niveus/fonts.css b/docs/Website/niveus/niveus/fonts.css
new file mode 100644
index 0000000..16f861e
--- /dev/null
+++ b/docs/Website/niveus/niveus/fonts.css
@@ -0,0 +1,42 @@
+/* ==========================================================================
+ Niveus — Fonts
+ --------------------------------------------------------------------------
+ Niveus ships its own faces, so it reads the same wherever it is used
+ instead of depending on what a machine happens to have installed. Both are
+ variable fonts under the SIL Open Font License (fonts/OFL-*.txt), free to
+ bundle with anything.
+
+ Instrument Sans the interface. Crisp, slightly narrow, warm at small
+ sizes — the voice of every label, control and heading.
+ JetBrains Mono code, figures, and the engraved labels (.nv-overline)
+ that mark sections the way markings mark an instrument.
+
+ The paths are relative to this file and to dist/niveus.css alike: both sit
+ one level below the root, where fonts/ lives.
+ ========================================================================== */
+
+@font-face {
+ font-family: "Instrument Sans";
+ src: url("../fonts/InstrumentSans.woff2") format("woff2");
+ font-weight: 400 700;
+ font-stretch: 75% 100%;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Instrument Sans";
+ src: url("../fonts/InstrumentSans-Italic.woff2") format("woff2");
+ font-weight: 400 700;
+ font-stretch: 75% 100%;
+ font-style: italic;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("../fonts/JetBrainsMono.woff2") format("woff2");
+ font-weight: 100 800;
+ font-style: normal;
+ font-display: swap;
+}
diff --git a/docs/Website/niveus/niveus/niveus.css b/docs/Website/niveus/niveus/niveus.css
index fa1425f..3f198b2 100644
--- a/docs/Website/niveus/niveus/niveus.css
+++ b/docs/Website/niveus/niveus/niveus.css
@@ -1,5 +1,5 @@
/* ==========================================================================
- Niveus — a calm, adaptive interface language.
+ Niveus — a tactile, adaptive interface language.
Made by Nexoniarz.
--------------------------------------------------------------------------
The single entry point. An application links this file and nothing else:
@@ -22,6 +22,9 @@
@layer niveus.tokens, niveus.theme, niveus.base, niveus.continuity,
niveus.components, niveus.utilities;
+/* --- Fonts: the faces Niveus ships, so it reads the same everywhere ---------- */
+@import url("fonts.css");
+
/* --- Tokens: the values, appearance-agnostic ------------------------------- */
@import url("tokens/color.css") layer(niveus.tokens);
@import url("tokens/typography.css") layer(niveus.tokens);
@@ -54,6 +57,13 @@
@import url("components/bed.css") layer(niveus.components);
@import url("components/badge.css") layer(niveus.components);
@import url("components/table.css") layer(niveus.components);
+@import url("components/window.css") layer(niveus.components);
+@import url("components/tile.css") layer(niveus.components);
+@import url("components/avatar.css") layer(niveus.components);
+@import url("components/stat.css") layer(niveus.components);
+@import url("components/steps.css") layer(niveus.components);
+@import url("components/deck.css") layer(niveus.components);
+@import url("components/skeleton.css") layer(niveus.components);
/* --- Utilities ----------------------------------------------------------------- */
@import url("utilities.css") layer(niveus.utilities);
diff --git a/docs/Website/niveus/niveus/niveus.js b/docs/Website/niveus/niveus/niveus.js
index 5eb959a..144187e 100644
--- a/docs/Website/niveus/niveus/niveus.js
+++ b/docs/Website/niveus/niveus/niveus.js
@@ -13,7 +13,7 @@
state (a router, a framework) should call nv.transition() around its own
update and ignore the rest.
- nv.transition(update, { direction, kind }) run an update continuously
+ nv.transition(update, { direction, kind, scope }) run an update continuously
nv.setTheme("dark" | "light" | "system") cross-fade the appearance
nv.openDialog(dialog, trigger) open it out of its trigger
nv.selectTab(tablist, tab) move through the same surface
@@ -27,9 +27,16 @@
[data-nv-close] button that closes its dialog or popover
[data-nv-scroller] frame whose [data-nv-scroll] controls move
its .nv-scroller a page at a time
+ .nv-reveal / .nv-reveal-group content that arrives as it scrolls into
+ view, once (see continuity.css)
+ .nv-card--spotlight card whose light follows the pointer
- Wiring also names every navigation item, which is what lets a travelling
- selection marker pass *behind* the labels rather than over them.
+ Only what changes is lifted. A transition names the selection marker and
+ the items of the groups it is given as `scope`, and only for as long as it
+ runs; everything else stays in the one page snapshot. Naming every item on
+ the page permanently made every transition — a theme change included —
+ tear the page into dozens of layers, which is both slow and what pulled
+ items out from under frosted chrome.
Reduced motion is honoured by skipping the view transition entirely: the
update still runs, it simply arrives instead of travelling.
@@ -51,7 +58,10 @@
/**
* Run `update` so the user can follow the change.
* `direction` ("forward" | "backward") tells content which way to travel;
- * `kind` ("theme") marks a change the whole surface takes part in.
+ * `kind` ("theme") marks a change the whole surface takes part in;
+ * `scope` (an element or a list of them) names the selection groups whose
+ * marker should travel. Without a scope, nothing is lifted and the change
+ * cross-fades in place.
*/
function transition(update, options) {
options = options || {};
@@ -63,11 +73,20 @@
if (options.direction) root.dataset.nvDirection = options.direction;
if (options.kind) root.dataset.nvTransition = options.kind;
+ var groups = [].concat(options.scope || []).filter(Boolean);
+ var lifted = [];
+ groups.forEach(function (group) {
+ group.setAttribute("data-nv-travelling", "");
+ lifted = lifted.concat(liftLabels(group));
+ });
+
var view = document.startViewTransition(update);
function clear() {
delete root.dataset.nvDirection;
delete root.dataset.nvTransition;
+ groups.forEach(function (group) { group.removeAttribute("data-nv-travelling"); });
+ lifted.forEach(function (item) { item.style.viewTransitionName = ""; });
}
view.finished.then(clear, clear);
@@ -146,16 +165,32 @@
}
if (next < 0 || next === current) return Promise.resolve();
+ // The outgoing and incoming panels carry one name between them — the old
+ // one in the old state, the new one in the new — so the browser treats
+ // them as one surface whose content changes. Named only for the length of
+ // the change, so any number of tab sets can live on one page.
+ var panelOf = function (candidate) {
+ return candidate ? document.getElementById(candidate.getAttribute("aria-controls")) : null;
+ };
+ var outgoing = panelOf(tabs[current]);
+ var incoming = panelOf(tab);
+ if (outgoing && canTransition()) outgoing.style.viewTransitionName = "nv-panel";
+
var done = transition(function () {
tabs.forEach(function (candidate, index) {
var selected = index === next;
candidate.setAttribute("aria-selected", String(selected));
candidate.tabIndex = selected ? 0 : -1;
- var panel = document.getElementById(candidate.getAttribute("aria-controls"));
+ var panel = panelOf(candidate);
if (panel) panel.hidden = !selected;
});
- }, { direction: next > current ? "forward" : "backward" });
+ if (outgoing) outgoing.style.viewTransitionName = "";
+ if (incoming && canTransition()) incoming.style.viewTransitionName = "nv-panel";
+ }, { direction: next > current ? "forward" : "backward", scope: tablist });
+
+ var settle = function () { if (incoming) incoming.style.viewTransitionName = ""; };
+ done.then(settle, settle);
tab.focus();
return done;
@@ -240,29 +275,84 @@
}
/**
- * Give every item in a selection group a view-transition name, so it is
- * captured as its own layer and rides *above* the travelling marker instead
- * of being painted over by it.
+ * Give every item in `scope` a view-transition name, so it is captured as
+ * its own layer and rides *above* the travelling marker instead of being
+ * painted over by it. Returns the items it named, so the caller can take
+ * the names back off; transition() does this for its `scope` and clears
+ * them when the change is over.
*
* This cannot live in CSS: a view-transition-name must be unique across the
- * whole document, and a stylesheet cannot count how many navigations a page
- * happens to contain. Items already carrying a name are left alone, so an
- * application can assign its own.
+ * whole document, and a stylesheet cannot count items. Items already
+ * carrying a name are left alone, so an application can assign its own.
*/
var carried = 0;
function liftLabels(scope) {
- var groups = scope.querySelectorAll(".nv-nav, .nv-nav-tabs, [data-nv-travel]");
+ var items = scope.querySelectorAll(".nv-nav__item");
+ var named = [];
- for (var i = 0; i < groups.length; i++) {
- var items = groups[i].querySelectorAll(".nv-nav__item");
-
- for (var j = 0; j < items.length; j++) {
- if (items[j].style.viewTransitionName) continue;
- carried += 1;
- items[j].style.viewTransitionName = "nv-carried-" + carried;
- }
+ for (var j = 0; j < items.length; j++) {
+ if (items[j].style.viewTransitionName) continue;
+ carried += 1;
+ items[j].style.viewTransitionName = "nv-carried-" + carried;
+ named.push(items[j]);
}
+ return named;
+ }
+
+ /**
+ * Reveal content as it scrolls into view. Elements are only marked as
+ * waiting here, so a page without this script — or under reduced motion —
+ * never hides anything. Each element arrives once and is then left alone.
+ */
+ var revealer = null;
+
+ function arrive(target) {
+ var items = target.classList.contains("nv-reveal-group") ? target.children : [target];
+ for (var i = 0; i < items.length; i++) {
+ var item = items[i];
+ item.style.setProperty("--nv-reveal-index", String(i));
+ item.dataset.nvReveal = "shown";
+ item.addEventListener("animationend", function settle(event) {
+ if (event.target !== this || event.animationName !== "nv-arrive") return;
+ this.removeEventListener("animationend", settle);
+ delete this.dataset.nvReveal;
+ this.style.removeProperty("--nv-reveal-index");
+ });
+ }
+ }
+
+ function initReveal(scope) {
+ if (prefersReducedMotion() || typeof global.IntersectionObserver !== "function") return;
+
+ revealer = revealer || new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
+ if (!entry.isIntersecting) return;
+ revealer.unobserve(entry.target);
+ arrive(entry.target);
+ });
+ }, { rootMargin: "0px 0px -8% 0px" });
+
+ scope.querySelectorAll(".nv-reveal, .nv-reveal-group").forEach(function (target) {
+ if (target.dataset.nvRevealWired) return;
+ target.dataset.nvRevealWired = "";
+ var items = target.classList.contains("nv-reveal-group") ? target.children : [target];
+ for (var i = 0; i < items.length; i++) items[i].dataset.nvReveal = "pending";
+ revealer.observe(target);
+ });
+ }
+
+ /**
+ * The light on a .nv-card--spotlight sits under the pointer. One listener
+ * for the whole document, so cards added later need no wiring.
+ */
+ function trackSpotlight(event) {
+ if (event.pointerType && event.pointerType !== "mouse") return;
+ var surface = event.target.closest && event.target.closest(".nv-card--spotlight");
+ if (!surface) return;
+ var box = surface.getBoundingClientRect();
+ surface.style.setProperty("--nv-pointer-x", event.clientX - box.left + "px");
+ surface.style.setProperty("--nv-pointer-y", event.clientY - box.top + "px");
}
function wire(scope) {
@@ -282,7 +372,7 @@
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
- liftLabels(scope);
+ initReveal(scope);
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
control.addEventListener("click", function () {
@@ -314,6 +404,8 @@
prefersReducedMotion: prefersReducedMotion,
};
+ document.addEventListener("pointermove", trackSpotlight, { passive: true });
+
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { wire(); });
} else {
diff --git a/docs/Website/niveus/niveus/themes/dark.css b/docs/Website/niveus/niveus/themes/dark.css
index 0b79aeb..0c41e85 100644
--- a/docs/Website/niveus/niveus/themes/dark.css
+++ b/docs/Website/niveus/niveus/themes/dark.css
@@ -5,11 +5,13 @@
light-dark() in tokens/color.css. Like the light appearance, almost nothing
here is a literal color — every role is derived from --nv-brand.
- Dark Niveus is not the light theme inverted. Surfaces get *lighter* as they
- rise, shadows deepen rather than disappear, and the top highlight thins to a
- faint rim so elevation still reads without glare. The accent moves up its
- own ramp too: a mid-tone that reads as vivid on paper reads as muddy on a
- dark canvas.
+ Dark Niveus is warm graphite, not the light theme inverted. Surfaces get
+ *lighter* as they rise and each step is large enough to see; shadows are
+ deep and every raised thing carries a near-black contact line, because on a
+ dark canvas a shadow alone has nothing left to darken. The bevel is what
+ carries depth here — a lit top edge, a shaded bottom one — and filled color
+ stays saturated: a vivid accent with a light label reads as a lit key, not
+ as a hole.
========================================================================== */
:root[data-theme="dark"] {
@@ -19,20 +21,20 @@
:root,
[data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */
- --nv-dark-background: color-mix(in oklab, #0b0e14, var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-background-subtle: color-mix(in oklab, #10141b, var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-surface: color-mix(in oklab, #171c24, var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-surface-elevated: color-mix(in oklab, #1e242e, var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-surface-inset: color-mix(in oklab, #10141b, var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-surface-hover: color-mix(in oklab, #222835, var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-surface-active: color-mix(in oklab, #2a3140, var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-scrim: rgb(3 5 9 / 0.62);
+ --nv-dark-background: color-mix(in oklab, #0f0f12, var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-background-subtle: color-mix(in oklab, #18181b, var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-surface: color-mix(in oklab, #1f1f22, var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-surface-elevated: color-mix(in oklab, #2a2a2e, var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-surface-inset: color-mix(in oklab, #111113, var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-surface-hover: color-mix(in oklab, #313136, var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-surface-active: color-mix(in oklab, #39393f, var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-scrim: rgb(6 6 8 / 0.62);
/* --- Text --------------------------------------------------------------- */
- --nv-dark-text-primary: #e9edf4;
- --nv-dark-text-secondary: color-mix(in oklab, #a3aebf, var(--nv-brand) 8%);
- --nv-dark-text-tertiary: color-mix(in oklab, #7a8494, var(--nv-brand) 8%);
- --nv-dark-text-disabled: #58616f;
+ --nv-dark-text-primary: #ecebe8;
+ --nv-dark-text-secondary: color-mix(in oklab, #a9a8a3, var(--nv-brand) 8%);
+ --nv-dark-text-tertiary: color-mix(in oklab, #7d7c77, var(--nv-brand) 8%);
+ --nv-dark-text-disabled: #58585c;
--nv-dark-text-inverted: var(--nv-palette-neutral-975);
/* --- Borders ------------------------------------------------------------ */
@@ -41,30 +43,38 @@
--nv-dark-border-strong: rgb(255 255 255 / 0.2);
/* --- Accent -------------------------------------------------------------
- Lifted up the ramp and slightly desaturated: saturated mid-tones read as
- muddy on dark canvases, and fully saturated light ones read as neon. The
- floor also puts every filled color above the pivot, so on a dark canvas
- the derived label is always the dark one — which is what makes an accent
- button read as lit rather than as a hole. */
- --nv-dark-accent: oklch(from var(--nv-brand) clamp(0.72, calc(l + 0.12), 0.86) clamp(0.02, calc(c * 0.92), 0.2) h);
+ The same derivation as light: a fill is pushed out of the mid-tones until
+ its label clears 4.5:1, so the brand keeps its full saturation and a light
+ label. Earlier Niveus lifted the dark accent into pastel to avoid neon;
+ with bevel and sheen doing the shaping, a saturated fill reads as a lit
+ key instead, and the pastel was what made the whole system feel washed
+ out. Only the accent *text* (links, subtle labels) moves up the ramp,
+ because text on a dark canvas needs the lightness that a fill does not. */
+ --nv-dark-accent: oklch(from var(--nv-brand) calc(
+ clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
+ (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
+ ) clamp(0.02, c, 0.25) h);
--nv-dark-accent-hover: oklch(from var(--nv-dark-accent) calc(l + 0.05) c h);
--nv-dark-accent-active: oklch(from var(--nv-dark-accent) calc(l + 0.09) c h);
- --nv-dark-accent-subtle: oklch(from var(--nv-dark-accent) l c h / 0.15);
- --nv-dark-accent-subtle-hover: oklch(from var(--nv-dark-accent) l c h / 0.22);
- --nv-dark-accent-border: oklch(from var(--nv-dark-accent) l c h / 0.38);
- --nv-dark-accent-text: oklch(from var(--nv-dark-accent) clamp(0.72, calc(l + 0.08), 0.9) calc(c * 0.9) h);
+ --nv-dark-accent-subtle: oklch(from var(--nv-dark-accent) l c h / 0.2);
+ --nv-dark-accent-subtle-hover: oklch(from var(--nv-dark-accent) l c h / 0.28);
+ --nv-dark-accent-border: oklch(from var(--nv-dark-accent) l c h / 0.5);
+ --nv-dark-accent-text: oklch(from var(--nv-dark-accent) clamp(0.74, calc(l + 0.2), 0.9) calc(c * 0.9) h);
--nv-dark-on-accent: oklch(from var(--nv-dark-accent)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Success ------------------------------------------------------------ */
--nv-dark-success: oklch(from
- color-mix(in oklab, var(--nv-palette-success-400), var(--nv-brand) var(--nv-brand-harmony))
- clamp(0.72, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
+ color-mix(in oklab, var(--nv-palette-success-500), var(--nv-brand) var(--nv-brand-harmony))
+ calc(
+ clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
+ (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
+ ) clamp(0.02, c, 0.25) h);
--nv-dark-success-hover: oklch(from var(--nv-dark-success) calc(l + 0.05) c h);
- --nv-dark-success-subtle: oklch(from var(--nv-dark-success) l c h / 0.15);
+ --nv-dark-success-subtle: oklch(from var(--nv-dark-success) l c h / 0.2);
--nv-dark-success-border: oklch(from var(--nv-dark-success) l c h / 0.35);
- --nv-dark-success-text: oklch(from var(--nv-dark-success) clamp(0.74, calc(l + 0.08), 0.9) calc(c * 0.9) h);
+ --nv-dark-success-text: oklch(from var(--nv-dark-success) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h);
--nv-dark-on-success: oklch(from var(--nv-dark-success)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
@@ -72,49 +82,78 @@
/* --- Warning ------------------------------------------------------------ */
--nv-dark-warning: oklch(from
color-mix(in oklab, var(--nv-palette-warning-400), var(--nv-brand) var(--nv-brand-harmony))
- clamp(0.74, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
+ calc(
+ clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
+ (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
+ ) clamp(0.02, c, 0.25) h);
--nv-dark-warning-hover: oklch(from var(--nv-dark-warning) calc(l + 0.05) c h);
- --nv-dark-warning-subtle: oklch(from var(--nv-dark-warning) l c h / 0.15);
+ --nv-dark-warning-subtle: oklch(from var(--nv-dark-warning) l c h / 0.2);
--nv-dark-warning-border: oklch(from var(--nv-dark-warning) l c h / 0.35);
- --nv-dark-warning-text: oklch(from var(--nv-dark-warning) clamp(0.76, calc(l + 0.08), 0.9) calc(c * 0.9) h);
+ --nv-dark-warning-text: oklch(from var(--nv-dark-warning) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h);
--nv-dark-on-warning: oklch(from var(--nv-dark-warning)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Danger ------------------------------------------------------------- */
--nv-dark-danger: oklch(from
- color-mix(in oklab, var(--nv-palette-danger-400), var(--nv-brand) var(--nv-brand-harmony))
- clamp(0.72, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
+ color-mix(in oklab, var(--nv-palette-danger-500), var(--nv-brand) var(--nv-brand-harmony))
+ calc(
+ clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
+ (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
+ ) clamp(0.02, c, 0.25) h);
--nv-dark-danger-hover: oklch(from var(--nv-dark-danger) calc(l + 0.05) c h);
--nv-dark-danger-active: oklch(from var(--nv-dark-danger) calc(l + 0.09) c h);
- --nv-dark-danger-subtle: oklch(from var(--nv-dark-danger) l c h / 0.15);
+ --nv-dark-danger-subtle: oklch(from var(--nv-dark-danger) l c h / 0.2);
--nv-dark-danger-border: oklch(from var(--nv-dark-danger) l c h / 0.35);
- --nv-dark-danger-text: oklch(from var(--nv-dark-danger) clamp(0.74, calc(l + 0.08), 0.9) calc(c * 0.9) h);
+ --nv-dark-danger-text: oklch(from var(--nv-dark-danger) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h);
--nv-dark-on-danger: oklch(from var(--nv-dark-danger)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Depth and material -------------------------------------------------- */
- --nv-dark-shadow-ambient: rgb(0 0 0 / 0.3);
- --nv-dark-shadow-penumbra: rgb(0 0 0 / 0.4);
- --nv-dark-shadow-umbra: rgb(0 0 0 / 0.35);
- --nv-dark-shadow-inset: rgb(0 0 0 / 0.5);
- --nv-dark-highlight: rgb(255 255 255 / 0.07);
- --nv-dark-highlight-strong: rgb(255 255 255 / 0.12);
+ --nv-dark-shadow-ambient: rgb(0 0 0 / 0.5);
+ --nv-dark-shadow-penumbra: rgb(0 0 0 / 0.5);
+ --nv-dark-shadow-umbra: rgb(0 0 0 / 0.55);
+ --nv-dark-shadow-inset: rgb(0 0 0 / 0.65);
+ --nv-dark-highlight: rgb(255 255 255 / 0.15);
+ --nv-dark-highlight-strong: rgb(255 255 255 / 0.24);
+
+ /* The contact line is what separates graphite from graphite: a raised
+ surface over a dark canvas has no shadow to show, but it always has an
+ edge. */
+ --nv-dark-lowlight: rgb(0 0 0 / 0.5);
+ --nv-dark-outline: rgb(0 0 0 / 0.72);
+
+ --nv-dark-sheen-top: rgb(255 255 255 / 0.09);
+ --nv-dark-sheen-bottom: rgb(0 0 0 / 0.18);
+ --nv-dark-sheen-strong-top: rgb(255 255 255 / 0.3);
+ --nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.2);
+ --nv-dark-glow: oklch(from var(--nv-dark-accent) l c h / 0.5);
+
+ --nv-dark-knob: #f4f3ef;
+ --nv-dark-knob-shade: rgb(40 32 20 / 0.3);
/* The higher a surface sits, the more light reaches it. */
- --nv-dark-elevation-tint-1: rgb(255 255 255 / 0.025);
- --nv-dark-elevation-tint-2: rgb(255 255 255 / 0.042);
- --nv-dark-elevation-tint-3: rgb(255 255 255 / 0.06);
- --nv-dark-elevation-tint-4: rgb(255 255 255 / 0.078);
- --nv-dark-elevation-tint-5: rgb(255 255 255 / 0.095);
+ --nv-dark-elevation-tint-1: rgb(255 255 255 / 0.03);
+ --nv-dark-elevation-tint-2: rgb(255 255 255 / 0.05);
+ --nv-dark-elevation-tint-3: rgb(255 255 255 / 0.07);
+ --nv-dark-elevation-tint-4: rgb(255 255 255 / 0.09);
+ --nv-dark-elevation-tint-5: rgb(255 255 255 / 0.11);
- --nv-dark-material-frosted: color-mix(in oklab, rgb(23 28 36 / 0.72), var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-material-glass: color-mix(in oklab, rgb(30 36 46 / 0.55), var(--nv-brand) var(--nv-brand-tint));
- --nv-dark-material-border: rgb(255 255 255 / 0.1);
+ --nv-dark-material-frosted: color-mix(in oklab, rgb(34 34 40 / 0.5), var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint));
+ --nv-dark-material-border: rgb(255 255 255 / 0.14);
+
+ /* --- Window controls --------------------------------------------------------- */
+ --nv-dark-window-close: var(--nv-palette-window-close);
+ --nv-dark-window-minimize: var(--nv-palette-window-minimize);
+ --nv-dark-window-zoom: var(--nv-palette-window-zoom);
+ --nv-dark-window-inactive: var(--nv-palette-neutral-700);
+ --nv-dark-window-control-ring: rgb(0 0 0 / 0.35);
+ --nv-dark-window-glyph: rgb(0 0 0 / 0.6);
/* --- Interaction ---------------------------------------------------------- */
- --nv-dark-focus-ring: oklch(from var(--nv-dark-accent) l c h / 0.5);
- --nv-dark-focus-ring-contrast: rgb(6 8 12 / 0.9);
- --nv-dark-selection: oklch(from var(--nv-dark-accent) l c h / 0.3);
+ --nv-dark-focus-ring: oklch(from var(--nv-dark-accent-text) l c h / 0.55);
+ --nv-dark-focus-ring-contrast: rgb(8 8 10 / 0.9);
+ --nv-dark-selection: oklch(from var(--nv-dark-accent) l c h / 0.4);
}
diff --git a/docs/Website/niveus/niveus/themes/light.css b/docs/Website/niveus/niveus/themes/light.css
index ddee15f..357cf13 100644
--- a/docs/Website/niveus/niveus/themes/light.css
+++ b/docs/Website/niveus/niveus/themes/light.css
@@ -10,9 +10,12 @@
how much of the brand bleeds into the greys. See tokens/color.css for the
inputs.
- Light Niveus reads as paper on a cool, slightly recessed canvas: surfaces are
- near-white, depth comes from soft shadow rather than from contrast, and a
- crisp top highlight gives raised elements their edge.
+ Light Niveus reads as soft paper and enamel on a warm grey desk: surfaces
+ that hold content stay near-white, so documents never look yellowed; the
+ canvas around them is a few steps warmer and darker, so a window sits *on*
+ something. Raised controls are bevelled — a bright top edge, a soft shade
+ along the bottom, light falling across the face — and shadows are warm,
+ the color of shade on paper rather than of a grey filter.
========================================================================== */
:root {
@@ -32,14 +35,14 @@
Paper stays paper: the two surfaces that hold content are left untinted so
text keeps its contrast. Everything the surfaces sit *in* picks up a trace
of the brand, which is what stops an accent from looking stuck on. */
- --nv-light-background: color-mix(in oklab, #e9ecf3, var(--nv-brand) var(--nv-brand-tint));
- --nv-light-background-subtle: color-mix(in oklab, #eff1f6, var(--nv-brand) var(--nv-brand-tint));
- --nv-light-surface: var(--nv-palette-neutral-0);
- --nv-light-surface-elevated: var(--nv-palette-neutral-0);
- --nv-light-surface-inset: color-mix(in oklab, #e4e8f0, var(--nv-brand) var(--nv-brand-tint));
- --nv-light-surface-hover: color-mix(in oklab, #f4f6fa, var(--nv-brand) var(--nv-brand-tint));
- --nv-light-surface-active: color-mix(in oklab, #e8ebf2, var(--nv-brand) var(--nv-brand-tint));
- --nv-light-scrim: rgb(16 21 30 / 0.32);
+ --nv-light-background: color-mix(in oklab, #e8e5de, var(--nv-brand) var(--nv-brand-tint));
+ --nv-light-background-subtle: color-mix(in oklab, #eeece6, var(--nv-brand) var(--nv-brand-tint));
+ --nv-light-surface: #fbfbfa;
+ --nv-light-surface-elevated: #f8f8f6;
+ --nv-light-surface-inset: color-mix(in oklab, #eeede8, var(--nv-brand) var(--nv-brand-tint));
+ --nv-light-surface-hover: color-mix(in oklab, #f1f0ec, var(--nv-brand) var(--nv-brand-tint));
+ --nv-light-surface-active: color-mix(in oklab, #e6e5e0, var(--nv-brand) var(--nv-brand-tint));
+ --nv-light-scrim: rgb(28 24 18 / 0.34);
/* --- Text ---------------------------------------------------------------
Primary text stays neutral — it carries the reading contrast. The quieter
@@ -52,9 +55,9 @@
--nv-light-text-inverted: var(--nv-palette-neutral-0);
/* --- Borders ------------------------------------------------------------ */
- --nv-light-border-subtle: rgb(16 21 30 / 0.06);
- --nv-light-border: rgb(16 21 30 / 0.11);
- --nv-light-border-strong: rgb(16 21 30 / 0.2);
+ --nv-light-border-subtle: rgb(28 24 18 / 0.07);
+ --nv-light-border: rgb(28 24 18 / 0.13);
+ --nv-light-border-strong: rgb(28 24 18 / 0.22);
/* --- Accent --------------------------------------------------------------
Every filled color is pushed away from the middle of the lightness range
@@ -132,13 +135,27 @@
/* --- Depth and material -------------------------------------------------
Three shadow colors build every elevation level: an ambient contact
shadow, a soft penumbra, and a deeper umbra for high elevations. */
- --nv-light-shadow-ambient: rgb(16 21 30 / 0.05);
- --nv-light-shadow-penumbra: rgb(16 21 30 / 0.08);
- --nv-light-shadow-umbra: rgb(16 21 30 / 0.06);
- --nv-light-shadow-inset: rgb(16 21 30 / 0.12);
- --nv-light-highlight: rgb(255 255 255 / 0.85);
+ --nv-light-shadow-ambient: rgb(40 32 20 / 0.08);
+ --nv-light-shadow-penumbra: rgb(40 32 20 / 0.1);
+ --nv-light-shadow-umbra: rgb(40 32 20 / 0.16);
+ --nv-light-shadow-inset: rgb(40 32 20 / 0.14);
+ --nv-light-highlight: rgb(255 255 255 / 0.9);
--nv-light-highlight-strong: rgb(255 255 255 / 1);
+ /* Paper already has a border doing the contact work, so the outline only
+ softens it; the bottom shade is faint for the same reason. */
+ --nv-light-lowlight: rgb(40 32 20 / 0.09);
+ --nv-light-outline: rgb(40 32 20 / 0.05);
+
+ --nv-light-sheen-top: rgb(255 255 255 / 0.9);
+ --nv-light-sheen-bottom: rgb(40 32 20 / 0.07);
+ --nv-light-sheen-strong-top: rgb(255 255 255 / 0.32);
+ --nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16);
+ --nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3);
+
+ --nv-light-knob: var(--nv-palette-neutral-0);
+ --nv-light-knob-shade: rgb(40 32 20 / 0.13);
+
/* Light surfaces are already lighter than their canvas; the shadow alone
carries elevation, so there is nothing to add. */
--nv-light-elevation-tint-1: transparent;
@@ -147,9 +164,17 @@
--nv-light-elevation-tint-4: transparent;
--nv-light-elevation-tint-5: transparent;
- --nv-light-material-frosted: rgb(255 255 255 / 0.72);
- --nv-light-material-glass: rgb(255 255 255 / 0.5);
- --nv-light-material-border: rgb(255 255 255 / 0.55);
+ --nv-light-material-frosted: rgb(252 252 250 / 0.56);
+ --nv-light-material-glass: rgb(252 252 250 / 0.4);
+ --nv-light-material-border: rgb(255 255 255 / 0.72);
+
+ /* --- Window controls --------------------------------------------------------- */
+ --nv-light-window-close: var(--nv-palette-window-close);
+ --nv-light-window-minimize: var(--nv-palette-window-minimize);
+ --nv-light-window-zoom: var(--nv-palette-window-zoom);
+ --nv-light-window-inactive: var(--nv-palette-neutral-300);
+ --nv-light-window-control-ring: rgb(0 0 0 / 0.16);
+ --nv-light-window-glyph: rgb(0 0 0 / 0.55);
/* --- Interaction ----------------------------------------------------------- */
--nv-light-focus-ring: oklch(from var(--nv-light-accent) l c h / 0.4);
diff --git a/docs/Website/niveus/niveus/tokens/color.css b/docs/Website/niveus/niveus/tokens/color.css
index 9c3d7b9..9d35ca8 100644
--- a/docs/Website/niveus/niveus/tokens/color.css
+++ b/docs/Website/niveus/niveus/tokens/color.css
@@ -53,25 +53,37 @@
}
:root {
- /* --- Neutral ---------------------------------------------------------- */
+ /* --- Neutral ----------------------------------------------------------
+ Warm graphite to warm paper. A grey that leans very slightly towards
+ yellow reads as a material (metal, enamel, paper); a grey that leans blue
+ reads as a screen. The lean is small enough that the brand tint still
+ decides the family. */
--nv-palette-neutral-0: #ffffff;
- --nv-palette-neutral-25: #fbfcfe;
- --nv-palette-neutral-50: #f7f8fb;
- --nv-palette-neutral-100: #f1f3f8;
- --nv-palette-neutral-150: #e8ebf2;
- --nv-palette-neutral-200: #dfe3ec;
- --nv-palette-neutral-300: #ccd2de;
- --nv-palette-neutral-400: #a7b0c0;
- --nv-palette-neutral-500: #7c8797;
- --nv-palette-neutral-600: #5b6574;
- --nv-palette-neutral-700: #434b59;
- --nv-palette-neutral-800: #2d3340;
- --nv-palette-neutral-850: #232936;
- --nv-palette-neutral-900: #1a1f29;
- --nv-palette-neutral-925: #151a22;
- --nv-palette-neutral-950: #10141b;
- --nv-palette-neutral-975: #0b0e14;
- --nv-palette-neutral-1000: #06080c;
+ --nv-palette-neutral-25: #fcfcfb;
+ --nv-palette-neutral-50: #f8f8f6;
+ --nv-palette-neutral-100: #f2f1ee;
+ --nv-palette-neutral-150: #eae9e5;
+ --nv-palette-neutral-200: #e1e0db;
+ --nv-palette-neutral-300: #cfcec8;
+ --nv-palette-neutral-400: #aeada7;
+ --nv-palette-neutral-500: #87867f;
+ --nv-palette-neutral-600: #63625d;
+ --nv-palette-neutral-700: #4a4945;
+ --nv-palette-neutral-800: #343337;
+ --nv-palette-neutral-850: #2a2a2d;
+ --nv-palette-neutral-900: #1d1d20;
+ --nv-palette-neutral-925: #18181a;
+ --nv-palette-neutral-950: #131315;
+ --nv-palette-neutral-975: #0e0e10;
+ --nv-palette-neutral-1000: #08080a;
+
+ /* --- Window controls ----------------------------------------------------
+ The three lights at the start of a titlebar. They are the one place the
+ system uses fixed colors instead of the brand: their meaning is learned
+ from position and color together, and re-coloring them would unteach it. */
+ --nv-palette-window-close: #ff5f57;
+ --nv-palette-window-minimize: #febc2e;
+ --nv-palette-window-zoom: #28c840;
/* --- Accent (indigo) --------------------------------------------------- */
--nv-palette-accent-50: #eef2ff;
@@ -198,6 +210,33 @@
--nv-color-highlight: light-dark(var(--nv-light-highlight), var(--nv-dark-highlight));
--nv-color-highlight-strong: light-dark(var(--nv-light-highlight-strong), var(--nv-dark-highlight-strong));
+ /* The bevel's other half: the shade along a raised surface's bottom edge,
+ and the contact line around anything that sits on another surface. Light
+ along the top and shade along the bottom is what makes a control read as
+ something pressable instead of a painted rectangle. */
+ --nv-color-lowlight: light-dark(var(--nv-light-lowlight), var(--nv-dark-lowlight));
+ --nv-color-outline: light-dark(var(--nv-light-outline), var(--nv-dark-outline));
+
+ /* Sheen: the light falling across a raised face, top to bottom. The plain
+ pair is for neutral controls and surfaces; the strong pair is for filled
+ color (primary buttons, checked marks), where it has to read through a
+ saturated fill. Both are laid over a surface's own color, never instead
+ of it. */
+ --nv-color-sheen-top: light-dark(var(--nv-light-sheen-top), var(--nv-dark-sheen-top));
+ --nv-color-sheen-bottom: light-dark(var(--nv-light-sheen-bottom), var(--nv-dark-sheen-bottom));
+ --nv-color-sheen-strong-top: light-dark(var(--nv-light-sheen-strong-top), var(--nv-dark-sheen-strong-top));
+ --nv-color-sheen-strong-bottom: light-dark(var(--nv-light-sheen-strong-bottom), var(--nv-dark-sheen-strong-bottom));
+
+ /* Glow: light a surface gives off, as opposed to the shade it casts. It is
+ the accent seen through air — strong enough in dark to read as a lit
+ edge, faint in light, where paper does not emit. */
+ --nv-color-glow: light-dark(var(--nv-light-glow), var(--nv-dark-glow));
+
+ /* A knob: the thumb of a switch or a slider. It stays light in both
+ appearances, the way a physical knob does not change color with the room. */
+ --nv-color-knob: light-dark(var(--nv-light-knob), var(--nv-dark-knob));
+ --nv-color-knob-shade: light-dark(var(--nv-light-knob-shade), var(--nv-dark-knob-shade));
+
/* Elevation tint: on a dark canvas a shadow has nothing to darken, so a
raised surface also has to gain light. Transparent in light, where the
shadow does all the work. */
@@ -211,6 +250,14 @@
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
+ /* --- Window controls ------------------------------------------------------- */
+ --nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));
+ --nv-color-window-minimize: light-dark(var(--nv-light-window-minimize), var(--nv-dark-window-minimize));
+ --nv-color-window-zoom: light-dark(var(--nv-light-window-zoom), var(--nv-dark-window-zoom));
+ --nv-color-window-inactive: light-dark(var(--nv-light-window-inactive), var(--nv-dark-window-inactive));
+ --nv-color-window-control-ring: light-dark(var(--nv-light-window-control-ring), var(--nv-dark-window-control-ring));
+ --nv-color-window-glyph: light-dark(var(--nv-light-window-glyph), var(--nv-dark-window-glyph));
+
/* --- Interaction --------------------------------------------------------- */
--nv-color-focus-ring: light-dark(var(--nv-light-focus-ring), var(--nv-dark-focus-ring));
--nv-color-focus-ring-contrast: light-dark(var(--nv-light-focus-ring-contrast), var(--nv-dark-focus-ring-contrast));
diff --git a/docs/Website/niveus/niveus/tokens/elevation.css b/docs/Website/niveus/niveus/tokens/elevation.css
index 66c00d9..d185b73 100644
--- a/docs/Website/niveus/niveus/tokens/elevation.css
+++ b/docs/Website/niveus/niveus/tokens/elevation.css
@@ -22,34 +22,35 @@
--nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient),
- 0 1px 3px -1px var(--nv-color-shadow-penumbra);
+ 0 2px 4px -1px var(--nv-color-shadow-penumbra),
+ 0 6px 14px -8px var(--nv-color-shadow-umbra);
--nv-elevation-2:
0 1px 1px var(--nv-color-shadow-ambient),
- 0 2px 4px -1px var(--nv-color-shadow-penumbra),
- 0 4px 8px -4px var(--nv-color-shadow-umbra);
+ 0 3px 6px -2px var(--nv-color-shadow-penumbra),
+ 0 10px 20px -10px var(--nv-color-shadow-umbra);
--nv-elevation-3:
- 0 1px 1px var(--nv-color-shadow-ambient),
- 0 4px 8px -2px var(--nv-color-shadow-penumbra),
- 0 10px 20px -6px var(--nv-color-shadow-umbra);
+ 0 1px 2px var(--nv-color-shadow-ambient),
+ 0 6px 12px -4px var(--nv-color-shadow-penumbra),
+ 0 18px 36px -12px var(--nv-color-shadow-umbra);
--nv-elevation-4:
0 1px 2px var(--nv-color-shadow-ambient),
- 0 8px 16px -4px var(--nv-color-shadow-penumbra),
- 0 20px 40px -10px var(--nv-color-shadow-umbra);
+ 0 10px 20px -6px var(--nv-color-shadow-penumbra),
+ 0 30px 60px -16px var(--nv-color-shadow-umbra);
--nv-elevation-5:
- 0 1px 2px var(--nv-color-shadow-ambient),
- 0 14px 28px -6px var(--nv-color-shadow-penumbra),
- 0 32px 64px -14px var(--nv-color-shadow-umbra);
+ 0 2px 3px var(--nv-color-shadow-ambient),
+ 0 16px 32px -8px var(--nv-color-shadow-penumbra),
+ 0 44px 88px -20px var(--nv-color-shadow-umbra);
/* --- Inset ---------------------------------------------------------------
Recessed surfaces: fields, wells, tracks, pressed controls. */
--nv-elevation-inset-sm: inset 0 1px 2px var(--nv-color-shadow-inset);
--nv-elevation-inset-md:
inset 0 1px 1px var(--nv-color-shadow-inset),
- inset 0 2px 5px -1px var(--nv-color-shadow-inset);
+ inset 0 3px 6px -2px var(--nv-color-shadow-inset);
--nv-elevation-inset-lg:
inset 0 2px 3px var(--nv-color-shadow-inset),
inset 0 6px 12px -3px var(--nv-color-shadow-inset);
@@ -64,6 +65,29 @@
inset 0 1px 0 var(--nv-color-highlight),
inset 0 0 0 1px var(--nv-color-border-subtle);
+ /* --- Bevel -----------------------------------------------------------------
+ The highlight's other half. A raised face is lit along its top edge and
+ shaded along its bottom one; together they are the bevel, and the bevel is
+ what makes a Niveus control look like something a finger could press.
+ Raised controls and surfaces take the full bevel; the highlight alone is
+ for things that sit flush. */
+ --nv-lowlight-bottom: inset 0 -1px 0 var(--nv-color-lowlight);
+ --nv-bevel:
+ inset 0 1px 0 var(--nv-color-highlight),
+ inset 0 -1px 0 var(--nv-color-lowlight);
+
+ /* --- Outline ---------------------------------------------------------------
+ The contact line: a hairline just outside a raised surface, where it meets
+ whatever it rests on. In dark it is near-black and does most of the
+ separating; in light it only softens the border beside it. */
+ --nv-outline: 0 0 0 1px var(--nv-color-outline);
+
+ /* --- Ledge -----------------------------------------------------------------
+ A recess catches light on its *lower* lip, the edge facing the viewer.
+ Paired with an inset shadow, it turns a field from a dark rectangle into
+ a well cut into the surface. */
+ --nv-ledge: 0 1px 0 var(--nv-color-highlight);
+
/* --- Focus ---------------------------------------------------------------
A contrast ring sits under the accent ring so focus stays visible on both
light and dark surfaces, and on accent-filled controls. */
@@ -71,4 +95,30 @@
--nv-focus-ring:
0 0 0 1px var(--nv-color-focus-ring-contrast),
0 0 0 calc(1px + var(--nv-focus-ring-width)) var(--nv-color-focus-ring);
+
+ /* --- Glow ------------------------------------------------------------------
+ Light a surface emits rather than the shade it casts. Glow is for things
+ that are *on* — a live status, the selected item in a showcase, a lit key —
+ and it always sits beside an elevation, never instead of one: a glowing
+ surface still rests on something.
+
+ The geometry is a scale like elevation. The color is the component's: a
+ tinted surface glows in its own tint, mixed down to --nv-glow-strength. */
+ --nv-glow-strength: 40%;
+ --nv-glow-blur-sm: 12px;
+ --nv-glow-spread-sm: -2px;
+ --nv-glow-blur-md: 28px;
+ --nv-glow-spread-md: -6px;
+ --nv-glow-blur-lg: 64px;
+ --nv-glow-spread-lg: -12px;
+}
+
+/* The accent glows are declared on every branded subtree as well as the root:
+ a var() inside a custom property resolves where the property is declared, so
+ a root-only declaration would glow in the root brand everywhere. */
+:root,
+[data-nv-brand] {
+ --nv-glow-sm: 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) var(--nv-color-glow);
+ --nv-glow-md: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) var(--nv-color-glow);
+ --nv-glow-lg: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-lg) var(--nv-color-glow);
}
diff --git a/docs/Website/niveus/niveus/tokens/materials.css b/docs/Website/niveus/niveus/tokens/materials.css
index 2da6487..c718859 100644
--- a/docs/Website/niveus/niveus/tokens/materials.css
+++ b/docs/Website/niveus/niveus/tokens/materials.css
@@ -8,29 +8,50 @@
solid opaque, stable. The default. Content lives here.
elevated solid with depth and a top highlight. Separated from context.
inset recessed into its parent. Fields, wells, tracks.
- frosted translucent, heavily blurred. Chrome that sits over content:
- navigation bars, toolbars, sticky headers.
- glass more translucent, less blurred. Contextual layers where the
+ frosted translucent, moderately blurred, bevelled. Chrome that sits
+ over content: bars, docks, launchers, notifications, menus.
+ glass more translucent, lightly blurred. Contextual layers where the
content behind should stay legible: overlays, hover cards.
- Translucency has a cost — it tints whatever is behind it and forces the GPU
- to composite. Use frosted and glass for layers that move over content, not
- for content itself.
+ Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
+ and everything that holds content are solid; translucency is reserved for
+ the chrome that floats over them. Translucency has a cost — it tints
+ whatever is behind it and forces the GPU to composite — and used everywhere
+ it stops meaning "this floats".
+
+ Sheen is the light falling across a raised face. It is laid over a
+ surface's own color as background-image, so a surface keeps its role color
+ and gains the gradient; anything that does not draw gradients simply shows
+ the role color.
========================================================================== */
:root {
/* --- Blur ---------------------------------------------------------------- */
- --nv-blur-sm: 8px;
- --nv-blur-md: 16px;
- --nv-blur-lg: 28px;
- --nv-blur-xl: 44px;
+ --nv-blur-sm: 12px;
+ --nv-blur-md: 24px;
+ --nv-blur-lg: 36px;
+ --nv-blur-xl: 56px;
/* Backdrops lose saturation when blurred; a touch of saturate() puts the
- color back so frosted surfaces do not read as gray films. */
+ color back so frosted surfaces do not read as gray films. Moderate on
+ purpose: the blur says "this floats", it is not the look itself. */
--nv-backdrop-saturate: 180%;
- --nv-backdrop-frosted: blur(var(--nv-blur-lg)) saturate(var(--nv-backdrop-saturate));
- --nv-backdrop-glass: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
- --nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(120%);
+ --nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
+ --nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate));
+
+ /* --- Sheen ------------------------------------------------------------------
+ raised neutral controls: buttons, the selected tab, a titlebar
+ filled saturated fills: primary and danger buttons, checked marks
+ surface large surfaces: windows, dialogs, cards. Brightest along the
+ top edge, easing through the middle and settling into a faint
+ shade at the foot, so a panel reads as a curved, lit body
+ rather than as a flat sheet.
+ knob a round thumb: lit from above, shaded towards its rim */
+ --nv-sheen-raised: linear-gradient(var(--nv-color-sheen-top), var(--nv-color-sheen-bottom));
+ --nv-sheen-filled: linear-gradient(var(--nv-color-sheen-strong-top), var(--nv-color-sheen-strong-bottom));
+ --nv-sheen-surface: linear-gradient(var(--nv-color-sheen-top), transparent 7rem, transparent 55%, var(--nv-color-sheen-bottom));
+ --nv-sheen-knob: radial-gradient(circle at 50% 22%, var(--nv-color-highlight-strong), transparent 45%), radial-gradient(circle at 50% 30%, transparent 40%, var(--nv-color-knob-shade));
+ --nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(140%);
/* --- Material recipes -----------------------------------------------------
Each recipe is the full set of properties that defines the material.
@@ -41,19 +62,19 @@
--nv-material-elevated-bg: var(--nv-color-surface-elevated);
--nv-material-elevated-border: var(--nv-color-border-subtle);
- --nv-material-elevated-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
+ --nv-material-elevated-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
--nv-material-inset-bg: var(--nv-color-surface-inset);
--nv-material-inset-border: var(--nv-color-border-subtle);
- --nv-material-inset-shadow: var(--nv-elevation-inset-md);
+ --nv-material-inset-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
--nv-material-frosted-bg: var(--nv-color-material-frosted);
--nv-material-frosted-border: var(--nv-color-material-border);
- --nv-material-frosted-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
+ --nv-material-frosted-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
--nv-material-glass-bg: var(--nv-color-material-glass);
--nv-material-glass-border: var(--nv-color-material-border);
- --nv-material-glass-shadow: var(--nv-highlight-top-strong), var(--nv-elevation-4);
+ --nv-material-glass-shadow: var(--nv-highlight-top-strong), var(--nv-outline), var(--nv-elevation-4);
}
/* Where translucency cannot be honoured — reduced transparency, or a browser
diff --git a/docs/Website/niveus/niveus/tokens/motion.css b/docs/Website/niveus/niveus/tokens/motion.css
index b9075d8..b271e56 100644
--- a/docs/Website/niveus/niveus/tokens/motion.css
+++ b/docs/Website/niveus/niveus/tokens/motion.css
@@ -82,6 +82,15 @@
--nv-travel-sm: 4px;
--nv-travel-md: 8px;
--nv-travel-lg: 16px;
+
+ /* --- Sequence ---------------------------------------------------------------
+ The gap between siblings arriving one after another. Short enough that a
+ group reads as one movement, long enough that the eye sees an order. */
+ --nv-stagger: 60ms;
+
+ /* A loading shimmer is a slow, constant sweep — slow enough to read as
+ "waiting", never fast enough to read as an alert. */
+ --nv-duration-shimmer: 1600ms;
}
/* Reduced motion is a system-wide contract, not a per-component opt-in: cut
@@ -103,5 +112,6 @@
--nv-travel-sm: 0px;
--nv-travel-md: 0px;
--nv-travel-lg: 0px;
+ --nv-stagger: 0ms;
}
}
diff --git a/docs/Website/niveus/niveus/tokens/radius.css b/docs/Website/niveus/niveus/tokens/radius.css
index 13a2143..927416b 100644
--- a/docs/Website/niveus/niveus/tokens/radius.css
+++ b/docs/Website/niveus/niveus/tokens/radius.css
@@ -2,18 +2,19 @@
Niveus — Radius
--------------------------------------------------------------------------
Corner softness carries as much of the Niveus character as shadow does.
- Radii grow with the surface: controls stay modest, containers get rounder,
- and only pills and avatars go fully round.
+ Radii grow with the surface: controls stay modest and machined, containers
+ get noticeably rounder — a window or a popover is a soft object, a button
+ is a key — and only pills, knobs and avatars go fully round.
========================================================================== */
:root {
--nv-radius-none: 0;
--nv-radius-xs: 0.25rem; /* 4px — tags, swatches, nested chips */
--nv-radius-sm: 0.5rem; /* 8px — small controls, menu items */
- --nv-radius-md: 0.75rem; /* 12px — buttons, inputs, default control radius */
- --nv-radius-lg: 1rem; /* 16px — cards, panels */
- --nv-radius-xl: 1.375rem; /* 22px — dialogs, sheets, large containers */
- --nv-radius-2xl: 1.75rem; /* 28px — hero surfaces */
+ --nv-radius-md: 0.625rem; /* 10px — buttons, inputs, default control radius */
+ --nv-radius-lg: 0.875rem; /* 14px — cards, panels, menus */
+ --nv-radius-xl: 1.25rem; /* 20px — windows, dialogs, sheets, popovers */
+ --nv-radius-2xl: 1.5rem; /* 24px — docks, hero surfaces */
--nv-radius-full: 999rem; /* — pills, avatars, switches */
/* Nested radius: an inner surface inset by N should be N smaller than its
diff --git a/docs/Website/niveus/niveus/tokens/typography.css b/docs/Website/niveus/niveus/tokens/typography.css
index 476c09b..caabfef 100644
--- a/docs/Website/niveus/niveus/tokens/typography.css
+++ b/docs/Website/niveus/niveus/tokens/typography.css
@@ -7,11 +7,13 @@
:root {
/* --- Families ----------------------------------------------------------- */
+ /* Both ship with Niveus (fonts.css); the rest of each stack only covers the
+ moment before they load, or a context that cannot load them. */
--nv-font-family-sans:
- "Inter var", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
- "Helvetica Neue", Arial, sans-serif;
+ "Instrument Sans", "Inter var", "Inter", -apple-system, BlinkMacSystemFont,
+ "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--nv-font-family-mono:
- "SF Mono", "JetBrains Mono", "IBM Plex Mono", ui-monospace, "Menlo",
+ "JetBrains Mono", "SF Mono", "IBM Plex Mono", ui-monospace, "Menlo",
"Consolas", monospace;
/* --- Size scale ---------------------------------------------------------- */
@@ -46,6 +48,8 @@
--nv-letter-spacing-normal: 0em;
--nv-letter-spacing-wide: 0.02em;
--nv-letter-spacing-wider: 0.06em;
+ /* Engraved labels: small mono capitals, spaced like markings on a panel. */
+ --nv-letter-spacing-engraved: 0.1em;
/* --- Composite roles ------------------------------------------------------
Applied as classes in base.css (.nv-display, .nv-heading, …). Kept as
@@ -65,6 +69,11 @@
var(--nv-line-height-normal) var(--nv-font-family-sans);
--nv-font-code: var(--nv-font-weight-regular) var(--nv-font-size-sm) /
var(--nv-line-height-normal) var(--nv-font-family-mono);
+ /* The engraved label: section markers, the clock, anything that names a
+ region rather than being read. Paired with uppercase and the engraved
+ tracking (see .nv-overline in base.css). */
+ --nv-font-engraved: var(--nv-font-weight-medium) var(--nv-font-size-2xs) /
+ var(--nv-line-height-snug) var(--nv-font-family-mono);
/* --- Measure --------------------------------------------------------------- */
--nv-measure-narrow: 44ch;
diff --git a/docs/Website/niveus/tools/niveus_css.py b/docs/Website/niveus/tools/niveus_css.py
new file mode 100644
index 0000000..d3fee97
--- /dev/null
+++ b/docs/Website/niveus/tools/niveus_css.py
@@ -0,0 +1,944 @@
+"""Evaluate the subset of CSS that Niveus's token layer is written in.
+
+Niveus derives its entire color system from one input with relative color
+syntax, `color-mix()` and `calc()`. None of that survives a copy-paste into
+another platform's resources (Android XML, a Qt header, GTK CSS), and
+re-stating the results by hand would fork the system: the CSS would change and
+the port would quietly keep the old values.
+
+So this module implements the language instead, once, here — every port's
+generator imports it rather than carrying its own copy.
+
+It is deliberately narrow — it covers exactly what `niveus/tokens/*.css` and
+`niveus/themes/*.css` use:
+
+ #rrggbb / #rgb / #rrggbbaa rgb(r g b / a) transparent
+ oklch(L C H) oklch(from L C H [/ A])
+ color-mix(in oklab, , )
+ light-dark(, ) var(--x[, fallback])
+ calc(...) clamp(min,v,max) min(...) max(...)
+
+Inside `oklch(from X ...)` the identifiers `l`, `c`, `h` and `alpha` are bound
+to X's own OKLCH channels, which is what makes one brand value re-colour
+everything.
+
+Color math follows CSS Color 4: sRGB <-> linear sRGB <-> OKLab <-> OKLCH, and
+out-of-gamut results are gamut-mapped by chroma reduction against deltaEOK,
+the same approach browsers take. `color-mix()` mixes premultiplied, in OKLab.
+"""
+
+from __future__ import annotations
+
+import math
+import re
+from dataclasses import dataclass
+
+# ---------------------------------------------------------------------------
+# Color space conversions (CSS Color 4)
+# ---------------------------------------------------------------------------
+
+def _srgb_to_linear(v: float) -> float:
+ return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
+
+
+def _linear_to_srgb(v: float) -> float:
+ return v * 12.92 if v <= 0.0031308 else 1.055 * (v ** (1 / 2.4)) - 0.055
+
+
+# linear sRGB -> LMS -> OKLab, per Björn Ottosson.
+_LSRGB_TO_LMS = (
+ (0.4122214708, 0.5363325363, 0.0514459929),
+ (0.2119034982, 0.6806995451, 0.1073969566),
+ (0.0883024619, 0.2817188376, 0.6299787005),
+)
+_LMS_TO_OKLAB = (
+ (0.2104542553, 0.7936177850, -0.0040720468),
+ (1.9779984951, -2.4285922050, 0.4505937099),
+ (0.0259040371, 0.7827717662, -0.8086757660),
+)
+_OKLAB_TO_LMS = (
+ (1.0, 0.3963377774, 0.2158037573),
+ (1.0, -0.1055613458, -0.0638541728),
+ (1.0, -0.0894841775, -1.2914855480),
+)
+_LMS_TO_LSRGB = (
+ (4.0767416621, -3.3077115913, 0.2309699292),
+ (-1.2684380046, 2.6097574011, -0.3413193965),
+ (-0.0041960863, -0.7034186147, 1.7076147010),
+)
+
+
+def _mul(m, v):
+ return tuple(sum(r[i] * v[i] for i in range(3)) for r in m)
+
+
+def srgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]:
+ lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b))
+ lms = _mul(_LSRGB_TO_LMS, lin)
+ lms_ = tuple(math.copysign(abs(x) ** (1 / 3), x) for x in lms)
+ return _mul(_LMS_TO_OKLAB, lms_)
+
+
+def oklab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]:
+ lms_ = _mul(_OKLAB_TO_LMS, (L, a, b))
+ lms = tuple(x ** 3 for x in lms_)
+ lin = _mul(_LMS_TO_LSRGB, lms)
+ return tuple(_linear_to_srgb(x) for x in lin)
+
+
+def oklab_to_oklch(L: float, a: float, b: float) -> tuple[float, float, float]:
+ c = math.hypot(a, b)
+ # Below this chroma the hue is meaningless; CSS treats it as `none`, and
+ # carrying a garbage angle through a derivation produces garbage hues.
+ h = 0.0 if c < 1e-6 else math.degrees(math.atan2(b, a)) % 360.0
+ return (L, c, h)
+
+
+def oklch_to_oklab(L: float, c: float, h: float) -> tuple[float, float, float]:
+ rad = math.radians(h)
+ return (L, c * math.cos(rad), c * math.sin(rad))
+
+
+# ---------------------------------------------------------------------------
+# CIELAB, for the Android system palette
+# ---------------------------------------------------------------------------
+#
+# Niveus works in OKLab, but Android's tonal palette is defined in terms of
+# *tone*, which is CIE L* — the same scale Material You's HCT uses for its T
+# channel. Generating those ramps in OKLab lightness would put every tone at a
+# slightly wrong luminance, so the palette gets its own, correct, colour space
+# rather than an approximation.
+
+# sRGB (D65) -> XYZ
+_LRGB_TO_XYZ = (
+ (0.4123907993, 0.3575843394, 0.1804807884),
+ (0.2126390059, 0.7151686788, 0.0721923154),
+ (0.0193308187, 0.1191947798, 0.9505321522),
+)
+_XYZ_TO_LRGB = (
+ (3.2409699419, -1.5373831776, -0.4986107603),
+ (-0.9692436363, 1.8759675015, 0.0415550574),
+ (0.0556300797, -0.2039769589, 1.0569715142),
+)
+# D65 white point
+_WHITE = (0.9504559271, 1.0, 1.0890577508)
+
+_EPS = 216 / 24389
+_KAPPA = 24389 / 27
+
+
+def _f(t: float) -> float:
+ return t ** (1 / 3) if t > _EPS else (_KAPPA * t + 16) / 116
+
+
+def _f_inv(t: float) -> float:
+ t3 = t ** 3
+ return t3 if t3 > _EPS else (116 * t - 16) / _KAPPA
+
+
+def srgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]:
+ """sRGB to CIE L*a*b*. L* is 0-100 and is what Android calls tone."""
+ lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b))
+ x, y, z = _mul(_LRGB_TO_XYZ, lin)
+ fx, fy, fz = _f(x / _WHITE[0]), _f(y / _WHITE[1]), _f(z / _WHITE[2])
+ return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
+
+
+def lab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]:
+ fy = (L + 16) / 116
+ fx = fy + a / 500
+ fz = fy - b / 200
+ xyz = (_f_inv(fx) * _WHITE[0], _f_inv(fy) * _WHITE[1], _f_inv(fz) * _WHITE[2])
+ return tuple(_linear_to_srgb(v) for v in _mul(_XYZ_TO_LRGB, xyz))
+
+
+def lch_to_srgb_clamped(L: float, C: float, H: float) -> tuple[float, float, float]:
+ """A tone at a given hue and chroma, reduced until it fits sRGB.
+
+ Tone is fixed — the whole point of a tonal palette is that tone 40 is the
+ same lightness in every ramp — so chroma is what gives way, never L*.
+ """
+ rad = math.radians(H)
+ lo, hi = 0.0, max(C, 0.0)
+ best = lab_to_srgb(L, 0.0, 0.0)
+ for _ in range(28):
+ mid = (lo + hi) / 2
+ candidate = lab_to_srgb(L, mid * math.cos(rad), mid * math.sin(rad))
+ if all(-1e-4 <= v <= 1 + 1e-4 for v in candidate):
+ best = candidate
+ lo = mid
+ else:
+ hi = mid
+ return tuple(min(1.0, max(0.0, v)) for v in best)
+
+
+# ---------------------------------------------------------------------------
+# Values
+# ---------------------------------------------------------------------------
+
+@dataclass(frozen=True)
+class Num:
+ value: float
+ unit: str = ""
+
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
+ return f"Num({self.value}{self.unit})"
+
+
+@dataclass(frozen=True)
+class Color:
+ """A color held in OKLab plus alpha. `none` means fully transparent."""
+
+ L: float
+ a: float
+ b: float
+ alpha: float = 1.0
+
+ @staticmethod
+ def from_srgb(r: float, g: float, bl: float, alpha: float = 1.0) -> "Color":
+ L, a, b = srgb_to_oklab(r, g, bl)
+ return Color(L, a, b, alpha)
+
+ @property
+ def oklch(self) -> tuple[float, float, float]:
+ return oklab_to_oklch(self.L, self.a, self.b)
+
+ def to_srgb(self) -> tuple[float, float, float]:
+ return oklab_to_srgb(self.L, self.a, self.b)
+
+ # -- gamut mapping ----------------------------------------------------
+ def _in_gamut(self, eps: float = 1e-4) -> bool:
+ return all(-eps <= x <= 1 + eps for x in self.to_srgb())
+
+ def gamut_mapped(self) -> tuple[float, float, float]:
+ """sRGB triple, chroma-reduced into gamut (CSS Color 4 §13.2)."""
+ if self._in_gamut():
+ return tuple(min(1.0, max(0.0, x)) for x in self.to_srgb())
+
+ L, C, H = self.oklch
+ if L >= 1.0:
+ return (1.0, 1.0, 1.0)
+ if L <= 0.0:
+ return (0.0, 0.0, 0.0)
+
+ lo, hi = 0.0, C
+ clipped = None
+ for _ in range(32):
+ mid = (lo + hi) / 2
+ candidate = Color(*oklch_to_oklab(L, mid, H), self.alpha)
+ raw = candidate.to_srgb()
+ if candidate._in_gamut():
+ lo = mid
+ clipped = tuple(min(1.0, max(0.0, x)) for x in raw)
+ continue
+ clip = tuple(min(1.0, max(0.0, x)) for x in raw)
+ # deltaEOK between the candidate and its clipped form: once the
+ # clip is imperceptible we accept it rather than desaturating more.
+ cl = Color.from_srgb(*clip)
+ if math.dist((candidate.L, candidate.a, candidate.b), (cl.L, cl.a, cl.b)) < 0.02:
+ return clip
+ hi = mid
+ if clipped is not None:
+ return clipped
+ return tuple(min(1.0, max(0.0, x)) for x in self.to_srgb())
+
+ # -- output -----------------------------------------------------------
+ def hex8(self) -> str:
+ """#AARRGGBB — Android's order."""
+ r, g, b = self.gamut_mapped()
+ a = min(1.0, max(0.0, self.alpha))
+ return "#{:02X}{:02X}{:02X}{:02X}".format(
+ round(a * 255), round(r * 255), round(g * 255), round(b * 255)
+ )
+
+ def hex_rgb(self) -> str:
+ r, g, b = self.gamut_mapped()
+ return "#{:02X}{:02X}{:02X}".format(round(r * 255), round(g * 255), round(b * 255))
+
+ def argb_int(self) -> int:
+ r, g, b = self.gamut_mapped()
+ a = min(1.0, max(0.0, self.alpha))
+ return (
+ (round(a * 255) << 24)
+ | (round(r * 255) << 16)
+ | (round(g * 255) << 8)
+ | round(b * 255)
+ )
+
+ def relative_luminance(self) -> float:
+ r, g, b = self.gamut_mapped()
+ lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b))
+ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
+
+
+TRANSPARENT = Color(0.0, 0.0, 0.0, 0.0)
+
+
+def contrast_ratio(fg: Color, bg: Color) -> float:
+ """WCAG 2.1 contrast, compositing `fg` over `bg` if it is translucent."""
+ if fg.alpha < 1.0:
+ fr, fg_, fb = fg.gamut_mapped()
+ br, bg_, bb = bg.gamut_mapped()
+ a = fg.alpha
+ fg = Color.from_srgb(
+ fr * a + br * (1 - a), fg_ * a + bg_ * (1 - a), fb * a + bb * (1 - a)
+ )
+ l1, l2 = fg.relative_luminance(), bg.relative_luminance()
+ if l1 < l2:
+ l1, l2 = l2, l1
+ return (l1 + 0.05) / (l2 + 0.05)
+
+
+# ---------------------------------------------------------------------------
+# Tokenizer / parser
+# ---------------------------------------------------------------------------
+
+_TOKEN = re.compile(
+ r"""
+ (?P\s+)
+ | (?P\#[0-9a-fA-F]{3,8})
+ | (?P[a-zA-Z_][-a-zA-Z0-9_]*(?=\())
+ # A leading `-` starts an identifier only when a digit does not follow,
+ # or `-0.022em` would lex as the identifier `-0`.
+ | (?P-{1,2}(?![0-9])[-a-zA-Z0-9_]+|[a-zA-Z_][-a-zA-Z0-9_]*)
+ | (?P[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?(?:%|[a-zA-Z]+)?)
+ | (?P[(),/*+\-])
+ """,
+ re.VERBOSE,
+)
+
+
+def _lex(s: str) -> list[tuple[str, str]]:
+ out: list[tuple[str, str]] = []
+ i = 0
+ while i < len(s):
+ m = _TOKEN.match(s, i)
+ if not m:
+ raise ValueError(f"cannot lex at {s[i:i+30]!r} in {s!r}")
+ i = m.end()
+ kind = m.lastgroup
+ if kind == "ws":
+ continue
+ out.append((kind, m.group()))
+ return out
+
+
+class _Parser:
+ """Recursive descent over the component-value grammar Niveus uses."""
+
+ def __init__(self, tokens: list[tuple[str, str]], ev: "Evaluator"):
+ self.t = tokens
+ self.i = 0
+ self.ev = ev
+
+ # -- plumbing ---------------------------------------------------------
+ def peek(self) -> tuple[str, str] | None:
+ return self.t[self.i] if self.i < len(self.t) else None
+
+ def next(self) -> tuple[str, str]:
+ tok = self.t[self.i]
+ self.i += 1
+ return tok
+
+ def expect(self, text: str) -> None:
+ tok = self.next()
+ if tok[1] != text:
+ raise ValueError(f"expected {text!r}, got {tok[1]!r}")
+
+ def at(self, text: str) -> bool:
+ tok = self.peek()
+ return tok is not None and tok[1] == text
+
+ def args(self) -> list[list[tuple[str, str]]]:
+ """Consume `( a, b, c )` and return each argument's raw tokens."""
+ self.expect("(")
+ groups: list[list[tuple[str, str]]] = [[]]
+ depth = 0
+ while True:
+ tok = self.peek()
+ if tok is None:
+ raise ValueError("unterminated (")
+ if tok[1] == "(":
+ depth += 1
+ elif tok[1] == ")":
+ if depth == 0:
+ self.next()
+ break
+ depth -= 1
+ elif tok[1] == "," and depth == 0:
+ self.next()
+ groups.append([])
+ continue
+ groups.append(groups.pop() + [self.next()])
+ return [g for g in groups if g]
+
+ # -- arithmetic -------------------------------------------------------
+ def sum_(self) -> Num:
+ left = self.product()
+ while True:
+ tok = self.peek()
+ if tok is None:
+ break
+ if tok[1] in ("+", "-"):
+ op = self.next()[1]
+ left, right = _same_unit(left, self.product())
+ unit = left.unit or right.unit
+ left = Num(
+ left.value + right.value if op == "+" else left.value - right.value, unit
+ )
+ continue
+ # `calc(l -0.045)` lexes the operand as one signed number, so the
+ # sign has to be read back as the operator it was written as.
+ if tok[0] == "num" and tok[1][0] in "+-":
+ left, right = _same_unit(left, self.product())
+ left = Num(left.value + right.value, left.unit or right.unit)
+ continue
+ break
+ return left
+
+ def product(self) -> Num:
+ left = self.term()
+ while self.peek() and self.peek()[1] in ("*", "/"):
+ op = self.next()[1]
+ right = self.term()
+ unit = left.unit or right.unit
+ if op == "*":
+ left = Num(left.value * right.value, unit)
+ else:
+ left = Num(left.value / right.value, left.unit)
+ return left
+
+ def term(self) -> Num:
+ tok = self.peek()
+ if tok is None:
+ raise ValueError("unexpected end of expression")
+ if tok[1] == "(":
+ self.next()
+ v = self.sum_()
+ self.expect(")")
+ return v
+ if tok[1] == "-":
+ self.next()
+ v = self.term()
+ return Num(-v.value, v.unit)
+ if tok[1] == "+":
+ self.next()
+ return self.term()
+ val = self.value()
+ if isinstance(val, Num):
+ return val
+ raise ValueError(f"expected a number, got {val!r}")
+
+ # -- values -----------------------------------------------------------
+ def value(self):
+ tok = self.next()
+ kind, text = tok
+
+ if kind == "hex":
+ return _parse_hex(text)
+
+ if kind == "num":
+ return _parse_num(text)
+
+ if kind == "func":
+ name = text.lower()
+ if name == "calc":
+ inner = self.args()
+ return _Parser(inner[0], self.ev).sum_()
+ if name in ("clamp", "min", "max"):
+ vals = [_Parser(g, self.ev).sum_() for g in self.args()]
+ if name == "clamp":
+ lo, v, hi = vals
+ unit = v.unit or lo.unit or hi.unit
+ return Num(min(max(v.value, lo.value), hi.value), unit)
+ pick = min if name == "min" else max
+ chosen = pick(vals, key=lambda n: n.value)
+ return chosen
+ if name == "var":
+ groups = self.args()
+ varname = "".join(t[1] for t in groups[0])
+ fallback = groups[1] if len(groups) > 1 else None
+ return self.ev.resolve(varname, fallback)
+ if name == "rgb" or name == "rgba":
+ return _parse_rgb(self.args(), self.ev)
+ if name == "oklch":
+ return _parse_oklch(self.args(), self.ev)
+ if name == "oklab":
+ return _parse_oklab(self.args(), self.ev)
+ if name == "color-mix":
+ return _parse_color_mix(self.args(), self.ev)
+ if name == "light-dark":
+ groups = self.args()
+ pick = groups[0] if self.ev.appearance == "light" else groups[1]
+ return _Parser(pick, self.ev).value()
+ raise ValueError(f"unsupported function {name}()")
+
+ if kind == "ident":
+ low = text.lower()
+ if low == "transparent":
+ return TRANSPARENT
+ if low in ("none", "currentcolor"):
+ return TRANSPARENT
+ if low in _NAMED:
+ return _NAMED[low]
+ if low in self.ev.bindings:
+ return Num(self.ev.bindings[low])
+ raise ValueError(f"unknown identifier {text!r}")
+
+ raise ValueError(f"unexpected token {text!r}")
+
+
+# The initial value of the root font size. Tokens never mix units, but
+# component rules do (`calc(var(--_track-block) - 6px)` with a rem on one
+# side), and adding the numbers without converting is silently wrong.
+ROOT_FONT_SIZE_PX = 16.0
+
+
+def _same_unit(a: Num, b: Num) -> tuple[Num, Num]:
+ """Bring two lengths to a common unit before adding them."""
+ if a.unit == b.unit or not a.unit or not b.unit:
+ return a, b
+ lengths = {"px": 1.0, "rem": ROOT_FONT_SIZE_PX, "em": ROOT_FONT_SIZE_PX}
+ if a.unit in lengths and b.unit in lengths:
+ return (Num(a.value * lengths[a.unit], "px"), Num(b.value * lengths[b.unit], "px"))
+ raise ValueError(f"cannot add {a.unit} to {b.unit}")
+
+
+_NAMED = {
+ "white": Color.from_srgb(1, 1, 1),
+ "black": Color.from_srgb(0, 0, 0),
+}
+
+
+def _parse_hex(text: str) -> Color:
+ h = text[1:]
+ if len(h) in (3, 4):
+ h = "".join(ch * 2 for ch in h)
+ if len(h) == 6:
+ r, g, b = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4))
+ return Color.from_srgb(r, g, b)
+ if len(h) == 8:
+ r, g, b, a = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4, 6))
+ return Color.from_srgb(r, g, b, a)
+ raise ValueError(f"bad hex color {text!r}")
+
+
+def _parse_num(text: str) -> Num:
+ m = re.fullmatch(r"([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)(%|[a-zA-Z]*)", text)
+ if not m:
+ raise ValueError(f"bad number {text!r}")
+ v = float(m.group(1))
+ unit = m.group(2)
+ if unit == "%":
+ return Num(v / 100.0, "%")
+ return Num(v, unit)
+
+
+def _flat(groups: list[list[tuple[str, str]]]) -> list[tuple[str, str]]:
+ out: list[tuple[str, str]] = []
+ for i, g in enumerate(groups):
+ if i:
+ out.append(("punct", ","))
+ out.extend(g)
+ return out
+
+
+def _split_slash(tokens: list[tuple[str, str]]) -> tuple[list, list | None]:
+ depth = 0
+ for i, (_, text) in enumerate(tokens):
+ if text == "(":
+ depth += 1
+ elif text == ")":
+ depth -= 1
+ elif text == "/" and depth == 0:
+ return tokens[:i], tokens[i + 1 :]
+ return tokens, None
+
+
+def _components(tokens: list[tuple[str, str]], ev: "Evaluator", n: int) -> list[Num]:
+ """Read `n` space-separated numeric components from a token run."""
+ p = _Parser(tokens, ev)
+ out: list[Num] = []
+ for _ in range(n):
+ out.append(p.sum_())
+ return out
+
+
+def _parse_rgb(groups, ev) -> Color:
+ tokens = _flat(groups)
+ # Both `rgb(r g b / a)` and the legacy `rgb(r, g, b, a)` appear in the wild.
+ body, alpha_tokens = _split_slash(tokens)
+ body = [t for t in body if t[1] != ","]
+ nums = _components(body, ev, 3)
+ vals = []
+ for n in nums:
+ vals.append(n.value if n.unit == "%" else n.value / 255.0)
+ alpha = 1.0
+ if alpha_tokens:
+ alpha = _Parser(alpha_tokens, ev).sum_().value
+ return Color.from_srgb(*vals, alpha)
+
+
+def _oklch_channels(tokens, ev, base: Color | None):
+ body, alpha_tokens = _split_slash(tokens)
+ bindings = {}
+ if base is not None:
+ L, C, H = base.oklch
+ bindings = {"l": L, "c": C, "h": H, "alpha": base.alpha}
+ with ev.bound(bindings):
+ nums = _components(body, ev, 3)
+ alpha = base.alpha if base is not None else 1.0
+ if alpha_tokens:
+ alpha = _Parser(alpha_tokens, ev).sum_().value
+ return nums, alpha
+
+
+def _parse_oklch(groups, ev) -> Color:
+ tokens = _flat(groups)
+ base = None
+ if tokens and tokens[0][0] == "ident" and tokens[0][1].lower() == "from":
+ rest = tokens[1:]
+ p = _Parser(rest, ev)
+ base = p.value()
+ if not isinstance(base, Color):
+ raise ValueError("oklch(from …) needs a color")
+ tokens = rest[p.i :]
+ nums, alpha = _oklch_channels(tokens, ev, base)
+ L, C, H = (n.value for n in nums)
+ # A percentage lightness is 0–1; `oklch(50% …)` and `oklch(0.5 …)` agree.
+ return Color(*oklch_to_oklab(L, C, H), alpha)
+
+
+def _parse_oklab(groups, ev) -> Color:
+ tokens = _flat(groups)
+ base = None
+ if tokens and tokens[0][0] == "ident" and tokens[0][1].lower() == "from":
+ rest = tokens[1:]
+ p = _Parser(rest, ev)
+ base = p.value()
+ tokens = rest[p.i :]
+ body, alpha_tokens = _split_slash(tokens)
+ bindings = {}
+ if base is not None:
+ bindings = {"l": base.L, "a": base.a, "b": base.b, "alpha": base.alpha}
+ with ev.bound(bindings):
+ nums = _components(body, ev, 3)
+ alpha = base.alpha if base is not None else 1.0
+ if alpha_tokens:
+ alpha = _Parser(alpha_tokens, ev).sum_().value
+ return Color(nums[0].value, nums[1].value, nums[2].value, alpha)
+
+
+def _parse_color_mix(groups, ev) -> Color:
+ # color-mix(in oklab, [pct], [pct])
+ space_tokens = groups[0]
+ space = " ".join(t[1] for t in space_tokens).replace("in ", "").strip()
+ if space not in ("oklab", "oklch", "srgb"):
+ raise ValueError(f"unsupported color-mix space {space!r}")
+
+ parsed: list[tuple[Color, float | None]] = []
+ for g in groups[1:3]:
+ p = _Parser(g, ev)
+ col = p.value()
+ if not isinstance(col, Color):
+ raise ValueError("color-mix needs colors")
+ pct = None
+ if p.peek() is not None:
+ pct = p.sum_().value
+ parsed.append((col, pct))
+
+ (ca, pa), (cb, pb) = parsed
+ if pa is None and pb is None:
+ pa = pb = 0.5
+ elif pa is None:
+ pa = 1.0 - pb
+ elif pb is None:
+ pb = 1.0 - pa
+ total = pa + pb
+ if total == 0:
+ raise ValueError("color-mix percentages sum to zero")
+ pa, pb = pa / total, pb / total
+
+ # CSS mixes premultiplied by alpha, then un-premultiplies.
+ alpha = ca.alpha * pa + cb.alpha * pb
+ if space == "srgb":
+ ra, ga, ba = ca.gamut_mapped()
+ rb, gb, bb = cb.gamut_mapped()
+ comps = [
+ (x * ca.alpha * pa + y * cb.alpha * pb) for x, y in ((ra, rb), (ga, gb), (ba, bb))
+ ]
+ if alpha > 0:
+ comps = [x / alpha for x in comps]
+ return Color.from_srgb(*comps, alpha)
+
+ if space == "oklch":
+ La, Ca, Ha = ca.oklch
+ Lb, Cb, Hb = cb.oklch
+ # shorter hue arc
+ if abs(Hb - Ha) > 180:
+ if Hb > Ha:
+ Ha += 360
+ else:
+ Hb += 360
+ L = La * ca.alpha * pa + Lb * cb.alpha * pb
+ C = Ca * ca.alpha * pa + Cb * cb.alpha * pb
+ H = Ha * ca.alpha * pa + Hb * cb.alpha * pb
+ if alpha > 0:
+ L, C, H = L / alpha, C / alpha, H / alpha
+ return Color(*oklch_to_oklab(L, C, H % 360), alpha)
+
+ L = ca.L * ca.alpha * pa + cb.L * cb.alpha * pb
+ a = ca.a * ca.alpha * pa + cb.a * cb.alpha * pb
+ b = ca.b * ca.alpha * pa + cb.b * cb.alpha * pb
+ if alpha > 0:
+ L, a, b = L / alpha, a / alpha, b / alpha
+ return Color(L, a, b, alpha)
+
+
+# ---------------------------------------------------------------------------
+# Evaluator
+# ---------------------------------------------------------------------------
+
+class _Bound:
+ def __init__(self, ev: "Evaluator", extra: dict):
+ self.ev = ev
+ self.extra = extra
+
+ def __enter__(self):
+ self.saved = self.ev.bindings
+ self.ev.bindings = {**self.ev.bindings, **self.extra}
+
+ def __exit__(self, *exc):
+ self.ev.bindings = self.saved
+ return False
+
+
+class Evaluator:
+ """Resolves `--nv-*` custom properties for one appearance."""
+
+ def __init__(self, declarations: dict[str, str], appearance: str = "light"):
+ self.declarations = declarations
+ self.appearance = appearance
+ self.bindings: dict[str, float] = {}
+ self._cache: dict[tuple[str, str], object] = {}
+ self._stack: list[str] = []
+
+ def bound(self, extra: dict) -> _Bound:
+ return _Bound(self, extra)
+
+ def override(self, name: str, raw: str) -> None:
+ self.declarations[name] = raw
+ self._cache.clear()
+
+ def resolve(self, name: str, fallback=None):
+ key = (name, self.appearance)
+ if key in self._cache:
+ return self._cache[key]
+ if name not in self.declarations:
+ if fallback is not None:
+ return _Parser(fallback, self).value()
+ raise KeyError(f"undefined custom property {name}")
+ if name in self._stack:
+ raise ValueError(f"cyclic token reference: {' -> '.join(self._stack + [name])}")
+ self._stack.append(name)
+ # Bindings belong to the oklch(from …) that created them, not to a
+ # token resolved from inside it.
+ saved, self.bindings = self.bindings, {}
+ try:
+ value = self.eval(self.declarations[name])
+ finally:
+ self.bindings = saved
+ self._stack.pop()
+ self._cache[key] = value
+ return value
+
+ def eval(self, raw: str):
+ return _Parser(_lex(raw.strip()), self).value()
+
+ def color(self, name: str) -> Color:
+ v = self.resolve(name)
+ if not isinstance(v, Color):
+ raise TypeError(f"{name} is not a color: {v!r}")
+ return v
+
+
+# ---------------------------------------------------------------------------
+# Reading declarations out of the CSS
+# ---------------------------------------------------------------------------
+
+_COMMENT = re.compile(r"/\*.*?\*/", re.S)
+_DECL = re.compile(r"(--[-a-zA-Z0-9_]+)\s*:\s*([^;}]+)")
+
+
+def read_declarations(paths, skip_at_rules=True) -> dict[str, str]:
+ """Collect every `--nv-*: value` declaration, last one winning.
+
+ At-rule bodies (`@media (prefers-reduced-motion)`, `@supports not …`) are
+ skipped: they are alternate appearances of a token, and baking a
+ reduced-motion duration into the default build would be wrong.
+ """
+ out: dict[str, str] = {}
+ for path in paths:
+ with open(path, encoding="utf-8") as fh:
+ css = _COMMENT.sub("", fh.read())
+ if skip_at_rules:
+ css = _strip_at_rules(css)
+ for name, value in _DECL.findall(css):
+ out[name] = " ".join(value.split())
+ return out
+
+
+def _strip_at_rules(css: str) -> str:
+ out = []
+ i = 0
+ while i < len(css):
+ at = css.find("@", i)
+ if at == -1:
+ out.append(css[i:])
+ break
+ out.append(css[i:at])
+ # Skip to the matching close brace of the at-rule block.
+ brace = css.find("{", at)
+ if brace == -1:
+ break
+ depth = 0
+ j = brace
+ while j < len(css):
+ if css[j] == "{":
+ depth += 1
+ elif css[j] == "}":
+ depth -= 1
+ if depth == 0:
+ break
+ j += 1
+ i = j + 1
+ return "".join(out)
+
+
+def read_at_rule_declarations(paths, condition: str) -> dict[str, str]:
+ """Declarations from at-rule blocks whose prelude contains `condition`."""
+ out: dict[str, str] = {}
+ for path in paths:
+ with open(path, encoding="utf-8") as fh:
+ css = _COMMENT.sub("", fh.read())
+ for prelude, body in _iter_at_rules(css):
+ if condition in prelude:
+ for name, value in _DECL.findall(body):
+ out[name] = " ".join(value.split())
+ return out
+
+
+def _iter_at_rules(css: str):
+ i = 0
+ while True:
+ at = css.find("@", i)
+ if at == -1:
+ return
+ brace = css.find("{", at)
+ if brace == -1:
+ return
+ depth = 0
+ j = brace
+ while j < len(css):
+ if css[j] == "{":
+ depth += 1
+ elif css[j] == "}":
+ depth -= 1
+ if depth == 0:
+ break
+ j += 1
+ yield css[at:brace], css[brace + 1 : j]
+ i = j + 1
+
+
+# ---------------------------------------------------------------------------
+# Reading component rules
+# ---------------------------------------------------------------------------
+#
+# The token layer is custom properties on :root, which read_declarations
+# covers. A port that draws controls natively (a Qt style, Compose) also needs
+# the component layer: which role a secondary button's background is, which
+# shadow it gains on hover, what its disabled border becomes. Those live in
+# ordinary rules in components/*.css, and restating them in a port would fork
+# the system just as surely as restating a colour. So they are read here too.
+
+def _split_selectors(prelude: str) -> list[str]:
+ """`a, b:not(c, d)` -> [`a`, `b:not(c, d)`], whitespace collapsed."""
+ out, depth, cur = [], 0, []
+ for ch in prelude:
+ if ch in "([":
+ depth += 1
+ elif ch in ")]":
+ depth -= 1
+ if ch == "," and depth == 0:
+ out.append(" ".join("".join(cur).split()))
+ cur = []
+ continue
+ cur.append(ch)
+ tail = " ".join("".join(cur).split())
+ if tail:
+ out.append(tail)
+ return out
+
+
+def _parse_block_declarations(body: str) -> dict[str, str]:
+ out: dict[str, str] = {}
+ depth, cur = 0, []
+ for ch in body + ";":
+ if ch == "(":
+ depth += 1
+ elif ch == ")":
+ depth -= 1
+ if ch == ";" and depth == 0:
+ decl = "".join(cur).strip()
+ cur = []
+ if ":" in decl:
+ name, value = decl.split(":", 1)
+ out[name.strip()] = " ".join(value.split())
+ continue
+ cur.append(ch)
+ return out
+
+
+def read_rules(paths, media: tuple[str, ...] = ("hover: hover",)) -> dict[str, dict[str, str]]:
+ """Every style rule, as selector -> {property: value}, last one winning.
+
+ A grouped selector (`a, b { … }`) is recorded under each of its members.
+ Rules inside `@media` blocks whose condition contains one of `media` are
+ read as if they were top level: `(hover: hover)` only says the device has a
+ pointer, which a desktop always does. Every other at-rule is skipped;
+ those are alternate states (reduced motion, missing features, entry
+ animations) rather than the component's normal appearance.
+ """
+ rules: dict[str, dict[str, str]] = {}
+
+ def walk(css: str) -> None:
+ i = 0
+ while True:
+ brace = css.find("{", i)
+ if brace == -1:
+ return
+ prelude = css[i:brace].strip()
+ depth, j = 0, brace
+ while j < len(css):
+ if css[j] == "{":
+ depth += 1
+ elif css[j] == "}":
+ depth -= 1
+ if depth == 0:
+ break
+ j += 1
+ body = css[brace + 1 : j]
+ if prelude.startswith("@"):
+ if prelude.startswith("@media") and any(m in prelude for m in media):
+ walk(body)
+ else:
+ decls = _parse_block_declarations(body)
+ for sel in _split_selectors(prelude):
+ rules.setdefault(sel, {}).update(decls)
+ i = j + 1
+
+ for path in paths:
+ with open(path, encoding="utf-8") as fh:
+ walk(_COMMENT.sub("", fh.read()))
+ return rules