mirror of
https://github.com/Nexoniarz/Niveus.git
synced 2026-11-03 04:34:26 +00:00
Tokens - --nv-color-glow role (light and dark) and a glow scale: --nv-glow-sm/md/lg, --nv-glow-blur-*, --nv-glow-spread-*, --nv-glow-strength - --nv-stagger (0ms under reduced motion) and --nv-duration-shimmer Components - Card: --lit (lit top edge in --nv-card-tint), --glow, --spotlight (light follows the pointer); light layers combine with every elevation - Tile: small raised key for a glyph or product mark; --solid, --glow, sizes - Avatar: raised disc with --ring / --glow, sizes, __status, avatar group - Stat: readings set into one recessed well (.nv-stat-group) - Steps: markers set into a rail; done steps light their stretch; vertical - Deck: one surface in front of stacked layers of its kind - Skeleton: recessed placeholder with a slow sweep, still under reduced motion - Text: .nv-text-lit and .nv-text-lit--accent Continuity - .nv-reveal / .nv-reveal-group: content arrives once as it scrolls into view (nv-arrive keyframe); niveus.js marks pending state, so nothing is hidden without the script or under reduced motion - niveus.js tracks the pointer for .nv-card--spotlight README and REFERENCE document every new class, token and attribute; tools/check.py passes and dist/ is rebuilt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
10 KiB
CSS
263 lines
10 KiB
CSS
/* ==========================================================================
|
||
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;
|
||
}
|
||
|
||
/* --- 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 <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;
|
||
}
|
||
}
|