Niveus/niveus/continuity.css
Nexoniarz 23b8dd376d Niveus: a tactile, adaptive interface language
Bevelled controls with light falling across them, fields as wells, solid
windows, and glass kept for the chrome that floats over everything else.
Warm graphite in dark, soft paper in light, one brand color driving both.
Ships its own fonts (Instrument Sans, JetBrains Mono; OFL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:47:03 +02:00

236 lines
9.2 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ==========================================================================
Niveus — Continuity
--------------------------------------------------------------------------
Every interaction should read as a continuation of the previous state, not
as a replacement of it. When something moves, grows, collapses or changes
appearance, the change itself should be perceptible: where it came from,
where it went, and why.
This layer holds the cross-cutting machinery that individual components
then opt into:
· one duration and easing for everything the browser animates between
two states via a view transition
· emphasis that travels between elements instead of disappearing from one
and reappearing on another (--nv-selection-name)
· direction, so moving forward and moving back do not look identical
· appearance changes that cross-fade rather than cut
The goal is not to animate everything. It is that nothing pops into a new
state the user could have watched it reach.
========================================================================== */
:root {
/* Lets block-size: auto participate in transitions, which is what allows
content to expand from where it already is instead of appearing at full
height. Without it, every disclosure in the system would jump. */
interpolate-size: allow-keywords;
/* The default name for travelling emphasis. A page with more than one
selection group must give each group its own name — two visible elements
sharing a view-transition-name abort the transition entirely. */
--nv-selection-name: nv-selection;
}
/* --- View transitions -------------------------------------------------------
Anything the application wraps in document.startViewTransition() inherits
the system's travel duration and easing, so a state change animates like
the rest of Niveus without the application choosing timings. */
::view-transition-group(*) {
animation-duration: var(--nv-duration-travel);
animation-timing-function: var(--nv-ease-emphasized);
}
/* The page behind a travelling element cross-fades quickly — it is context,
not the subject of the change, and a slow fade would make the whole
interface feel as though it were being replaced. */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: var(--nv-duration-quick);
animation-timing-function: var(--nv-ease-standard);
}
/* --- Travelling emphasis -------------------------------------------------------
A selection marker moves between two elements, which no single element can
transition — so it is captured and interpolated. The problem that creates is
layering: transition layers paint above the page snapshot, so a marker that
sits *behind* its label at rest would slide over the top of every label it
passed, washing the text as it went.
The fix is to capture the labels too and order the groups explicitly. The
marker travels below; the items ride above it, exactly as they do at rest.
niveus.js 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. */
::view-transition-group(.nv-travelling) {
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;
}
/* A carried item does not move — only its color changes when selection leaves
or arrives. That resolves quickly so the label does not sit half-way between
two colors for the length of the travel. */
::view-transition-old(.nv-carried),
::view-transition-new(.nv-carried) {
animation-duration: var(--nv-duration-fast);
}
/* --- Appearance ---------------------------------------------------------------
A theme change is the one moment the entire surface is the subject, so the
root cross-fade slows down and every color arrives together. The attribute
is set for the duration of the change by nv.setTheme(). */
:root[data-nv-transition="theme"]::view-transition-old(root),
:root[data-nv-transition="theme"]::view-transition-new(root) {
animation-duration: var(--nv-duration-theme);
animation-timing-function: var(--nv-ease-standard);
}
/* Without the view-transition path — no script, or a browser without the API —
surfaces still glide, because their own transitions cover color. */
/* --- Direction ------------------------------------------------------------------
Moving forward and moving back should not look the same. The application
sets data-nv-direction on the root for the length of the transition; content
then leaves towards where the user came from and enters from where they are
going. Without a direction, content simply rises into place.
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-old(.nv-content) {
animation: nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both;
}
::view-transition-new(.nv-content) {
animation:
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-old(.nv-content) {
animation:
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="forward"]::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-end var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both;
}
:root[data-nv-direction="backward"]::view-transition-old(.nv-content) {
animation:
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 {
from { opacity: 0; }
}
@keyframes nv-fade-out {
to { opacity: 0; }
}
@keyframes nv-rise {
from { translate: 0 var(--nv-travel-sm); }
}
@keyframes nv-enter-from-end {
from { translate: var(--nv-travel-lg) 0; }
}
@keyframes nv-enter-from-start {
from { translate: calc(var(--nv-travel-lg) * -1) 0; }
}
@keyframes nv-exit-to-start {
to { translate: calc(var(--nv-travel-md) * -1) 0; }
}
@keyframes nv-exit-to-end {
to { translate: var(--nv-travel-md) 0; }
}
/* --- Utilities ---------------------------------------------------------------------
.nv-continuous marks a region whose content should cross-fade in place when
it changes — the "same surface, different content" relationship. */
.nv-continuous {
view-transition-name: var(--nv-content-name, nv-content);
view-transition-class: nv-content;
}
/* Collapsible region for markup that is not a <details>. The 0fr -> 1fr grid
row is the one technique that animates to an unknown content height in every
browser, with no measurement and no script. */
.nv-collapse {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows var(--nv-duration-expand) var(--nv-ease-standard),
opacity var(--nv-duration-expand) var(--nv-ease-standard);
opacity: 0;
}
.nv-collapse > * {
min-block-size: 0;
overflow: hidden;
}
.nv-collapse[data-open],
.nv-collapse:not([hidden])[open] {
grid-template-rows: 1fr;
opacity: 1;
}
/* Reduced motion does not mean "no continuity" — a state change still has to
resolve rather than be cut mid-way. The token layer already collapses every
duration to 1ms; this removes the displacement so nothing slides. */
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation-duration: 1ms;
}
}