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

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

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

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

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

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

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

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

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

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

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

View file

@ -1,22 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 18" width="28" height="18" role="img" aria-labelledby="pride-title"> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<title id="pride-title">Progress Pride flag</title> <svg
<desc>The six-stripe rainbow with the Progress chevron: white, pink and light viewBox="0 0 28 18"
width="28"
height="18"
role="img"
aria-labelledby="pride-title"
version="1.1"
id="svg10"
sodipodi:docname="pride.svg"
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs10" />
<sodipodi:namedview
id="namedview10"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="28.814601"
inkscape:cx="15.773253"
inkscape:cy="10.706378"
inkscape:window-width="1920"
inkscape:window-height="1056"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg10" />
<title
id="pride-title">Progress Pride flag</title>
<desc
id="desc1">The six-stripe rainbow with the Progress chevron: white, pink and light
blue for trans people, brown and black for queer people of colour and for blue for trans people, brown and black for queer people of colour and for
those lost to AIDS. It sits in the top bar every day of the year, which is those lost to AIDS. It sits in the top bar every day of the year, which is
the whole point of it.</desc> the whole point of it.</desc>
<!-- the six --> <!-- the six -->
<rect width="28" height="3" y="0" fill="#E40303"/> <rect
<rect width="28" height="3" y="3" fill="#FF8C00"/> width="28"
<rect width="28" height="3" y="6" fill="#FFED00"/> height="3"
<rect width="28" height="3" y="9" fill="#008026"/> y="0"
<rect width="28" height="3" y="12" fill="#24408E"/> fill="#E40303"
<rect width="28" height="3" y="15" fill="#732982"/> id="rect1" />
<rect
width="28"
height="3"
y="3"
fill="#FF8C00"
id="rect2" />
<rect
width="28"
height="3"
y="6"
fill="#FFED00"
id="rect3" />
<rect
width="28"
height="3"
y="9"
fill="#008026"
id="rect4" />
<rect
width="28"
height="3"
y="12"
fill="#24408E"
id="rect5" />
<rect
width="28"
height="3"
y="15"
fill="#732982"
id="rect6" />
<!-- the chevron: nested, outermost first, so each one leaves a band --> <!-- the chevron: nested, outermost first, so each one leaves a band -->
<polygon points="0,0 0,18 13,9" fill="#FFFFFF"/> <polygon
<polygon points="0,0 0,18 11,9" fill="#FFAFC8"/> points="0,18 13,9 0,0 "
<polygon points="0,0 0,18 9,9" fill="#74D7EE"/> fill="#ffffff"
<polygon points="0,0 0,18 7,9" fill="#613915"/> id="polygon6"
<polygon points="0,0 0,18 5,9" fill="#000000"/> transform="scale(0.97864331,1)" />
<polygon
points="11,9 0,0 0,18 "
fill="#ffafc8"
id="polygon7"
transform="matrix(0.98738014,0,0,0.98091343,0,0.17177914)" />
<polygon
points="0,18 9,9 0,0 "
fill="#74d7ee"
id="polygon8"
transform="matrix(1,0,0,0.96455351,0,0.3190184)" />
<polygon
points="0,18 7,9 0,0 "
fill="#613915"
id="polygon9"
transform="matrix(1,0,0,0.92365372,0,0.68711656)" />
<polygon
points="5,9 0,0 0,18 "
fill="#000000"
id="polygon10"
transform="matrix(1.0624683,0,0,0.85276074,0,1.3251534)" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Before After
Before After

View file

@ -1,20 +1,73 @@
<!-- SPDX-License-Identifier: Apache-2.0 --> <!-- SPDX-License-Identifier: Apache-2.0 -->
# The website # The website
Six files and a folder of marks. No build step, no dependencies, no Three pages, two scripts, one stylesheet of its own, and the design system
framework — open `index.html` and it works, including from a `file://` URL. underneath. No build step, no dependencies, no framework — open `index.html`
and it works, including from a `file://` URL.
``` ```
index.html the front page index.html the front page
download.html get it, write it to a stick, or build it download.html get it, write it to a stick, or build it
docs.html the documentation reader docs.html the documentation reader
style.css the palette, transcribed from include/theme.h app.css this site's layout — and nothing else, see below
app.js the front page: the boot log types, sections fade in app.js the boot log types, sections arrive, the bar takes a shadow
docs.js the Markdown renderer and the doc index docs.js the Markdown renderer and the doc index
niveus/ Niveus, vendored. The site links niveus/dist/niveus.css and
niveus/niveus/niveus.js
Assets/ shift.svg (logo), uberblock.svg (SchFS), Assets/ shift.svg (logo), uberblock.svg (SchFS),
usb.svg (downloads), pride.svg usb.svg (downloads), pride.svg
``` ```
## It is built on Niveus
[Niveus](../niveus/) is the interface language the project uses for everything
with a screen — this site first, applications after it. The rule that makes it
worth having is the one it enforces on this directory:
**`app.css` contains no colour, shadow, radius, type size or duration.** It is
layout: where things sit, how wide they get, when they stack. Everything else
is a token. A hex value in that file means the system is missing something, and
the answer is to add it to Niveus rather than to work around it here.
What that replaced was a 21 KB `style.css` that transcribed
[`include/theme.h`](../../include/theme.h) by hand — palette, type scale,
shadows, the lot. The intent was right and the mechanism was a copy: two files
that had to be kept in step by remembering to. Now the whole identity is one
declaration, in the `<head>` of each page:
```css
:root { --nv-brand: #7C5CFF; }
```
That is `SCH_ACCENT`, the same violet the kernel draws with. Niveus derives the
accent and its states, the link colour, the focus ring, the tint in the greys
and how far the status colours lean — for light and dark both. The site still
looks like the operating system, and now there is one number to keep true
instead of forty.
**The appearance follows the system, and remembers what you pick.** Dark is the
default because the OS is dark; the Light/Dark/Auto group in the bar is real,
and `app.js` keeps the choice in `localStorage`. An inline script in each
`<head>` reads it back before first paint, so the page never renders in one
appearance and corrects itself in front of you.
**PSOGM purple appears exactly once.** `#4B1D9A` is on the crash-screen section
and nowhere else, because [../branding/README.md](../branding/README.md) says
it means *the machine stopped* and that a colour used everywhere means nothing.
It is spelled the way Niveus spells a subtree with its own colour —
`data-nv-brand` with its own `--nv-brand` — so every role inside that section
re-derives from it rather than being painted on. If it shows up in a second
place, that is a bug in the page.
### Updating the system
`niveus/` here is a copy, so that this directory can be deployed on its own.
The original lives in [../niveus/](../niveus/) and is where changes are made:
```sh
rm -rf docs/Website/niveus && cp -r docs/niveus docs/Website/niveus
```
## The download page ## The download page
`download.html` links to `releases/schos.iso`, a path relative to this `download.html` links to `releases/schos.iso`, a path relative to this
@ -27,11 +80,12 @@ make iso
mkdir -p docs/Website/releases && cp schos.iso docs/Website/releases/ mkdir -p docs/Website/releases && cp schos.iso docs/Website/releases/
``` ```
The page is written so that this is the only thing that needs doing, and so that The page is written so that this is the only thing that needs doing, and so
nothing on it is a promise the tree cannot keep. In particular the support table that nothing on it is a promise the tree cannot keep. In particular the support
says **one real machine, under test** rather than listing hardware, because one table says **one real machine, under test** rather than listing hardware,
Chromebox is the entire bare-metal record and a compatibility list would be because one Chromebox is the entire bare-metal record and a compatibility list
fiction. When that changes, change the table; do not soften the wording. would be fiction. When that changes, change the table; do not soften the
wording.
The `dd` command is split across two lines on purpose. It was one long line The `dd` command is split across two lines on purpose. It was one long line
first, and the card clipped it at `conv=` — a destructive command that can first, and the card clipped it at `conv=` — a destructive command that can
@ -41,8 +95,13 @@ scroll half of itself out of view is a good way to take somebody's disk.
`docs.html` renders the Markdown in `docs/` in the site's own theme, with the `docs.html` renders the Markdown in `docs/` in the site's own theme, with the
index down the left and a table of contents down the right. Links between index down the left and a table of contents down the right. Links between
documents are rewritten to stay inside the reader, so `../history/schfs.md` documents are rewritten to stay inside the reader, so `../history/schfs.md` in
in an architecture page navigates rather than downloads. an architecture page navigates rather than downloads.
Both rails are `.nv-nav--vertical`, the current document is marked with
`aria-current="page"` rather than a class of our own, and the renderer emits
`.nv-table` and `.nv-code` — the reader is built from the same system as the
rest of the site, so a change to Niveus reaches the documentation too.
The parser is about 150 lines in `docs.js` and handles what `docs/` actually The parser is about 150 lines in `docs.js` and handles what `docs/` actually
uses: headings, fenced code, tables, lists, quotes, rules and inline marks. uses: headings, fenced code, tables, lists, quotes, rules and inline marks.
@ -79,22 +138,13 @@ is not.
## The flag ## The flag
`Assets/pride.svg` is in the top bar and in the footer, on both pages, and it `Assets/pride.svg` is in the top bar and in the footer, on every page, and it
is not seasonal. Support with an end date is marketing; this is a project that is not seasonal. Support with an end date is marketing; this is a project that
belongs to whoever writes it. It is also deliberately small — the site is belongs to whoever writes it. It is also deliberately small — the site is
about an operating system, and the flag is a statement, not a subject. about an operating system, and the flag is a statement, not a subject.
## The rules it follows ## The rules it follows
**The palette is not a design decision.** It is `include/theme.h`, copied. A
site describing an operating system should look like the operating system, and
copying the header means the two cannot drift apart by accident.
**PSOGM purple appears exactly once.** `#4B1D9A` is on the crash-screen section
and nowhere else, because [../branding/README.md](../branding/README.md) says
it means *the machine stopped* and that a colour used everywhere means nothing.
If it shows up in a second place, that is a bug in the page.
**The typefaces are the ones the OS ships.** Roboto for text and JetBrains Mono **The typefaces are the ones the OS ships.** Roboto for text and JetBrains Mono
for the system voice — both already in `ports/`, both already in `NOTICE`. They for the system voice — both already in `ports/`, both already in `NOTICE`. They
are requested from the system rather than downloaded: a font file would be are requested from the system rather than downloaded: a font file would be
@ -102,7 +152,8 @@ larger than the entire rest of the site.
**The script is optional.** If `app.js` fails to load the page is still **The script is optional.** If `app.js` fails to load the page is still
complete and still readable. Everything it does is decoration, and it honours complete and still readable. Everything it does is decoration, and it honours
`prefers-reduced-motion` by doing none of it. `prefers-reduced-motion` by doing none of it. `niveus.js` is optional in the
same way — without it, state changes arrive instead of travelling.
**The boot log is real.** The lines in `app.js` are from an actual boot, not **The boot log is real.** The lines in `app.js` are from an actual boot, not
invented. The timer calibration line is there deliberately; see section 13 of invented. The timer calibration line is there deliberately; see section 13 of
@ -118,6 +169,6 @@ find kernel include -name '*.c' -o -name '*.h' | xargs wc -l | tail -1
grep -c '^SYSCALL' kernel/sys/table.inc grep -c '^SYSCALL' kernel/sys/table.inc
``` ```
The feature cards carry a tag — `runs`, `in progress`, or `designed` — and the The feature cards carry a badge — `runs`, `in progress`, or `designed` — and
tag is a promise. Anything marked `runs` is in the tree and works today. When the badge is a promise. Anything marked `runs` is in the tree and works today.
that stops being true, change the tag rather than the wording. When that stops being true, change the badge rather than the wording.

529
docs/Website/app.css Normal file
View file

@ -0,0 +1,529 @@
/* SPDX-License-Identifier: Apache-2.0 */
/* ==========================================================================
The SchOS site — layout only
--------------------------------------------------------------------------
Everything here is *arrangement*: where things sit, how wide they get, when
they stack. Not one colour, shadow, radius, type size or duration is stated
in this file — those come from Niveus, which is the whole point of the
split. A hex value in here means the system is missing a token, and the
answer is to add one rather than to work around it.
The one exception is `--nv-brand`, which is declared in the pages: it is
SCH_ACCENT from include/theme.h, and it is an input to the system rather
than a restatement of it.
========================================================================== */
/* --- The page shell -------------------------------------------------------- */
.shell {
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
.section {
padding-block: var(--nv-space-20);
}
.section__head {
max-inline-size: var(--nv-measure);
margin-block-end: var(--nv-space-8);
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
}
/* A closing remark under a figure. Narrower than the figure, so it reads as
commentary on it rather than as the next section. */
.section__note {
max-inline-size: var(--nv-measure);
margin-block-start: var(--nv-space-5);
}
/* The flag in the bar. Deliberately small: the site is about an operating
system, and the flag is a statement, not a subject. */
.pride img {
display: block;
block-size: var(--nv-space-4);
inline-size: auto;
border-radius: var(--nv-radius-xs);
}
@media (max-width: 60rem) {
.appearance { display: none; }
}
/* --- Hero ------------------------------------------------------------------ */
.hero {
display: grid;
gap: var(--nv-space-12);
padding-block: var(--nv-space-16) var(--nv-space-8);
align-items: center;
}
@media (min-width: 62rem) {
.hero {
grid-template-columns: 5fr 7fr;
gap: var(--nv-space-14);
}
}
.hero__copy {
display: flex;
flex-direction: column;
gap: var(--nv-space-5);
align-items: flex-start;
}
.hero__title {
display: flex;
align-items: center;
gap: var(--nv-space-4);
font-size: clamp(var(--nv-font-size-4xl), 7vw, var(--nv-font-size-5xl));
}
.hero__mark {
inline-size: 1.15em;
block-size: 1.15em;
}
/* --- The console ----------------------------------------------------------- */
/*
A terminal is a recess, not a card that happens to hold monospace: the
material says "this is a window into the machine" before the text does.
*/
.console {
overflow: hidden;
}
.console__bar {
display: flex;
align-items: center;
gap: var(--nv-space-3);
padding: var(--nv-space-2) var(--nv-space-3);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.console__log {
margin: 0;
padding: var(--nv-space-4);
min-block-size: 18rem;
overflow-x: auto;
white-space: pre;
line-height: var(--nv-line-height-relaxed);
background: var(--nv-color-surface-inset);
box-shadow: var(--nv-elevation-inset-sm);
}
/* The cursor the boot log types behind. */
.console__log::after {
content: "";
display: inline-block;
inline-size: 0.6em;
block-size: 1em;
vertical-align: text-bottom;
background: var(--nv-color-accent);
animation: console-blink 1.1s steps(2, start) infinite;
}
@keyframes console-blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.console__log::after { animation: none; }
}
/* --- Origin, as a timeline -------------------------------------------------- */
/*
The rail is not decoration: these steps happened in this order, and the
order is the claim the section makes. A grid would lose it.
*/
.timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--nv-space-8);
border-inline-start: var(--nv-border-width) solid var(--nv-color-border);
padding-inline-start: var(--nv-space-6);
}
.timeline__step {
display: flex;
flex-direction: column;
gap: var(--nv-space-2);
max-inline-size: var(--nv-measure);
position: relative;
}
.timeline__step::before {
content: "";
position: absolute;
inline-size: var(--nv-space-2);
block-size: var(--nv-space-2);
border-radius: var(--nv-radius-full);
background: var(--nv-color-accent);
inset-inline-start: calc(var(--nv-space-6) * -1 - var(--nv-space-1));
inset-block-start: var(--nv-space-1);
}
.timeline__when {
color: var(--nv-color-text-tertiary);
}
/* --- The stack -------------------------------------------------------------- */
.stack {
display: flex;
flex-direction: column;
}
.stack__layer {
display: grid;
grid-template-columns: 12rem 1fr;
gap: var(--nv-space-4);
align-items: baseline;
padding: var(--nv-space-3) var(--nv-space-5);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.stack__layer:last-child { border-block-end: 0; }
.stack__name {
display: flex;
align-items: center;
gap: var(--nv-space-2);
font-family: var(--nv-font-family-mono);
color: var(--nv-color-text-primary);
}
/* The three seams the section is about. They are the argument, so they carry
the accent ground; everything else is the arrangement between them. */
.stack__layer--seam {
background: var(--nv-color-accent-subtle);
box-shadow: inset var(--nv-border-width-strong) 0 0 var(--nv-color-accent);
}
@media (max-width: 48rem) {
.stack__layer {
grid-template-columns: 1fr;
gap: var(--nv-space-1);
}
}
/* --- Hybrid: what a driver needs -------------------------------------------- */
.needs {
display: grid;
gap: var(--nv-space-4);
margin-block-end: var(--nv-space-8);
}
@media (min-width: 48rem) {
.needs { grid-template-columns: repeat(2, 1fr); }
}
/* --- Numbers ---------------------------------------------------------------- */
.figures {
display: grid;
gap: var(--nv-space-4);
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
}
/* A card carries no padding of its own -- its parts do -- and a stat tile has
no parts. */
.figure {
display: flex;
flex-direction: column;
gap: var(--nv-space-1);
align-items: flex-start;
padding: var(--nv-space-5);
}
.figure__value {
font-variant-numeric: tabular-nums;
letter-spacing: var(--nv-letter-spacing-tight);
}
/* --- PSOGM ------------------------------------------------------------------ */
.crash {
display: grid;
gap: var(--nv-space-8);
align-items: center;
padding: var(--nv-space-12);
}
@media (min-width: 48rem) {
.crash { grid-template-columns: auto 1fr; }
}
/* The bed's label is a caption for the whole region, not the first cell of
its grid. */
.crash .nv-bed__label {
grid-column: 1 / -1;
}
.crash__mark {
inline-size: var(--nv-space-20);
block-size: auto;
}
.crash__copy {
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
}
/* --- The closing band -------------------------------------------------------- */
/* Padded here for the same reason .figure is: a card's padding lives on its
parts, and this band has none -- it is a mark, some copy and two buttons. */
.getband {
display: grid;
gap: var(--nv-space-8);
align-items: center;
padding: var(--nv-space-8);
}
@media (min-width: 48rem) {
.getband { grid-template-columns: auto 1fr; }
}
.getband__mark {
inline-size: var(--nv-space-20);
block-size: auto;
justify-self: center;
}
.getband__copy {
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
align-items: flex-start;
}
/* --- Cards ------------------------------------------------------------------- */
/* Niveus's reset makes images block-level, which is right nearly everywhere
and wrong for a mark that belongs beside a title. A flex row rather than
`display: inline`, so the two stay aligned whatever the title does. */
.nv-card__title:has(.cardmark) {
display: flex;
align-items: center;
gap: var(--nv-space-2);
}
.cardmark {
inline-size: 1.2em;
block-size: 1.2em;
flex: none;
}
/* --- Footer ------------------------------------------------------------------ */
.site-footer {
display: flex;
flex-direction: column;
gap: var(--nv-space-5);
padding-block: var(--nv-space-8) var(--nv-space-16);
}
.site-footer__row {
display: flex;
flex-wrap: wrap;
gap: var(--nv-space-2) var(--nv-space-6);
align-items: baseline;
justify-content: space-between;
padding-block-start: var(--nv-space-6);
}
.pride-note {
display: flex;
align-items: center;
gap: var(--nv-space-3);
}
.pride-note img {
block-size: var(--nv-space-4);
inline-size: auto;
border-radius: var(--nv-radius-xs);
}
/* --- Reveal on scroll -------------------------------------------------------- */
/*
app.js adds .is-revealed. Without the script every section is simply
visible, which is the only acceptable failure mode for decoration.
*/
.section[data-reveal] {
opacity: 0;
translate: 0 var(--nv-travel-md);
transition:
opacity var(--nv-duration-slow) var(--nv-ease-entrance),
translate var(--nv-duration-slow) var(--nv-ease-entrance);
}
.section[data-reveal].is-revealed {
opacity: 1;
translate: 0 0;
}
/* --- Cards that are entirely a link ------------------------------------------ */
/*
The whole card is the target -- `.nv-card--interactive` says so by lifting
under the pointer -- so an underline on the title is a second affordance for
the same thing, and it reads as a link *inside* a link.
*/
.nv-card__link {
text-decoration: none;
}
/* --- Command blocks ----------------------------------------------------------- */
/*
A command is the one thing on these pages that must be copied exactly, so it
gets a recess of its own rather than sitting in the card's own material.
`white-space: pre` and not `pre-wrap`: a wrapped shell command is a command
that can be mistyped.
*/
.cmd {
margin: 0;
padding: var(--nv-space-3) var(--nv-space-4);
overflow-x: auto;
white-space: pre;
line-height: var(--nv-line-height-relaxed);
background: var(--nv-color-surface-inset);
border-radius: var(--nv-radius-nested-md);
box-shadow: var(--nv-elevation-inset-sm);
}
/* --- The documentation reader ------------------------------------------------ */
/*
Index, document, contents. Three columns that become one on a narrow screen,
in that order of importance: the document is the page, the index is how you
got there, and the contents panel is a convenience.
*/
.docshell {
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
padding-block: var(--nv-space-8) var(--nv-space-16);
display: grid;
gap: var(--nv-space-8);
align-items: start;
}
@media (min-width: 64rem) {
.docshell {
grid-template-columns: 15rem minmax(0, 1fr) 14rem;
gap: var(--nv-space-10);
}
}
@media (max-width: 64rem) {
.docshell__rail { display: none; }
}
/* Both panels follow the reader down the page, under the bar rather than
behind it. */
.docshell__aside,
.docshell__rail {
position: sticky;
inset-block-start: var(--nv-space-16);
max-block-size: calc(100vh - var(--nv-space-24));
overflow-y: auto;
}
/* A vertical nav that is a panel in a page, not the chrome of an app: no
surface of its own, and no full-height rule down its edge. */
.docnav {
position: static; /* the column is what sticks, not the nav */
background: none;
border: 0;
box-shadow: none;
padding: 0;
inline-size: 100%; /* the sidebar component is a fixed 15rem */
block-size: auto;
}
.docnav .nv-nav__list {
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-px);
margin-block-end: var(--nv-space-5);
}
/* A nav item in a bar is one line by definition; a document title is not. The
contents panel is 14rem wide and some headings are longer than that, so they
wrap rather than being cut off mid-word -- a truncated entry is a
destination you cannot recognise. */
.docnav .nv-nav__item {
justify-content: flex-start;
inline-size: 100%;
white-space: normal;
block-size: auto;
min-block-size: var(--nv-control-height-sm);
padding-block: var(--nv-space-1-5);
text-align: start;
}
/* A contents panel is a shape: depth reads as indentation. */
.toc-lvl3 .nv-nav__item { padding-inline-start: var(--nv-space-5); }
.toc-lvl4 .nv-nav__item { padding-inline-start: var(--nv-space-8); }
/* --- The document itself ----------------------------------------------------- */
.doc {
max-inline-size: none; /* the column already sets the measure */
}
.doc > * + * {
margin-block-start: var(--nv-space-4);
}
.doc h1,
.doc h2,
.doc h3,
.doc h4 {
margin-block-start: var(--nv-space-8);
scroll-margin-block-start: var(--nv-space-20);
}
.doc h2 {
padding-block-end: var(--nv-space-2);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.doc__pre {
margin: 0;
padding: var(--nv-space-4);
overflow-x: auto;
white-space: pre;
line-height: var(--nv-line-height-relaxed);
background: var(--nv-color-surface-inset);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-elevation-inset-sm);
}
.doc blockquote {
margin-inline: 0;
padding: var(--nv-space-1) var(--nv-space-5);
border-inline-start: var(--nv-border-width-strong) solid var(--nv-color-accent-border);
color: var(--nv-color-text-secondary);
}
.doc ul,
.doc ol {
padding-inline-start: var(--nv-space-6);
}
.doc li + li {
margin-block-start: var(--nv-space-2);
}

View file

@ -1,12 +1,16 @@
/* SPDX-License-Identifier: Apache-2.0 /* SPDX-License-Identifier: Apache-2.0
* *
* Two behaviours, no framework: * Three behaviours, no framework:
* 1. the hero console types a real boot log * 1. the hero console types a real boot log
* 2. sections fade in once, as they arrive * 2. sections fade in once, as they arrive
* 3. the bar takes a shadow once the page has scrolled under it
* *
* Both are decoration. If this file fails to load the page is still complete * All three are decoration. If this file fails to load the page is still
* and still readable, which is the test any script on a documentation site * complete and still readable, which is the test any script on a
* should have to pass. * documentation site should have to pass.
*
* Niveus does the rest: its own niveus.js wires the appearance buttons, and
* everything visual comes from the stylesheet. Nothing here sets a style.
*/ */
(function () { (function () {
"use strict"; "use strict";
@ -34,6 +38,8 @@
var out = document.getElementById("bootlog"); var out = document.getElementById("bootlog");
/* The classes are Niveus text roles, so the log is coloured by the same
tokens as the rest of the page and follows the appearance with it. */
function span(cls, text) { function span(cls, text) {
var e = document.createElement("span"); var e = document.createElement("span");
if (cls) e.className = cls; if (cls) e.className = cls;
@ -43,28 +49,19 @@
function emit(line) { function emit(line) {
if (line.t) { if (line.t) {
out.appendChild(span("t", line.t + " ")); out.appendChild(span("nv-text-tertiary", line.t + " "));
out.appendChild(span("k", line.k)); out.appendChild(span("nv-text-accent", line.k));
} }
out.appendChild(span(line.g ? "g" : "", line.s)); out.appendChild(span(line.g ? "nv-text-success" : "", line.s));
out.appendChild(document.createTextNode("\n")); out.appendChild(document.createTextNode("\n"));
} }
function cursor() { if (out && reduced) {
var c = document.createElement("i");
c.className = "cursor";
out.appendChild(c);
}
if (!out) {
/* nothing to do */
} else if (reduced) {
LINES.forEach(emit); // the whole log at once, no animation LINES.forEach(emit); // the whole log at once, no animation
cursor(); } else if (out) {
} else {
var i = 0; var i = 0;
(function step() { (function step() {
if (i >= LINES.length) { cursor(); return; } if (i >= LINES.length) return;
emit(LINES[i++]); emit(LINES[i++]);
/* Uneven on purpose: a boot that ticks out at a metronome's pace reads /* Uneven on purpose: a boot that ticks out at a metronome's pace reads
as a progress bar rather than as a machine doing different work. */ as a progress bar rather than as a machine doing different work. */
@ -74,20 +71,45 @@
/* ---- 2. reveal on scroll ------------------------------------------ */ /* ---- 2. reveal on scroll ------------------------------------------ */
var targets = document.querySelectorAll(".reveal"); var targets = document.querySelectorAll("[data-reveal]");
if (reduced || !("IntersectionObserver" in window)) { if (reduced || !("IntersectionObserver" in window)) {
Array.prototype.forEach.call(targets, function (el) { el.classList.add("in"); }); Array.prototype.forEach.call(targets, function (el) {
return; el.classList.add("is-revealed");
} });
} else {
var io = new IntersectionObserver(function (entries) { var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) { entries.forEach(function (entry) {
if (!entry.isIntersecting) return; if (!entry.isIntersecting) return;
entry.target.classList.add("in"); entry.target.classList.add("is-revealed");
io.unobserve(entry.target); // once, not on every scroll past io.unobserve(entry.target); // once, not on every scroll past
}); });
}, { rootMargin: "0px 0px -12% 0px", threshold: 0.05 }); }, { rootMargin: "0px 0px -12% 0px", threshold: 0.05 });
Array.prototype.forEach.call(targets, function (el) { io.observe(el); }); Array.prototype.forEach.call(targets, function (el) { io.observe(el); });
}
/* ---- 3. the bar, once something has passed under it ---------------- */
var bar = document.querySelector(".nv-nav");
if (bar) {
var onScroll = function () {
if (window.scrollY > 8) bar.setAttribute("data-scrolled", "");
else bar.removeAttribute("data-scrolled");
};
addEventListener("scroll", onScroll, { passive: true });
onScroll();
}
/* ---- and remember the appearance ----------------------------------- */
/*
* Niveus changes the appearance; which one was chosen is this site's to
* keep. The inline script in <head> reads it back before first paint.
*/
document.documentElement.addEventListener("nv:theme", function (event) {
try {
localStorage.setItem("schos-theme", (event.detail && event.detail.theme) || "system");
} catch (e) { /* storage unavailable — the choice lasts this visit */ }
});
})(); })();

View file

@ -1,74 +1,104 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Docs — SchOS</title> <title>Docs — SchOS</title>
<meta name="description" content="The SchOS documentation: architecture, guides, plans and the history of the bugs worth remembering."> <meta name="description" content="The SchOS documentation: architecture, guides, plans and the history of the bugs worth remembering.">
<link rel="icon" href="Assets/shift.svg" type="image/svg+xml"> <link rel="icon" href="Assets/shift.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
</head>
<body class="reading">
<header class="top"> <link rel="stylesheet" href="niveus/dist/niveus.css">
<div class="wrap"> <script src="niveus/niveus/niveus.js" defer></script>
<a class="brand" href="index.html"> <link rel="stylesheet" href="app.css">
<img src="Assets/shift.svg" alt="">
<script>
try {
var saved = localStorage.getItem("schos-theme");
if (saved === "system") document.documentElement.removeAttribute("data-theme");
else if (saved) document.documentElement.setAttribute("data-theme", saved);
} catch (e) { /* storage unavailable — keep the default */ }
</script>
<style>
:root { --nv-brand: #7C5CFF; }
</style>
</head>
<body>
<nav class="nv-nav" aria-label="Main" style="--nv-selection-name: nv-schos-nav">
<a class="nv-nav__brand" href="index.html">
<img class="nv-nav__icon" src="Assets/shift.svg" alt="">
SchOS SchOS
</a> </a>
<a class="pride" href="#pride-note" title="SchOS stands with the LGBTQIAP+ community — every month of the year">
<ul class="nv-nav__list">
<li><a class="nv-nav__item" href="index.html">Home</a></li>
<li><a class="nv-nav__item" href="download.html">Download</a></li>
<li><a class="nv-nav__item" href="docs.html" aria-current="page">Docs</a></li>
</ul>
<span class="nv-nav__spacer"></span>
<a class="pride" href="#pride-note"
title="SchOS stands with the LGBTQIAP+ community — every month of the year">
<img src="Assets/pride.svg" alt="Progress Pride flag"> <img src="Assets/pride.svg" alt="Progress Pride flag">
</a> </a>
<nav>
<a href="index.html">Home</a>
<a href="download.html">Download</a>
<a href="docs.html" class="on">Docs</a>
</nav>
</div>
</header>
<div class="docshell"> <div class="nv-button-group appearance" role="group" aria-label="Appearance">
<aside class="sidebar" aria-label="Documentation index"> <button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<div class="sticky"> <button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="true">Dark</button>
<nav id="docnav"></nav> <button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="false">Auto</button>
</div> </div>
</nav>
<!-- Index, document, contents. Three columns that become one on a narrow
screen, in that order, because that is their order of importance when
there is only room for the middle one. -->
<div class="docshell">
<aside class="docshell__aside" aria-label="Documentation index">
<nav class="nv-nav nv-nav--vertical nv-nav--static docnav" id="docnav"
style="--nv-selection-name: nv-docs-index"></nav>
</aside> </aside>
<main> <main class="docshell__main">
<article id="doc" class="prose" aria-live="polite"> <article class="nv-prose doc" id="doc" aria-live="polite">
<p class="loading">Loading…</p> <p class="nv-text-tertiary">Loading…</p>
</article> </article>
</main> </main>
<aside class="rail" aria-label="On this page"> <aside class="docshell__rail" aria-label="On this page">
<div class="sticky"> <nav class="nv-nav nv-nav--vertical nv-nav--static docnav" id="toc"
<nav id="toc" hidden></nav> style="--nv-selection-name: nv-docs-toc" hidden></nav>
</div>
</aside> </aside>
</div> </div>
<footer> <footer class="shell site-footer">
<div class="wrap"> <div class="nv-divider"></div>
<span>SchOS — kernel <b>SchIFT</b>, filesystem <b>SchFS</b>.</span> <div class="site-footer__row">
<span class="mono">Apache-2.0, with the xv6-derived core under MIT.</span> <span class="nv-body">SchOS — kernel <b>SchIFT</b>, filesystem <b>SchFS</b>.</span>
<span class="pride-note" id="pride-note"> <span class="nv-code nv-text-tertiary">Apache-2.0, with the xv6-derived core under MIT.</span>
</div>
<p class="nv-body nv-text-secondary pride-note" id="pride-note">
<img src="Assets/pride.svg" alt=""> <img src="Assets/pride.svg" alt="">
Built by, and for, people of every orientation and gender identity. Built by, and for, people of every orientation and gender identity.
The flag stays up all year. The flag stays up all year.
</span> </p>
<span class="note" id="ai-note">
<b>On AI.</b> <p class="nv-body nv-text-secondary nv-prose" id="ai-note">
<b class="nv-text-primary">On AI.</b>
Parts of SchOS were written with AI assistance, and that is not the measure Parts of SchOS were written with AI assistance, and that is not the measure
of anything. A project is good when the people building it understand what of anything. A project is good when the people building it understand what
was built, and bad when nobody does — whoever, or whatever, typed it. Vibe was built, and bad when nobody does — whoever, or whatever, typed it. Vibe
your way through and you get something no one can fix. Stay in the loop and your way through and you get something no one can fix. Stay in the loop and
you get a system you can explain line by line. The bug writeups in you get a system you can explain line by line. The bug writeups in
<a href="?doc=history/kernel.md">history/</a> are where that gets <a href="docs.html?doc=history/kernel.md">history/</a> are where that gets
checked, including the wrong turns. checked, including the wrong turns.
</span> </p>
</div>
</footer> </footer>
<script src="docs.js"></script> <script src="docs.js"></script>
<script src="app.js"></script>
</body> </body>
</html> </html>

View file

@ -178,7 +178,8 @@
i++; i++;
while (i < L.length && !/^\s*```/.test(L[i])) buf.push(L[i++]); while (i < L.length && !/^\s*```/.test(L[i])) buf.push(L[i++]);
i++; i++;
out.push('<pre class="code"' + (lang ? ' data-lang="' + esc(lang) + '"' : "") + out.push('<pre class="nv-code doc__pre"' +
(lang ? ' data-lang="' + esc(lang) + '"' : "") +
"><code>" + esc(buf.join("\n")) + "</code></pre>"); "><code>" + esc(buf.join("\n")) + "</code></pre>");
continue; continue;
} }
@ -187,7 +188,7 @@
if (/^\s*\|/.test(line) && /^\s*\|?[\s:|-]+$/.test(L[i + 1] || "") && /-/.test(L[i + 1] || "")) { if (/^\s*\|/.test(line) && /^\s*\|?[\s:|-]+$/.test(L[i + 1] || "") && /-/.test(L[i + 1] || "")) {
var head = cells(line); var head = cells(line);
i += 2; i += 2;
out.push('<div class="tablewrap"><table><thead><tr>'); out.push('<div class="nv-table-frame"><table class="nv-table"><thead><tr>');
head.forEach(function (c) { out.push("<th>" + inline(c, from) + "</th>"); }); head.forEach(function (c) { out.push("<th>" + inline(c, from) + "</th>"); });
out.push("</tr></thead><tbody>"); out.push("</tr></thead><tbody>");
while (i < L.length && /^\s*\|/.test(L[i])) { while (i < L.length && /^\s*\|/.test(L[i])) {
@ -244,13 +245,15 @@
function buildNav() { function buildNav() {
DOCS.forEach(function (g) { DOCS.forEach(function (g) {
var h = document.createElement("p"); var h = document.createElement("p");
h.className = "navgroup"; h.className = "nv-nav__section";
h.textContent = g.group; h.textContent = g.group;
nav.appendChild(h); nav.appendChild(h);
var ul = document.createElement("ul"); var ul = document.createElement("ul");
ul.className = "nv-nav__list";
g.items.forEach(function (it) { g.items.forEach(function (it) {
var li = document.createElement("li"); var li = document.createElement("li");
var a = document.createElement("a"); var a = document.createElement("a");
a.className = "nv-nav__item";
a.href = docHref(it[0]); a.href = docHref(it[0]);
a.textContent = it[1]; a.textContent = it[1];
a.dataset.path = it[0]; a.dataset.path = it[0];
@ -263,7 +266,8 @@
function markCurrent(path) { function markCurrent(path) {
Array.prototype.forEach.call(nav.querySelectorAll("a"), function (a) { Array.prototype.forEach.call(nav.querySelectorAll("a"), function (a) {
a.classList.toggle("on", a.dataset.path === path); if (a.dataset.path === path) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
}); });
} }
@ -285,14 +289,16 @@
if (real.length < 2) { tocEl.hidden = true; return; } if (real.length < 2) { tocEl.hidden = true; return; }
tocEl.hidden = false; tocEl.hidden = false;
var t = document.createElement("p"); var t = document.createElement("p");
t.className = "navgroup"; t.className = "nv-nav__section";
t.textContent = "On this page"; t.textContent = "On this page";
tocEl.appendChild(t); tocEl.appendChild(t);
var ul = document.createElement("ul"); var ul = document.createElement("ul");
ul.className = "nv-nav__list";
real.forEach(function (h) { real.forEach(function (h) {
var li = document.createElement("li"); var li = document.createElement("li");
li.className = "lvl" + h.level; li.className = "toc-lvl" + h.level;
var a = document.createElement("a"); var a = document.createElement("a");
a.className = "nv-nav__item";
a.href = "#" + h.id; a.href = "#" + h.id;
a.textContent = plain(h.text); a.textContent = plain(h.text);
li.appendChild(a); li.appendChild(a);
@ -334,7 +340,7 @@
"<p>Browsers block <code>fetch()</code> on <code>file://</code> URLs, so the " + "<p>Browsers block <code>fetch()</code> on <code>file://</code> URLs, so the " +
"reader cannot load the Markdown. The rest of the site works from a file; " + "reader cannot load the Markdown. The rest of the site works from a file; " +
"this page does not. Serve the repository root:</p>" + "this page does not. Serve the repository root:</p>" +
'<pre class="code"><code>cd /home/nexoniarz/Dokumenty/Projects/SchOS\n' + '<pre class="nv-code doc__pre"><code>cd /home/nexoniarz/Dokumenty/Projects/SchOS\n' +
"python3 -m http.server 8000</code></pre>" + "python3 -m http.server 8000</code></pre>" +
"<p>Then open <code>http://localhost:8000/docs/Website/docs.html</code>.</p>" + "<p>Then open <code>http://localhost:8000/docs/Website/docs.html</code>.</p>" +
'<p>Or read this one directly: <a href="../' + esc(path) + '">' + esc(path) + "</a></p>"); '<p>Or read this one directly: <a href="../' + esc(path) + '">' + esc(path) + "</a></p>");

View file

@ -1,215 +1,299 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Download — SchOS</title> <title>Download — SchOS</title>
<meta name="description" content="Get SchOS: a bootable ISO for a USB stick, what it needs, what it does on first boot, and how to build one yourself."> <meta name="description" content="Get SchOS: a bootable ISO for a USB stick, what it needs, what it does on first boot, and how to build one yourself.">
<link rel="icon" href="Assets/shift.svg" type="image/svg+xml"> <link rel="icon" href="Assets/shift.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="niveus/dist/niveus.css">
<script src="niveus/niveus/niveus.js" defer></script>
<link rel="stylesheet" href="app.css">
<script>
try {
var saved = localStorage.getItem("schos-theme");
if (saved === "system") document.documentElement.removeAttribute("data-theme");
else if (saved) document.documentElement.setAttribute("data-theme", saved);
} catch (e) { /* storage unavailable — keep the default */ }
</script>
<style>
:root { --nv-brand: #7C5CFF; }
</style>
</head> </head>
<body> <body>
<header class="top"> <nav class="nv-nav" aria-label="Main" style="--nv-selection-name: nv-schos-nav">
<div class="wrap"> <a class="nv-nav__brand" href="index.html">
<a class="brand" href="index.html"> <img class="nv-nav__icon" src="Assets/shift.svg" alt="">
<img src="Assets/shift.svg" alt="">
SchOS SchOS
</a> </a>
<a class="pride" href="#pride-note" title="SchOS stands with the LGBTQIAP+ community — every month of the year">
<ul class="nv-nav__list">
<li><a class="nv-nav__item" href="index.html">Home</a></li>
<li><a class="nv-nav__item" href="download.html" aria-current="page">Download</a></li>
<li><a class="nv-nav__item" href="docs.html">Docs</a></li>
</ul>
<span class="nv-nav__spacer"></span>
<a class="pride" href="#pride-note"
title="SchOS stands with the LGBTQIAP+ community — every month of the year">
<img src="Assets/pride.svg" alt="Progress Pride flag"> <img src="Assets/pride.svg" alt="Progress Pride flag">
</a> </a>
<nav>
<a href="index.html">Home</a> <div class="nv-button-group appearance" role="group" aria-label="Appearance">
<a href="download.html" class="on">Download</a> <button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<a href="docs.html">Docs</a> <button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="true">Dark</button>
</nav> <button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="false">Auto</button>
</div> </div>
</header> </nav>
<main id="top"> <main id="top">
<section class="hero getit"> <!-- ====================================================================== -->
<div class="wrap"> <header class="shell hero">
<div> <div class="hero__copy">
<p class="eyebrow">Download</p> <p class="nv-overline nv-text-accent">Download</p>
<h1><img class="mark" src="Assets/usb.svg" alt="">Put it on a stick</h1>
<p> <h1 class="nv-display hero__title">
<img class="hero__mark" src="Assets/usb.svg" alt="">Put it on a stick
</h1>
<p class="nv-prose">
SchOS ships as one bootable ISO. It boots on UEFI, runs entirely from SchOS ships as one bootable ISO. It boots on UEFI, runs entirely from
RAM, and touches nothing on the machine unless you tell it to — there is RAM, and touches nothing on the machine unless you tell it to — there is
no installer yet, and that is the point of trying it this way. no installer yet, and that is the point of trying it this way.
</p> </p>
<div class="cta">
<a class="btn primary" href="releases/schos.iso" download>Download the ISO</a> <div class="nv-cluster">
<a class="btn" href="#build">Build it yourself</a> <a class="nv-button nv-button--primary nv-button--lg" href="releases/schos.iso" download>Download the ISO</a>
<a class="nv-button nv-button--lg" href="#build">Build it yourself</a>
</div> </div>
<p class="filemeta">
<code>schos.iso</code> · about 62 MB · UEFI x86-64 · nothing is written <p class="nv-caption">
to your disks <code class="nv-code">schos.iso</code> · about 62 MB · UEFI x86-64 ·
nothing is written to your disks
</p> </p>
</div> </div>
<div class="getpanel"> <!-- The support table. Deliberately not a compatibility list: one Chromebox
<p class="eyebrow">Before you do</p> is the entire bare-metal record, and a longer table would be fiction. -->
<h2 class="small">This has booted on exactly one real machine</h2> <article class="nv-card nv-card--elevated">
<p> <div class="nv-card__header">
<div>
<p class="nv-overline nv-text-tertiary">Before you do</p>
<h2 class="nv-card__title">This has booted on exactly one real machine</h2>
</div>
</div>
<div class="nv-card__body nv-text-secondary">
Everything below QEMU is honest reporting rather than a compatibility Everything below QEMU is honest reporting rather than a compatibility
list. SchOS drives real hardware, and it has been run on a very small list. SchOS drives real hardware, and it has been run on a very small
number of it. number of it.
</p> </div>
<table class="support"> <div class="nv-card__body">
<tr><td>QEMU / OVMF</td><td><span class="tag runs">boots</span></td></tr> <div class="nv-table-frame">
<tr><td>Asus Chromebox <span class="dimtext">Guado CN62</span></td><td><span class="tag partial">under test</span></td></tr> <table class="nv-table nv-table--compact">
<tr><td>Anything else</td><td><span class="tag planned">unknown</span></td></tr> <tbody>
<tr>
<td>QEMU / OVMF</td>
<td class="nv-table__actions"><span class="nv-badge nv-badge--success">boots</span></td>
</tr>
<tr>
<td>Asus Chromebox <span class="nv-text-tertiary">Guado CN62</span></td>
<td class="nv-table__actions"><span class="nv-badge nv-badge--warning">under test</span></td>
</tr>
<tr>
<td>Anything else</td>
<td class="nv-table__actions"><span class="nv-badge">unknown</span></td>
</tr>
</tbody>
</table> </table>
<p class="fineprint"> </div>
</div>
<div class="nv-card__footer nv-card__footer--plain">
<p class="nv-caption nv-text-tertiary">
If it does not boot, the kernel log is on the screen from its first line If it does not boot, the kernel log is on the screen from its first line
— that is deliberate, and it is there because of this machine. A photo of — that is deliberate, and it is there because of this machine. A photo of
where it stops is a genuinely useful bug report. where it stops is a genuinely useful bug report.
</p> </p>
</div> </div>
</div> </article>
</section> </header>
<section id="write" class="reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section id="write" class="shell section" data-reveal>
<p class="eyebrow">Writing it</p> <div class="section__head">
<h2>Two ways onto a stick</h2> <p class="nv-overline nv-text-accent">Writing it</p>
<p class="lede"> <h2 class="nv-heading">Two ways onto a stick</h2>
<p class="nv-prose nv-text-secondary">
Ventoy first, because it is the one that does not cost you the stick. An Ventoy first, because it is the one that does not cost you the stick. An
ISO copied onto a Ventoy drive sits next to every other ISO on it, and the ISO copied onto a Ventoy drive sits next to every other ISO on it, and the
drive stays an ordinary drive. drive stays an ordinary drive.
</p> </p>
</div>
<div class="cards two"> <div class="needs">
<div class="card"> <article class="nv-card">
<span class="tag runs">recommended</span> <div class="nv-card__header">
<h3>Ventoy</h3> <h3 class="nv-card__title">Ventoy</h3>
<p> <span class="nv-badge nv-badge--success">recommended</span>
</div>
<div class="nv-card__body nv-text-secondary">
Copy the file. That is the whole procedure — Ventoy finds it and offers Copy the file. That is the whole procedure — Ventoy finds it and offers
it in its menu at boot, and the stick remains usable for everything it in its menu at boot, and the stick remains usable for everything
else. else.
</p> </div>
<pre class="cmd"><span class="p">$</span> cp schos.iso /run/media/<span class="v">you</span>/Ventoy/</pre> <div class="nv-card__body">
<p class="fineprint"> <pre class="nv-code cmd"><span class="nv-text-tertiary">$</span> cp schos.iso /run/media/<span class="nv-text-accent">you</span>/Ventoy/</pre>
</div>
<div class="nv-card__footer nv-card__footer--plain">
<p class="nv-caption nv-text-tertiary">
This is how SchOS is actually tested on hardware. No optical drive has This is how SchOS is actually tested on hardware. No optical drive has
been involved at any point. been involved at any point.
</p> </p>
</div> </div>
</article>
<div class="card"> <article class="nv-card">
<span class="tag partial">destructive</span> <div class="nv-card__header">
<h3><code>dd</code></h3> <h3 class="nv-card__title"><code class="nv-code">dd</code></h3>
<p> <span class="nv-badge nv-badge--danger">destructive</span>
</div>
<div class="nv-card__body nv-text-secondary">
Writes the image over the whole device. <b>Check the device name Writes the image over the whole device. <b>Check the device name
twice</b> — there is no confirmation and no undo, and the wrong letter twice</b> — there is no confirmation and no undo, and the wrong letter
takes a disk with it. takes a disk with it.
</p> </div>
<pre class="cmd"><span class="p">$</span> lsblk <div class="nv-card__body">
<span class="p">$</span> sudo dd if=schos.iso of=/dev/<span class="v">sdX</span> \ <!-- Split across two lines on purpose: it was one long line first, and the
card clipped it at conv=. A destructive command that can scroll half of
itself out of view is a good way to take somebody's disk. -->
<pre class="nv-code cmd"><span class="nv-text-tertiary">$</span> lsblk
<span class="nv-text-tertiary">$</span> sudo dd if=schos.iso of=/dev/<span class="nv-text-accent">sdX</span> \
bs=4M status=progress conv=fsync</pre> bs=4M status=progress conv=fsync</pre>
<p class="fineprint"> </div>
<code>of=</code> is the whole device, not a partition — <code>sdX</code> <div class="nv-card__footer nv-card__footer--plain">
and never <code>sdX1</code>. <p class="nv-caption nv-text-tertiary">
<code class="nv-code">of=</code> is the whole device, not a partition —
<code class="nv-code">sdX</code> and never <code class="nv-code">sdX1</code>.
</p> </p>
</div> </div>
</div> </article>
</div> </div>
</section> </section>
<section id="firstboot" class="reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section id="firstboot" class="shell section" data-reveal>
<p class="eyebrow">First boot</p> <div class="section__head">
<h2>What actually happens</h2> <p class="nv-overline nv-text-accent">First boot</p>
<p class="lede"> <h2 class="nv-heading">What actually happens</h2>
<p class="nv-prose nv-text-secondary">
Written out because a system you have never seen before gives you no way to Written out because a system you have never seen before gives you no way to
tell "working" from "stuck", and the difference is worth knowing in advance. tell "working" from "stuck", and the difference is worth knowing in advance.
</p> </p>
<div class="origin-grid">
<div class="step">
<span class="when">1</span>
<h3>The loader</h3>
<p>
A splash, and <b>F8</b> for boot options. It is a PE/COFF loader built
with clang and lld-link; there is no BIOS path, so the firmware has to
be in UEFI mode.
</p>
</div> </div>
<div class="step">
<span class="when">2</span> <!-- Numbered because this is a sequence: each step only makes sense after
<h3>The kernel log</h3> the one above it has happened. -->
<p> <ol class="timeline">
<li class="timeline__step">
<span class="nv-overline timeline__when">Step one</span>
<h3 class="nv-title">The loader</h3>
<p class="nv-body nv-text-secondary">
A splash, and <b>F8</b> for boot options — including the resolution the
whole machine will come up in. It is a PE/COFF loader built with clang
and lld-link; there is no BIOS path, so the firmware has to be in UEFI
mode.
</p>
</li>
<li class="timeline__step">
<span class="nv-overline timeline__when">Step two</span>
<h3 class="nv-title">The kernel log</h3>
<p class="nv-body nv-text-secondary">
On screen, from the first line that can draw. Cores, the PCI bus, the On screen, from the first line that can draw. Cores, the PCI bus, the
network card, the filesystem. If it stops, it stops somewhere you can network card, the filesystem. If it stops, it stops somewhere you can
read. read.
</p> </p>
</div> </li>
<div class="step"> <li class="timeline__step">
<span class="when">3</span> <span class="nv-overline timeline__when">Step three</span>
<h3>A shell</h3> <h3 class="nv-title">A shell</h3>
<p> <p class="nv-body nv-text-secondary">
<code>schinit</code> starts one on the console and a second on <code class="nv-code">schinit</code> starts one on the console and a
<code>tty2</code>. <code>mksh</code> is <code>/bin/sh</code>, with second on <code class="nv-code">tty2</code>.
<code>zsh</code> beside it and the real toybox userland behind both. <code class="nv-code">mksh</code> is <code class="nv-code">/bin/sh</code>,
with <code class="nv-code">zsh</code> beside it and the real toybox
userland behind both.
</p> </p>
</div> </li>
<div class="step"> <li class="timeline__step">
<span class="when">4</span> <span class="nv-overline timeline__when">Step four</span>
<h3>Six terminals</h3> <h3 class="nv-title">Six terminals</h3>
<p> <p class="nv-body nv-text-secondary">
<code>AltGr+F1</code>…<code>F6</code> switches between them — the one <code class="nv-code">AltGr+F1</code>…<code class="nv-code">F6</code>
chord a Linux host does not take for itself — or <code>chvt N</code> switches between them — the one chord a Linux host does not take for
from a shell. itself — or <code class="nv-code">chvt N</code> from a shell.
</p> </p>
</div> </li>
</div> </ol>
<p class="seam-note"> <p class="nv-body nv-text-tertiary section__note">
<b>Nothing is written to your disks and nothing persists.</b> The root <b>Nothing is written to your disks and nothing persists.</b> The root
filesystem is in RAM; the machine is exactly as you left it after a reboot, filesystem is in RAM; the machine is exactly as you left it after a reboot,
including the parts of it you were hoping to keep. There is no installer — including the parts of it you were hoping to keep. There is no installer —
<a href="docs.html?doc=plan/1.0.md">1.0</a> is where that starts. <a href="docs.html?doc=plan/1.0.md">1.0</a> is where that starts.
</p> </p>
</div>
</section> </section>
<section id="build" class="reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section id="build" class="shell section" data-reveal>
<p class="eyebrow">Or build it</p> <div class="section__head">
<h2>Clone it, build it, boot it</h2> <p class="nv-overline nv-text-accent">Or build it</p>
<p class="lede"> <h2 class="nv-heading">Clone it, build it, boot it</h2>
The toolchain is one file. On NixOS <code>shell.nix</code> brings the whole <p class="nv-prose nv-text-secondary">
thing in; elsewhere it is clang, lld, GNU make and a host compiler — see The toolchain is one file. On NixOS <code class="nv-code">shell.nix</code>
<a href="docs.html?doc=guides/building.md">the build guide</a>. brings the whole thing in; elsewhere it is clang, lld, GNU make and a host
</p> compiler — see <a href="docs.html?doc=guides/building.md">the build guide</a>.
<pre class="cmd block"><span class="p">$</span> git clone &lt;the repository&gt; schos &amp;&amp; cd schos
<span class="p">$</span> nix-shell <span class="c"># NixOS only; elsewhere see the build guide</span>
<span class="p">$</span> make iso <span class="c"># produces schos.iso</span>
<span class="p">$</span> make qemu <span class="c"># or just run it, without a stick</span></pre>
<p class="seam-note">
<code>make qemu</code> is the one to reach for first. Everything on this
page works there, it takes seconds rather than a reboot, and a serial
console beats a photograph of a screen.
</p> </p>
</div> </div>
<div class="nv-card nv-card--inset">
<div class="nv-card__body">
<pre class="nv-code cmd"><span class="nv-text-tertiary">$</span> git clone &lt;the repository&gt; schos &amp;&amp; cd schos
<span class="nv-text-tertiary">$</span> nix-shell <span class="nv-text-tertiary"># NixOS only; elsewhere see the build guide</span>
<span class="nv-text-tertiary">$</span> make iso <span class="nv-text-tertiary"># produces schos.iso</span>
<span class="nv-text-tertiary">$</span> make qemu <span class="nv-text-tertiary"># or just run it, without a stick</span></pre>
</div>
</div>
<p class="nv-body nv-text-tertiary section__note">
<code class="nv-code">make qemu</code> is the one to reach for first.
Everything on this page works there, it takes seconds rather than a reboot,
and a serial console beats a photograph of a screen.
</p>
</section> </section>
</main> </main>
<footer> <footer class="shell site-footer">
<div class="wrap"> <div class="nv-divider"></div>
<span>SchOS — kernel <b>SchIFT</b>, filesystem <b>SchFS</b>.</span> <div class="site-footer__row">
<span class="mono">Apache-2.0, with the xv6-derived core under MIT.</span> <span class="nv-body">SchOS — kernel <b>SchIFT</b>, filesystem <b>SchFS</b>.</span>
<span class="pride-note" id="pride-note"> <span class="nv-code nv-text-tertiary">Apache-2.0, with the xv6-derived core under MIT.</span>
</div>
<p class="nv-body nv-text-secondary pride-note" id="pride-note">
<img src="Assets/pride.svg" alt=""> <img src="Assets/pride.svg" alt="">
Built by, and for, people of every orientation and gender identity. Built by, and for, people of every orientation and gender identity.
The flag stays up all year. The flag stays up all year.
</span> </p>
<span class="note" id="ai-note">
<b>On AI.</b> <p class="nv-body nv-text-secondary nv-prose" id="ai-note">
<b class="nv-text-primary">On AI.</b>
Parts of SchOS were written with AI assistance, and that is not the measure Parts of SchOS were written with AI assistance, and that is not the measure
of anything. A project is good when the people building it understand what of anything. A project is good when the people building it understand what
was built, and bad when nobody does — whoever, or whatever, typed it. Vibe was built, and bad when nobody does — whoever, or whatever, typed it. Vibe
@ -217,8 +301,7 @@
you get a system you can explain line by line. The bug writeups in you get a system you can explain line by line. The bug writeups in
<a href="docs.html?doc=history/kernel.md">history/</a> are where that gets <a href="docs.html?doc=history/kernel.md">history/</a> are where that gets
checked, including the wrong turns. checked, including the wrong turns.
</span> </p>
</div>
</footer> </footer>
<script src="app.js"></script> <script src="app.js"></script>

View file

@ -1,442 +1,638 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="dark">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SchOS — a 64-bit operating system written from scratch</title> <title>SchOS — a 64-bit operating system written from scratch</title>
<meta name="description" content="SchOS is a from-scratch x86-64 operating system: the SchIFT kernel, the SchFS copy-on-write filesystem, a UEFI loader, and a userland that runs unmodified musl and toybox."> <meta name="description" content="SchOS is a from-scratch x86-64 operating system: the SchIFT kernel, the SchFS copy-on-write filesystem, a UEFI loader, and a userland that runs unmodified musl and toybox.">
<link rel="icon" href="Assets/shift.svg" type="image/svg+xml"> <link rel="icon" href="Assets/shift.svg" type="image/svg+xml">
<link rel="stylesheet" href="style.css">
<!-- Niveus, and its optional continuity helpers. -->
<link rel="stylesheet" href="niveus/dist/niveus.css">
<script src="niveus/niveus/niveus.js" defer></script>
<!-- This site's own layout. No colours, no shadows, no type sizes. -->
<link rel="stylesheet" href="app.css">
<script>
/* Before first paint, so the page never renders in one appearance and then
corrects itself. Dark is the default because the OS is dark; the toggle
is real, and what it chooses is remembered. */
try {
var saved = localStorage.getItem("schos-theme");
if (saved === "system") document.documentElement.removeAttribute("data-theme");
else if (saved) document.documentElement.setAttribute("data-theme", saved);
} catch (e) { /* storage unavailable — keep the default */ }
</script>
<!-- The whole identity, and it is not a design decision: SCH_ACCENT, copied
from include/theme.h. A site describing an operating system should look
like the operating system. -->
<style>
:root { --nv-brand: #7C5CFF; }
</style>
</head> </head>
<body> <body>
<header class="top"> <nav class="nv-nav" id="topnav" aria-label="Main" style="--nv-selection-name: nv-schos-nav">
<div class="wrap"> <a class="nv-nav__brand" href="#top">
<a class="brand" href="#top"> <img class="nv-nav__icon" src="Assets/shift.svg" alt="">
<img src="Assets/shift.svg" alt="">
SchOS SchOS
</a> </a>
<a class="pride" href="#pride-note" title="SchOS stands with the LGBTQIAP+ community — every month of the year">
<ul class="nv-nav__list">
<li><a class="nv-nav__item" href="#origin">Origin</a></li>
<li><a class="nv-nav__item" href="#stack">The stack</a></li>
<li><a class="nv-nav__item" href="#what">What it has</a></li>
<li><a class="nv-nav__item" href="#hybrid">Hybrid</a></li>
<li><a class="nv-nav__item" href="#numbers">Numbers</a></li>
</ul>
<span class="nv-nav__spacer"></span>
<a class="pride" href="#pride-note"
title="SchOS stands with the LGBTQIAP+ community — every month of the year">
<img src="Assets/pride.svg" alt="Progress Pride flag"> <img src="Assets/pride.svg" alt="Progress Pride flag">
</a> </a>
<nav>
<a href="#origin">Origin</a> <div class="nv-button-group appearance" role="group" aria-label="Appearance">
<a href="#stack">The stack</a> <button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<a href="#what">What it has</a> <button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="true">Dark</button>
<a href="#hybrid">Hybrid</a> <button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="false">Auto</button>
<a href="#numbers">Numbers</a>
<a href="download.html">Download</a>
<a href="docs.html">Docs</a>
</nav>
</div> </div>
</header>
<a class="nv-button nv-button--sm" href="docs.html">Docs</a>
<a class="nv-button nv-button--sm nv-button--primary" href="download.html">Download</a>
</nav>
<main id="top"> <main id="top">
<section class="hero"> <!-- ====================================================================== -->
<div class="wrap"> <header class="shell hero">
<div> <div class="hero__copy">
<h1><img class="mark" src="Assets/shift.svg" alt="">SchOS</h1> <span class="nv-badge nv-badge--accent">
<p> <span class="nv-badge__dot"></span>
SchIFT 0.1.0 · boots on real hardware
</span>
<h1 class="nv-display hero__title">
<img class="hero__mark" src="Assets/shift.svg" alt="">SchOS
</h1>
<p class="nv-prose">
A 64-bit operating system written from scratch — its own kernel, its own A 64-bit operating system written from scratch — its own kernel, its own
filesystem, its own bootloader. It boots on UEFI, drives real hardware, filesystem, its own bootloader. It boots on UEFI, drives real hardware,
and runs unmodified <code>musl</code> and <code>toybox</code> on top. and runs unmodified <code class="nv-code">musl</code> and
<code class="nv-code">toybox</code> on top.
</p> </p>
<div class="cta">
<a class="btn primary" href="download.html">Download it</a> <div class="nv-cluster">
<a class="btn" href="#what">What it has</a> <a class="nv-button nv-button--primary nv-button--lg" href="download.html">Download it</a>
</div> <a class="nv-button nv-button--lg" href="#what">What it has</a>
</div> </div>
<div class="console" aria-label="Boot log"> <p class="nv-caption">Apache-2.0 · runs from RAM · writes nothing to your disks</p>
<div class="console-bar">
<i class="dot live"></i> ttyS0
<span>SchIFT</span>
</div> </div>
<pre id="bootlog" aria-live="polite"></pre>
</div>
</div>
</section>
<section id="origin" class="reveal"> <div class="nv-card nv-card--flush console" aria-label="Boot log">
<div class="wrap"> <div class="console__bar">
<p class="eyebrow">Origin</p> <span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>ttyS0</span>
<h2>It started as a teaching kernel and stopped being one</h2> <span class="nv-caption nv-text-tertiary">SchIFT</span>
<p class="lede"> </div>
<pre class="nv-code console__log" id="bootlog" aria-live="polite"></pre>
</div>
</header>
<!-- ====================================================================== -->
<section id="origin" class="shell section" data-reveal>
<div class="section__head">
<p class="nv-overline nv-text-accent">Origin</p>
<h2 class="nv-heading">It started as a teaching kernel and stopped being one</h2>
<p class="nv-prose nv-text-secondary">
SchOS began as <a href="https://github.com/mit-pdos/xv6-public">xv6</a>, MIT's SchOS began as <a href="https://github.com/mit-pdos/xv6-public">xv6</a>, MIT's
teaching operating system: 32-bit, single core, one filesystem, a few dozen teaching operating system: 32-bit, single core, one filesystem, a few dozen
syscalls. Almost none of that survives. What follows is what replaced it. syscalls. Almost none of that survives. What follows is what replaced it.
</p> </p>
</div>
<div class="origin-grid"> <!-- A timeline, not a grid: these happened in this order, and the order is
<div class="step"> the point. The rail is what carries that; the labels are its steps. -->
<span class="when">First</span> <ol class="timeline">
<h3>64-bit, and more than one core</h3> <li class="timeline__step">
<p> <span class="nv-overline timeline__when">First</span>
<h3 class="nv-title">64-bit, and more than one core</h3>
<p class="nv-body nv-text-secondary">
Four-level paging, a real SMP bring-up, per-CPU state and a trap path Four-level paging, a real SMP bring-up, per-CPU state and a trap path
that survives both. This is the part that invalidates most of what a that survives both. This is the part that invalidates most of what a
32-bit uniprocessor kernel assumes. 32-bit uniprocessor kernel assumes.
</p> </p>
</div> </li>
<div class="step"> <li class="timeline__step">
<span class="when">Then</span> <span class="nv-overline timeline__when">Then</span>
<h3>Its own filesystem</h3> <h3 class="nv-title">Its own filesystem</h3>
<p> <p class="nv-body nv-text-secondary">
xv6's log-structured filesystem was replaced by <b>SchFS</b>: a xv6's log-structured filesystem was replaced by <b>SchFS</b>: a
copy-on-write B-tree with an uberblock ring, extents and checksummed copy-on-write B-tree with an uberblock ring, extents and checksummed
metadata. A crash leaves either the old tree or the new one. metadata. A crash leaves either the old tree or the new one.
</p> </p>
</div> </li>
<div class="step"> <li class="timeline__step">
<span class="when">Then</span> <span class="nv-overline timeline__when">Then</span>
<h3>A Linux-shaped ABI</h3> <h3 class="nv-title">A Linux-shaped ABI</h3>
<p> <p class="nv-body nv-text-secondary">
The syscall table adopted Linux numbering, which is what makes an The syscall table adopted Linux numbering, which is what makes an
unmodified musl and an unmodified toybox run. The table is one unmodified musl and an unmodified toybox run. The table is one
<code>.inc</code> file, and it is the seam the whole system is built around. <code class="nv-code">.inc</code> file, and it is the seam the whole
system is built around.
</p> </p>
</div> </li>
<div class="step"> <li class="timeline__step">
<span class="when">Then</span> <span class="nv-overline timeline__when">Then</span>
<h3>Hardware, not emulation</h3> <h3 class="nv-title">Hardware, not emulation</h3>
<p> <p class="nv-body nv-text-secondary">
A UEFI loader, xHCI and USB storage, an e1000 with a real TCP/IP stack, A UEFI loader, xHCI and USB storage, an e1000 with a real TCP/IP stack,
virtio-gpu and VBE framebuffers, and a device model where a driver never virtio-gpu and VBE framebuffers, and a device model where a driver never
names its consumer. names its consumer.
</p> </p>
</div> </li>
<div class="step"> <li class="timeline__step">
<span class="when">Now</span> <span class="nv-overline timeline__when">Now</span>
<h3>Drivers that are not in the kernel</h3> <h3 class="nv-title">Drivers that are not in the kernel</h3>
<p> <p class="nv-body nv-text-secondary">
A process can be handed a PCI device — its registers, its memory, its A process can be handed a PCI device — its registers, its memory, its
interrupts — and drive it. The GPU driver already runs that way. Nobody interrupts — and drive it. The GPU driver already runs that way. Nobody
writes a driver for every device that exists; the answer is to stop writes a driver for every device that exists; the answer is to stop
requiring that they live inside. requiring that they live inside.
</p> </p>
</div> </li>
</div> </ol>
</div>
</section> </section>
<section id="stack" class="reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section id="stack" class="shell section" data-reveal>
<p class="eyebrow">Architecture</p> <div class="section__head">
<h2>Three seams hold the whole thing together</h2> <p class="nv-overline nv-text-accent">Architecture</p>
<p class="lede"> <h2 class="nv-heading">Three seams hold the whole thing together</h2>
<p class="nv-prose nv-text-secondary">
Above the syscall table, software is ordinary and portable. Below the device Above the syscall table, software is ordinary and portable. Below the device
model, a driver never names its consumer and a consumer never names a driver. model, a driver never names its consumer and a consumer never names a driver.
And at <code>/dev/pci</code>, a driver stops needing to be in the kernel at And at <code class="nv-code">/dev/pci</code>, a driver stops needing to be in
all. Everything else is arrangement. the kernel at all. Everything else is arrangement.
</p>
<div class="stack">
<div class="layer"><span class="name">userland</span><span class="what">toybox, mksh, zsh, musl, and the first-party programs in <code>cmd/</code></span></div>
<div class="layer"><span class="name">userspace drivers</span><span class="what"><code>vgpud</code> — a driver that is an ordinary process</span></div>
<div class="layer"><span class="name">schinit</span><span class="what">PID 1 — job manifests, supervision, reincarnation</span></div>
<div class="layer seam"><span class="name">syscall table</span><span class="what">Linux numbering, one <code>.inc</code> file — the ABI seam</span></div>
<div class="layer"><span class="name">kernel services</span><span class="what">VFS, sockets and <code>SCM_RIGHTS</code>, signals, mmap, poll, procfs</span></div>
<div class="layer"><span class="name">subsystems</span><span class="what">SchFS, lwIP, virtual terminals, graphics, the buffer cache</span></div>
<div class="layer seam"><span class="name">device model</span><span class="what">class vtables and bus binding — the driver seam</span></div>
<div class="layer seam"><span class="name">/dev/pci</span><span class="what">registers, DMA and interrupts to a process — the eviction seam</span></div>
<div class="layer"><span class="name">drivers</span><span class="what">xHCI, e1000, virtio-gpu, VBE, IDE, PS/2</span></div>
<div class="layer"><span class="name">SchIFT core</span><span class="what">scheduler, paging, traps, IRQ, SMP, DMA</span></div>
<div class="layer"><span class="name">boot</span><span class="what">the UEFI loader in <code>boot/uefi</code></span></div>
</div>
<p class="seam-note">
<code>uname -s</code> reports <b>SchIFT</b> — the kernel — the way every Unix
reports its kernel rather than its distribution.
</p> </p>
</div> </div>
<div class="nv-card nv-card--flush stack">
<div class="stack__layer">
<span class="nv-label stack__name">userland</span>
<span class="nv-body nv-text-secondary">toybox, mksh, zsh, musl, and the first-party programs in <code class="nv-code">cmd/</code></span>
</div>
<div class="stack__layer">
<span class="nv-label stack__name">userspace drivers</span>
<span class="nv-body nv-text-secondary"><code class="nv-code">vgpud</code> — a driver that is an ordinary process</span>
</div>
<div class="stack__layer">
<span class="nv-label stack__name">schinit</span>
<span class="nv-body nv-text-secondary">PID 1 — job manifests, supervision, reincarnation</span>
</div>
<div class="stack__layer stack__layer--seam">
<span class="nv-label stack__name">syscall table<span class="nv-badge nv-badge--accent nv-badge--square">seam</span></span>
<span class="nv-body nv-text-secondary">Linux numbering, one <code class="nv-code">.inc</code> file — the ABI seam</span>
</div>
<div class="stack__layer">
<span class="nv-label stack__name">kernel services</span>
<span class="nv-body nv-text-secondary">VFS, sockets and <code class="nv-code">SCM_RIGHTS</code>, signals, mmap, poll, procfs</span>
</div>
<div class="stack__layer">
<span class="nv-label stack__name">subsystems</span>
<span class="nv-body nv-text-secondary">SchFS, lwIP, virtual terminals, graphics, the buffer cache</span>
</div>
<div class="stack__layer stack__layer--seam">
<span class="nv-label stack__name">device model<span class="nv-badge nv-badge--accent nv-badge--square">seam</span></span>
<span class="nv-body nv-text-secondary">class vtables and bus binding — the driver seam</span>
</div>
<div class="stack__layer stack__layer--seam">
<span class="nv-label stack__name">/dev/pci<span class="nv-badge nv-badge--accent nv-badge--square">seam</span></span>
<span class="nv-body nv-text-secondary">registers, DMA and interrupts to a process — the eviction seam</span>
</div>
<div class="stack__layer">
<span class="nv-label stack__name">drivers</span>
<span class="nv-body nv-text-secondary">xHCI, e1000, virtio-gpu, VBE, IDE, PS/2</span>
</div>
<div class="stack__layer">
<span class="nv-label stack__name">SchIFT core</span>
<span class="nv-body nv-text-secondary">scheduler, paging, traps, IRQ, SMP, DMA</span>
</div>
<div class="stack__layer">
<span class="nv-label stack__name">boot</span>
<span class="nv-body nv-text-secondary">the UEFI loader in <code class="nv-code">boot/uefi</code></span>
</div>
</div>
<p class="nv-body nv-text-tertiary section__note">
<code class="nv-code">uname -s</code> reports <b>SchIFT</b> — the kernel — the
way every Unix reports its kernel rather than its distribution.
</p>
</section> </section>
<section id="what" class="reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section id="what" class="shell section" data-reveal>
<p class="eyebrow">What it has</p> <div class="section__head">
<h2>Built and running, and honest about the rest</h2> <p class="nv-overline nv-text-accent">What it has</p>
<p class="lede"> <h2 class="nv-heading">Built and running, and honest about the rest</h2>
<p class="nv-prose nv-text-secondary">
Every card marked <b>runs</b> is in the tree and works today. The tags are Every card marked <b>runs</b> is in the tree and works today. The tags are
not aspirational; the things that are not finished say so. not aspirational; the things that are not finished say so.
</p> </p>
</div>
<div class="cards"> <div class="nv-card-grid">
<div class="card"> <article class="nv-card">
<span class="tag runs">runs</span> <div class="nv-card__header">
<h3><img class="cardmark" src="Assets/uberblock.svg" alt="">SchFS</h3> <h3 class="nv-card__title"><img class="cardmark" src="Assets/uberblock.svg" alt="">SchFS</h3>
<p> <span class="nv-badge nv-badge--success">runs</span>
</div>
<div class="nv-card__body nv-text-secondary">
Copy-on-write B-tree, a 128-slot uberblock ring, extents with refcounts Copy-on-write B-tree, a 128-slot uberblock ring, extents with refcounts
and reflink, CRC32c on metadata, and free space derived from the tree and reflink, CRC32c on metadata, and free space derived from the tree
rather than stored beside it — so there is no second structure to rather than stored beside it — so there is no second structure to
disagree with the first. There is nothing to repair after a crash. disagree with the first. There is nothing to repair after a crash.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag runs">runs</span>
<h3>SMP x86-64 kernel</h3> <article class="nv-card">
<p> <div class="nv-card__header">
<h3 class="nv-card__title">SMP x86-64 kernel</h3>
<span class="nv-badge nv-badge--success">runs</span>
</div>
<div class="nv-card__body nv-text-secondary">
Four-level paging, multiple cores, preemptive scheduling, a calibrated Four-level paging, multiple cores, preemptive scheduling, a calibrated
LAPIC timer, real signal delivery and job control. 205 syscalls on LAPIC timer, real signal delivery and job control. 207 syscalls on
Linux numbering. Linux numbering.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag runs">runs</span>
<h3>Networking</h3> <article class="nv-card">
<p> <div class="nv-card__header">
<h3 class="nv-card__title">Networking</h3>
<span class="nv-badge nv-badge--success">runs</span>
</div>
<div class="nv-card__body nv-text-secondary">
An e1000 driver under lwIP, with sockets for AF_INET, AF_UNIX and An e1000 driver under lwIP, with sockets for AF_INET, AF_UNIX and
AF_NETLINK. DHCP brings the interface up at boot; <code>ping</code> AF_NETLINK. DHCP brings the interface up at boot;
reaches the outside world. <code class="nv-code">ping</code> reaches the outside world.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag runs">runs</span>
<h3>UEFI boot</h3> <article class="nv-card">
<p> <div class="nv-card__header">
<h3 class="nv-card__title">UEFI boot</h3>
<span class="nv-badge nv-badge--success">runs</span>
</div>
<div class="nv-card__body nv-text-secondary">
A PE/COFF loader built with clang and lld-link that sets up its own page A PE/COFF loader built with clang and lld-link that sets up its own page
tables and hands the kernel a framebuffer. No BIOS path, no chainloading. tables and hands the kernel a framebuffer. No BIOS path, no chainloading.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag runs">runs</span>
<h3>USB</h3> <article class="nv-card">
<p> <div class="nv-card__header">
<h3 class="nv-card__title">USB</h3>
<span class="nv-badge nv-badge--success">runs</span>
</div>
<div class="nv-card__body nv-text-secondary">
xHCI, hubs, HID and mass storage — a keyboard and a USB disk that the xHCI, hubs, HID and mass storage — a keyboard and a USB disk that the
kernel can mount, through the same device model as everything else. kernel can mount, through the same device model as everything else.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag runs">runs</span>
<h3>Virtual terminals</h3> <article class="nv-card">
<p> <div class="nv-card__header">
<h3 class="nv-card__title">Virtual terminals</h3>
<span class="nv-badge nv-badge--success">runs</span>
</div>
<div class="nv-card__body nv-text-secondary">
Six of them, each with its own cell grid, scrollback and foreground Six of them, each with its own cell grid, scrollback and foreground
job — switched with <code>AltGr+F1</code>…<code>F6</code>, which is the job — switched with <code class="nv-code">AltGr+F1</code>…<code class="nv-code">F6</code>,
one chord a Linux host does not take for itself. The console draws to which is the one chord a Linux host does not take for itself. The console
whichever framebuffer is present, from the kernel's first line. draws to whichever framebuffer is present, from the kernel's first line.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag runs">runs</span>
<h3>schinit</h3> <article class="nv-card">
<p> <div class="nv-card__header">
<h3 class="nv-card__title">schinit</h3>
<span class="nv-badge nv-badge--success">runs</span>
</div>
<div class="nv-card__body nv-text-secondary">
PID 1 does almost nothing: it forks a supervisor, spawns jobs on its PID 1 does almost nothing: it forks a supervisor, spawns jobs on its
behalf, and reaps. The supervisor holds the manifests and the respawn behalf, and reaps. The supervisor holds the manifests and the respawn
policy, and if it dies PID 1 replaces it and hands back the children it policy, and if it dies PID 1 replaces it and hands back the children it
was already watching. PID 1 can re-exec itself without dropping them. was already watching. PID 1 can re-exec itself without dropping them.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag runs">runs</span>
<h3>A real userland</h3> <article class="nv-card">
<p> <div class="nv-card__header">
musl and toybox, both unmodified, plus mksh as <code>/bin/sh</code>. <h3 class="nv-card__title">A real userland</h3>
<span class="nv-badge nv-badge--success">runs</span>
</div>
<div class="nv-card__body nv-text-secondary">
musl and toybox, both unmodified, plus mksh as <code class="nv-code">/bin/sh</code>.
<b>fastfetch</b> builds from upstream source with an empty <b>fastfetch</b> builds from upstream source with an empty
<code>patches/</code> directory — when it could not detect something, the <code class="nv-code">patches/</code> directory — when it could not detect
answer was to give the kernel what it was looking for. something, the answer was to give the kernel what it was looking for.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag runs">runs</span>
<h3>Drivers outside the kernel</h3> <article class="nv-card">
<p> <div class="nv-card__header">
<code>/dev/pci</code> hands a process a device: its registers by <h3 class="nv-card__title">Drivers outside the kernel</h3>
<code>mmap</code>, physically contiguous memory it can point the device <span class="nv-badge nv-badge--success">runs</span>
at, and interrupts as something to <code>read()</code>. The virtio-gpu
driver already lives out there. <a href="#hybrid">How that works →</a>
</p>
</div> </div>
<div class="card"> <div class="nv-card__body nv-text-secondary">
<span class="tag partial">in progress</span> <code class="nv-code">/dev/pci</code> hands a process a device: its
<h3>Schcom</h3> registers by <code class="nv-code">mmap</code>, physically contiguous
<p> memory it can point the device at, and interrupts as something to
<code class="nv-code">read()</code>. The virtio-gpu driver already lives
out there.
</div>
<div class="nv-card__footer nv-card__footer--plain">
<a class="nv-button nv-button--ghost nv-button--sm" href="#hybrid">How that works →</a>
</div>
</article>
<article class="nv-card">
<div class="nv-card__header">
<h3 class="nv-card__title">Schcom</h3>
<span class="nv-badge nv-badge--warning">in progress</span>
</div>
<div class="nv-card__body nv-text-secondary">
The compositor that will own the display. v0 runs, and descriptor The compositor that will own the display. v0 runs, and descriptor
passing — the one thing a Wayland client cannot do without — now passing — the one thing a Wayland client cannot do without — now
exists. What a buffer still needs is a shareable memory object. exists. What a buffer still needs is a shareable memory object.
</p>
</div> </div>
<div class="card"> </article>
<span class="tag planned">designed</span>
<h3>SchAPI</h3> <article class="nv-card">
<p> <div class="nv-card__header">
<h3 class="nv-card__title">SchAPI</h3>
<span class="nv-badge">designed</span>
</div>
<div class="nv-card__body nv-text-secondary">
The native programming interface — specified, not built, and deliberately The native programming interface — specified, not built, and deliberately
so. It should be designed <i>from</i> the compositor's experience rather so. It should be designed <i>from</i> the compositor's experience rather
than before it. than before it.
</p>
</div>
</div> </div>
</article>
</div> </div>
</section> </section>
<section id="hybrid" class="reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section id="hybrid" class="shell section" data-reveal>
<p class="eyebrow">Hybrid</p> <div class="section__head">
<h2>A driver can leave the kernel</h2> <p class="nv-overline nv-text-accent">Hybrid</p>
<p class="lede"> <h2 class="nv-heading">A driver can leave the kernel</h2>
<p class="nv-prose nv-text-secondary">
Nobody writes a driver for every device that exists — not a project this Nobody writes a driver for every device that exists — not a project this
size, and not any project. So the interesting question is not how to write size, and not any project. So the interesting question is not how to write
more of them, but what a driver actually needs from a kernel, and whether it more of them, but what a driver actually needs from a kernel, and whether it
can have that from outside. can have that from outside.
</p> </p>
<div class="origin-grid">
<div class="step">
<span class="when">It needs</span>
<h3>Its registers</h3>
<p>
<code>mmap</code> on <code>/dev/pci</code>, where the offset is the
physical address of a BAR. The kernel refuses any offset that is not
inside a BAR of a device this process was given, which is what stops it
being <code>/dev/mem</code> with extra steps.
</p>
</div> </div>
<div class="step">
<span class="when">It needs</span> <div class="needs">
<h3>Memory the device can reach</h3> <article class="nv-card nv-card--inset">
<p> <div class="nv-card__header">
<div class="nv-stack nv-stack--tight">
<p class="nv-overline nv-text-tertiary">It needs</p>
<h3 class="nv-card__title">Its registers</h3>
</div>
</div>
<div class="nv-card__body nv-text-secondary">
<code class="nv-code">mmap</code> on <code class="nv-code">/dev/pci</code>,
where the offset is the physical address of a BAR. The kernel refuses any
offset that is not inside a BAR of a device this process was given, which
is what stops it being <code class="nv-code">/dev/mem</code> with extra steps.
</div>
</article>
<article class="nv-card nv-card--inset">
<div class="nv-card__header">
<div class="nv-stack nv-stack--tight">
<p class="nv-overline nv-text-tertiary">It needs</p>
<h3 class="nv-card__title">Memory the device can reach</h3>
</div>
</div>
<div class="nv-card__body nv-text-secondary">
Physically contiguous, and the process is told the physical address — Physically contiguous, and the process is told the physical address —
which is the whole reason it cannot do this for itself. The arena is which is the whole reason it cannot do this for itself. The arena is
reserved at boot, before the general allocator gets anything. reserved at boot, before the general allocator gets anything.
</p>
</div> </div>
<div class="step"> </article>
<span class="when">It needs</span>
<h3>To be told when to look</h3> <article class="nv-card nv-card--inset">
<p> <div class="nv-card__header">
Interrupts arrive by <code>read()</code>, so they compose with <div class="nv-stack nv-stack--tight">
<code>poll</code>. The kernel masks the line and leaves it masked until <p class="nv-overline nv-text-tertiary">It needs</p>
the driver writes back — because a PCI interrupt is level-triggered, and <h3 class="nv-card__title">To be told when to look</h3>
nobody but the driver knows how to clear it at the device.
</p>
</div> </div>
<div class="step">
<span class="when">It needs</span>
<h3>A descriptor to arrive</h3>
<p>
<code>sendmsg</code> with <code>SCM_RIGHTS</code>. Handing a process the
device it is allowed to touch and handing a compositor a buffer turned
out to be the same act, which is why one primitive answered both.
</p>
</div> </div>
<div class="nv-card__body nv-text-secondary">
Interrupts arrive by <code class="nv-code">read()</code>, so they compose
with <code class="nv-code">poll</code>. The kernel masks the line and
leaves it masked until the driver writes back — because a PCI interrupt is
level-triggered, and nobody but the driver knows how to clear it at the device.
</div>
</article>
<article class="nv-card nv-card--inset">
<div class="nv-card__header">
<div class="nv-stack nv-stack--tight">
<p class="nv-overline nv-text-tertiary">It needs</p>
<h3 class="nv-card__title">A descriptor to arrive</h3>
</div>
</div>
<div class="nv-card__body nv-text-secondary">
<code class="nv-code">sendmsg</code> with <code class="nv-code">SCM_RIGHTS</code>.
Handing a process the device it is allowed to touch and handing a
compositor a buffer turned out to be the same act, which is why one
primitive answered both.
</div>
</article>
</div> </div>
<div class="console transcript" aria-label="Evicting the GPU driver to userspace"> <div class="nv-card nv-card--flush console" aria-label="Evicting the GPU driver to userspace">
<div class="console-bar"> <div class="console__bar">
<i class="dot live"></i> tty1 <span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>tty1</span>
<span>vgpud</span> <span class="nv-caption nv-text-tertiary">vgpud</span>
</div> </div>
<pre><span class="t">$ </span>vgpud <pre class="nv-code console__log"><span class="nv-text-tertiary">$ </span>vgpud
<span class="k">vgpud</span>: virtio-gpu at 00:03.0, currently held by virtio-gpu <span class="nv-text-accent">vgpud</span>: virtio-gpu at 00:03.0, currently held by virtio-gpu
<span class="k">virtio-gpu</span>: released to userspace; screen is the VBE framebuffer <span class="nv-text-accent">virtio-gpu</span>: released to userspace; screen is the VBE framebuffer
<span class="k">vgpud</span>: claimed <span class="nv-text-accent">vgpud</span>: claimed
<span class="k">vgpud</span>: display is 1280x800 <span class="nv-text-accent">vgpud</span>: display is 1280x800
<span class="k">vgpud</span>: scanout is ours, framebuffer at physical 0x3eceb000 <span class="nv-text-accent">vgpud</span>: scanout is ours, framebuffer at physical 0x3eceb000
<span class="g">vgpud</span>: <span class="g">drew a test pattern from userspace</span> <span class="nv-text-success">vgpud</span>: <span class="nv-text-success">mirroring the console</span>
</pre> </pre>
</div> </div>
<p class="seam-note"> <p class="nv-body nv-text-tertiary section__note">
<b>No syscall in that transcript knows it is talking about a GPU.</b> The <b>No syscall in that transcript knows it is talking about a GPU.</b> The
kernel handed over a PCI device; the process decided what it was. That is kernel handed over a PCI device; the process decided what it was. That is
the property that makes the interface general — and the reason SchOS's own the property that makes the interface general — and the reason SchOS's own
drivers are being moved out through it first, rather than shaping it around drivers are being moved out through it first, rather than shaping it around
something foreign and hoping it fits. something foreign and hoping it fits.
</p> </p>
</div>
</section> </section>
<section id="numbers" class="reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section id="numbers" class="shell section" data-reveal>
<p class="eyebrow">Numbers</p> <div class="section__head">
<h2>The size of it</h2> <p class="nv-overline nv-text-accent">Numbers</p>
<h2 class="nv-heading">The size of it</h2>
</div>
<div class="figures"> <div class="figures">
<div class="figure"><b>46,961</b><span>lines in <code>kernel/</code> and <code>include/</code></span></div> <div class="nv-card figure">
<div class="figure"><b>207</b><span>syscalls, Linux numbering</span></div> <b class="nv-display figure__value">46,961</b>
<div class="figure"><b>0</b><span>patches carried against fastfetch</span></div> <span class="nv-label nv-text-secondary">lines in <code class="nv-code">kernel/</code> and <code class="nv-code">include/</code></span>
</div>
<div class="nv-card figure">
<b class="nv-display figure__value">207</b>
<span class="nv-label nv-text-secondary">syscalls, Linux numbering</span>
</div>
<div class="nv-card figure">
<b class="nv-display figure__value">0</b>
<span class="nv-label nv-text-secondary">patches carried against fastfetch</span>
</div> </div>
</div> </div>
</section> </section>
<section class="crash reveal"> <!-- ======================================================================
<div class="wrap"> PSOGM. The one place this purple is allowed, so it is spelled the one
<img src="Assets/shift.svg" alt="Shift, the SchOS mark, as the crash screen draws it"> way the system allows a subtree to carry its own colour: re-derive the
<div> whole set from it, and let every role follow. -->
<p class="eyebrow">When it stops</p> <section class="shell section" data-reveal>
<h2>PSOGM</h2> <div class="nv-bed nv-bed--single crash" data-nv-brand style="--nv-brand: #4B1D9A">
<p> <span class="nv-bed__label">When it stops</span>
<img class="crash__mark" src="Assets/shift.svg"
alt="Shift, the SchOS mark, as the crash screen draws it">
<div class="crash__copy">
<h2 class="nv-heading">PSOGM</h2>
<p class="nv-prose">
When the kernel panics it does not scroll a stack trace past you. It draws When the kernel panics it does not scroll a stack trace past you. It draws
a full-screen report — what failed, where, and the state that mattered — a full-screen report — what failed, where, and the state that mattered —
and encodes the whole thing as a QR code so it can leave the machine and encodes the whole thing as a QR code so it can leave the machine
without being retyped. without being retyped. It reaches every display the machine has, including
the ones the kernel has handed to a userspace driver.
</p> </p>
<p> <p class="nv-prose nv-text-secondary">
This purple is <code>#4B1D9A</code>, and the theme reserves it for exactly This purple is <code class="nv-code">#4B1D9A</code>, and the theme reserves
this. Normal states are calm and dark; only failure is loud. It is the one it for exactly this. Normal states are calm and dark; only failure is loud.
colour in the system that means something, which is why it appears once on It is the one colour in the system that means something, which is why it
this page and nowhere else. appears once on this page and nowhere else.
</p> </p>
</div> </div>
</div> </div>
</section> </section>
<section id="docs" class="reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section id="docs" class="shell section" data-reveal>
<p class="eyebrow">Reading</p> <div class="section__head">
<h2>Where to look</h2> <p class="nv-overline nv-text-accent">Reading</p>
<p class="lede"> <h2 class="nv-heading">Where to look</h2>
<p class="nv-prose nv-text-secondary">
The documentation is written to outlive the work. Anything finished is The documentation is written to outlive the work. Anything finished is
described in <code>architecture/</code>; anything intended lives in described in <code class="nv-code">architecture/</code>; anything intended
<code>plan/</code>; and the bugs worth remembering are kept in lives in <code class="nv-code">plan/</code>; and the bugs worth remembering
<code>history/</code>, in full, including the wrong turns. are kept in <code class="nv-code">history/</code>, in full, including the
wrong turns.
</p> </p>
<p class="readerlink"><a class="btn primary" href="docs.html">Open the docs reader</a></p> <p><a class="nv-button nv-button--primary" href="docs.html">Open the docs reader</a></p>
<div class="links">
<a class="link-card" href="docs.html?doc=architecture/README.md">
<b>Architecture</b><span>What the system is, layer by layer</span>
</a>
<a class="link-card" href="docs.html?doc=plan/1.0.md">
<b>The 1.0 definition</b><span>What is in, what is out, and why</span>
</a>
<a class="link-card" href="docs.html?doc=history/kernel.md">
<b>Kernel history</b><span>The bugs, and what each one taught</span>
</a>
<a class="link-card" href="docs.html?doc=guides/building.md">
<b>Building it</b><span>Toolchain, ports, and running under QEMU</span>
</a>
</div> </div>
<div class="nv-card-grid">
<article class="nv-card nv-card--interactive">
<div class="nv-card__header">
<div>
<h3 class="nv-card__title">
<a class="nv-card__link" href="docs.html?doc=architecture/README.md">Architecture</a>
</h3>
<p class="nv-card__subtitle">What the system is, layer by layer</p>
</div>
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header">
<div>
<h3 class="nv-card__title">
<a class="nv-card__link" href="docs.html?doc=plan/1.0.md">The 1.0 definition</a>
</h3>
<p class="nv-card__subtitle">What is in, what is out, and why</p>
</div>
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header">
<div>
<h3 class="nv-card__title">
<a class="nv-card__link" href="docs.html?doc=history/kernel.md">Kernel history</a>
</h3>
<p class="nv-card__subtitle">The bugs, and what each one taught</p>
</div>
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header">
<div>
<h3 class="nv-card__title">
<a class="nv-card__link" href="docs.html?doc=guides/building.md">Building it</a>
</h3>
<p class="nv-card__subtitle">Toolchain, ports, and running under QEMU</p>
</div>
</div>
</article>
</div> </div>
</section> </section>
<section class="getband reveal"> <!-- ====================================================================== -->
<div class="wrap"> <section class="shell section" data-reveal>
<img src="Assets/usb.svg" alt=""> <div class="nv-card nv-card--elevated getband">
<div> <img class="getband__mark" src="Assets/usb.svg" alt="">
<p class="eyebrow">Try it</p> <div class="getband__copy">
<h2>One ISO, one stick, nothing written to your disks</h2> <p class="nv-overline nv-text-accent">Try it</p>
<p class="lede"> <h2 class="nv-heading">One ISO, one stick, nothing written to your disks</h2>
<p class="nv-prose nv-text-secondary">
It runs from RAM and leaves the machine as it found it. Or skip the It runs from RAM and leaves the machine as it found it. Or skip the
stick entirely — <code>make qemu</code> is the same system in a window. stick entirely — <code class="nv-code">make qemu</code> is the same
</p> system in a window.
<p class="readerlink">
<a class="btn primary" href="download.html">Download SchOS</a>
<a class="btn" href="docs.html?doc=guides/building.md">Build it instead</a>
</p> </p>
<div class="nv-cluster">
<a class="nv-button nv-button--primary" href="download.html">Download SchOS</a>
<a class="nv-button" href="docs.html?doc=guides/building.md">Build it instead</a>
</div>
</div> </div>
</div> </div>
</section> </section>
</main> </main>
<footer> <footer class="shell site-footer">
<div class="wrap"> <div class="nv-divider"></div>
<span>SchOS — kernel <b>SchIFT</b>, filesystem <b>SchFS</b>.</span> <div class="site-footer__row">
<span class="mono">Apache-2.0, with the xv6-derived core under MIT.</span> <span class="nv-body">SchOS — kernel <b>SchIFT</b>, filesystem <b>SchFS</b>.</span>
<span class="pride-note" id="pride-note"> <span class="nv-code nv-text-tertiary">Apache-2.0, with the xv6-derived core under MIT.</span>
</div>
<p class="nv-body nv-text-secondary pride-note" id="pride-note">
<img src="Assets/pride.svg" alt=""> <img src="Assets/pride.svg" alt="">
Built by, and for, people of every orientation and gender identity. Built by, and for, people of every orientation and gender identity.
The flag stays up all year. The flag stays up all year.
</span> </p>
<span class="note" id="ai-note">
<b>On AI.</b> <p class="nv-body nv-text-secondary nv-prose" id="ai-note">
<b class="nv-text-primary">On AI.</b>
Parts of SchOS were written with AI assistance, and that is not the measure Parts of SchOS were written with AI assistance, and that is not the measure
of anything. A project is good when the people building it understand what of anything. A project is good when the people building it understand what
was built, and bad when nobody does — whoever, or whatever, typed it. Vibe was built, and bad when nobody does — whoever, or whatever, typed it. Vibe
@ -444,8 +640,7 @@
you get a system you can explain line by line. The bug writeups in you get a system you can explain line by line. The bug writeups in
<a href="docs.html?doc=history/kernel.md">history/</a> are where that gets <a href="docs.html?doc=history/kernel.md">history/</a> are where that gets
checked, including the wrong turns. checked, including the wrong turns.
</span> </p>
</div>
</footer> </footer>
<script src="app.js"></script> <script src="app.js"></script>

View file

@ -0,0 +1,443 @@
# Niveus
A calm, adaptive interface language built around clarity, depth, material and
consistency.
Niveus is a **design-system layer**, not a component library bolted onto an
application. Applications consume it; they do not redefine it. The visual rules
live here and can change without touching a line of application logic.
```html
<link rel="stylesheet" href="dist/niveus.css">
```
```html
<html data-theme="dark"> <!-- omit data-theme to follow the system -->
```
Open `index.html` for the full specimen: materials, the elevation scale, every
semantic color, the type scale, and each component in both appearances.
`examples/` holds four complete products built with it.
**[REFERENCE.md](REFERENCE.md) is the complete API** — every class, token,
attribute and function, each listed beside the name the rest of the industry
uses for it. It is written to be read by a person *or* handed to a model on its
own: an assistant that has only that file can write correct Niveus markup
without seeing a line of the CSS. `tools/check.py` fails if a class exists in
the CSS and is missing from it.
---
## Using it
Reference the system, never restate it:
```css
/* No */
.thing { background: #ffffff; border-radius: 12px; padding: 10px 16px; }
/* Yes */
.thing {
background: var(--nv-color-surface-elevated);
border-radius: var(--nv-radius-md);
padding: var(--nv-space-2-5) var(--nv-space-4);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
}
```
Application-specific values are fine when they describe *your* content and
layout — a sidebar that happens to be 15rem wide, an aspect ratio, a grid
template. They are not fine when they restate the design language.
Everything ships inside the `niveus` cascade layer, so unlayered application
styles always win. Overriding a Niveus component never requires `!important`
or a longer selector.
## Structure
```
niveus/
├── tokens/ the values, appearance-agnostic
│ ├── color.css palette + semantic roles
│ ├── typography.css families, scale, weights, composite roles
│ ├── spacing.css 4px rhythm + control sizing
│ ├── radius.css corner scale
│ ├── elevation.css the 0–5 depth scale, inset, highlight, focus ring
│ ├── motion.css durations, easings, displacement
│ └── materials.css blur, backdrop recipes, material definitions
│
├── themes/ what those values become in each appearance
│ ├── light.css
│ └── dark.css
│
├── base.css elements, before any component
├── continuity.css how one state becomes the next
├── components/ surface, button, input, card, dialog, navigation,
│ menu, disclosure, scroller, bed, badge, table
├── utilities.css the short list
├── niveus.css the entry point
└── niveus.js optional — continuity for changes CSS cannot animate
examples/ four complete interfaces built from the system
├── ai-landing/ marketing page
├── ai-chat/ chat web app
├── it-shop/ hardware shop
└── dashboard/ monitoring dashboard
```
`niveus/niveus.css` is the authored entry point and works directly over HTTP.
`dist/niveus.css` is the same thing with the `@import` chain flattened into one
request — use it for anything shipped.
## Theming
A component never names a color. It names a **role**, and the appearance decides
what that role is made of:
`background` · `surface` · `surface-elevated` · `surface-inset` ·
`text-primary` · `text-secondary` · `text-tertiary` · `border` · `accent` ·
`success` · `warning` · `danger`
Each role resolves through `light-dark()`, pulling from `--nv-light-*` in
`themes/light.css` or `--nv-dark-*` in `themes/dark.css`. The appearance is
selected by `color-scheme`, which the theme files bind to `[data-theme]`:
| `data-theme` | Result |
| ------------ | ----------------------------- |
| *(absent)* | follows the operating system |
| `light` | light, always |
| `dark` | dark, always |
Dark is not light inverted. Surfaces get *lighter* as they rise, shadows deepen
rather than disappear, the accent moves up its ramp to stay legible, and the top
highlight thins to a faint rim.
## Materials and depth
**Material** answers what light does when it hits a surface. **Elevation**
answers how far that surface is from the canvas. They are independent.
| Material | Use for |
| ---------- | ---------------------------------------------- |
| `solid` | content. The default. |
| `elevated` | surfaces that must be read before their context |
| `inset` | fields, wells, tracks — anything recessed |
| `frosted` | chrome that content passes under: bars, toolbars |
| `glass` | contextual layers where content behind must stay legible |
| Elevation | Relationship |
| --------- | ------------------------------- |
| 0 | flush — painted on the canvas |
| 1 | resting — cards, rows, panels |
| 2 | raised — anything that reacts |
| 3 | floating — popovers, menus |
| 4 | lifted — dialogs, sheets |
| 5 | overlay — topmost, used sparingly |
Applied as classes: `.nv-material-frosted`, `.nv-elevation-3`. A shadow that is
on neither scale is a shadow that means nothing.
## Continuity
Every interaction should read as a continuation of the state before it. When
something moves, grows, collapses or changes appearance, the change itself
should be perceptible — where it came from, where it went, and why. The goal is
not to animate everything. It is that nothing pops into a new state the user
could have watched it reach.
| Change | How it stays continuous |
| ------ | ----------------------- |
| Selection moves between items | The marker itself travels. It is a pseudo-element with a shared `view-transition-name`, so the browser interpolates its old box into its new one. |
| Switching tabs | Panels cross-fade inside one box and slide with the direction of travel — forward one way, back the other. |
| Content expanding | `interpolate-size` lets `block-size: auto` animate, so content grows from the row that revealed it. Nothing is measured. |
| Menus and dialogs | A menu is anchored to its trigger and scales out of its edge; a dialog expands away from the control that opened it. |
| Theme change | The whole surface cross-fades. Surfaces also transition their own colors, so it glides even with no script. |
| Hover, press, focus, select | All have a duration. Marks grow from their centre, the switch thumb travels its track. |
Most of this is plain CSS. The three cases CSS cannot do alone — a selection
moving *between two elements*, one panel replacing another, and the whole page
changing appearance — need a view transition, which needs one line of script:
```js
nv.transition(() => { // or document.startViewTransition directly
previous.removeAttribute("aria-current");
next.setAttribute("aria-current", "page");
});
```
`niveus.js` is optional and dependency-free. Without it every component still
works; changes simply arrive rather than travel. It also wires
`[data-nv-theme]`, `[data-nv-dialog]`, `[data-nv-tabs]` and `[data-nv-close]`
so the common cases need no code at all.
### One rule worth knowing
A `view-transition-name` must be unique among all rendered elements, or the
browser skips the transition entirely. Each selection group therefore needs its
own marker name:
```html
<nav class="nv-nav" style="--nv-selection-name: nv-sel-sidebar"> … </nav>
<nav class="nv-nav-tabs" style="--nv-selection-name: nv-sel-tabs"> … </nav>
```
Tab strips default to `nv-tabs-selection` and everything else to
`nv-selection`, which is enough for one of each per page.
### Motion under a transition
Transition layers always paint above the page, so a travelling marker cannot
stay behind the labels it passes the way it does at rest. Rather than let an
opaque marker wipe them, Niveus thins it while it is in flight: it stays
visible the whole way — it can still be followed — but reads as having lifted
off the surface to travel.
`prefers-reduced-motion` collapses every duration at the token level and skips
view transitions entirely. The change still happens; it just arrives.
## Color
The whole color system has one input:
```css
:root { --nv-brand: #d92e3f; }
```
From it the system derives the accent and its hover and press states, the text
that lands on it, the link color, the focus ring, the selection highlight, the
tint in the greys, and how far the status colors lean — for both appearances,
with nothing else to set. No component is involved: they all name roles, and
the roles re-derive.
Three things make a single input actually work rather than just recolor a
button:
**The label is derived, not chosen.** Every filled color computes its own
foreground by reading its lightness: below a threshold it resolves to
near-white, above it to near-black. A yellow brand gets dark labels and a navy
one light labels, and no one has to remember to update a second token.
**The color is moved until its label is readable.** A mid-tone is the one place
where neither white nor black reaches 4.5:1, so a brand that lands there is
darkened until white works — or, if already light, lightened until black does.
Hue and chroma are untouched; only lightness moves, and only as far as it must.
This is why a mid-tone green arrives slightly deeper than the swatch you set.
**The rest of the palette follows.** Status colors lean towards the brand by
`--nv-brand-harmony` (12%) — red still reads as danger, it just stops looking
borrowed from another system — and the greys carry `--nv-brand-tint` (4%) of
it, which is what stops an accent from looking stuck on. Set either to `0%` for
literal status colors and strictly neutral greys.
| Input | Default | What it does |
| ----- | ------- | ------------ |
| `--nv-brand` | `#4f6cff` | the one color everything derives from |
| `--nv-brand-tint` | `4%` | how much brand bleeds into the greys |
| `--nv-brand-harmony` | `12%` | how far status colors lean towards it |
| `--nv-contrast-pivot` | `0.7` | where "too mid-toned to carry a label" begins |
| `--nv-contrast-dark` / `-light` | `0.53` / `0.78` | how far either side a color is pushed |
### Branding a subtree
`data-nv-brand` re-derives the whole set for one region, so a section can carry
its own color without a second theme:
```html
<section data-nv-brand style="--nv-brand: #0d9488">
<button class="nv-button nv-button--primary">Upgrade</button>
</section>
```
The attribute is required, not decoration: `var()` inside a custom property is
substituted where that property is *declared*, so a `--nv-brand` set on a bare
element would never reach the roles. `[data-nv-brand]` is the selector that
re-runs the derivation.
### Overriding a role outright
Deriving is the default, not a requirement. Any role can be set directly, for
the whole page or for a subtree:
```css
.danger-zone { --nv-color-accent: var(--nv-color-danger); }
```
Everything inside follows — buttons, focus rings, links, switches — because
none of them ever named a color.
## Examples
`examples/` holds four complete interfaces. Open `examples/index.html`, or go
straight to one:
| | | |
| --- | --- | --- |
| **Lumen** | `examples/ai-landing/` | AI product landing page — hero, features, a scrolling row, pricing, FAQ |
| **Lumen Chat** | `examples/ai-chat/` | chat web app — sidebar, transcript, composer, settings dialog with tabs |
| **Northwire** | `examples/it-shop/` | hardware shop — filters, product grid, stock states, basket |
| **Northwire Ops** | `examples/dashboard/` | monitoring dashboard — stat tiles, a chart, a service table, incidents |
They look like four different products. None of them overrides a component,
forks a token, or writes a color. What differs between them is one declaration:
```css
:root { --nv-brand: #ea580c; } /* the shop */
```
Each links exactly two files from the system — `dist/niveus.css` and the
optional `niveus/niveus.js` — and then its own `app.css`, which contains grids,
widths and breakpoints and **not a single hex value**. `tools/check.py` enforces
that: a color anywhere in an example, other than `--nv-brand` itself, fails the
build. An example that quietly reaches for a hex value is no longer
demonstrating the system; it is demonstrating how to work around one.
Where an example needed something the system did not have, the answer was to
add it to the system: `.nv-badge` and `.nv-table` exist because these pages
asked for them.
## Philosophy
Niveus is not a folder of CSS. It is a set of decisions that have already been
made, so that building an interface stops being a series of small aesthetic
arguments.
**Niveus is not about making interfaces look physical. It is about making
digital surfaces feel understandable.** Depth, material and motion are here to
answer questions the user is already asking — what is this, is it above or
below, where did it come from, did my click register — and for nothing else. An
effect that answers no question is decoration, and decoration is what makes an
interface tiring.
### The principles, and what each one costs
| | |
| --- | --- |
| **Clarity** | Every element has a purpose. Decoration never competes with content. |
| **Soft physicality** | Depth comes from shadow, highlight and inset — not from ornament. |
| **Material** | Surfaces are made of something. Solid, elevated, inset, frosted, glass. |
| **Restraint** | No effect exists because it is possible. Translucency and shadow are spent, not sprinkled. |
| **Consistency** | The same control behaves the same way everywhere, including under a pointer. |
| **Adaptability** | Nothing is defined in terms of an appearance or a color. |
| **Continuity** | Nothing pops into a new state the user could have watched it reach. |
They constrain each other on purpose. Continuity says to animate the change;
restraint says most changes do not need it. Material says to use translucency;
restraint says only for layers that pass over content. When two pull in
different directions, restraint wins — a quiet interface with one thing moving
reads clearly, and a busy one does not.
### Where the line runs
Niveus decides **how the application looks and behaves**. The application
decides **what it does**. In practice:
- A color, a shadow, a radius, a duration, a control height → the system.
- A sidebar that happens to be 15rem wide, an aspect ratio, a grid template →
the application.
- A value that is *about* your content is yours. A value that restates the
design language is the system's, and hard-coding it is how a system quietly
stops being one.
### Adding to the system
Three questions, in order:
1. **Can it be expressed with what already exists?** Most new requirements are
an existing component with a modifier, or a pattern — a composition of
components that needs no new CSS at all. The patterns on the specimen page
introduce nothing.
2. **Is it a rule, or is it this once?** A component earns its place when it
can be stated as a reusable principle. "Selection emphasis travels" is a
rule. "This menu is 14px from its button" is not.
3. **Does it hold in both appearances, at every elevation, and under a
pointer?** If it only works on white, or only when nothing is behind it, it
is not finished.
A new token follows the same test. If a value appears three times and means the
same thing each time, it is a token. If it appears three times and means
something different each time, three different things were given the same
number by accident.
### Working on it
Every rule in this repo is checked rather than trusted:
- `tools/check.py` fails on a role defined for one appearance but not the
other, a token referenced but never defined, any hard-coded color in a
component *or an example*, and any class that exists in the CSS but is
missing from `REFERENCE.md`.
- Contrast is measured, not assumed — the derived label on every filled color
clears 4.5:1 for any brand.
- `tools/build.py --check` fails if `dist/` has drifted from the sources.
If a change cannot be made without breaking one of those, the change is
probably not the problem the system has.
## Components
| Class | Notes |
| -------------- | --------------------------------------------------------- |
| `.nv-button` | `--primary` `--subtle` `--ghost` `--danger` `--frosted` `--float`, `--sm`/`--lg`, `--icon`, `--pill`, `--block`, `.nv-button-group` |
| `.nv-input` | with `.nv-field`, `.nv-input-group`, `.nv-select`, `.nv-checkbox`, `.nv-radio`, `.nv-switch`, `.nv-choice` |
| `.nv-card` | `--elevated` `--floating` `--inset` `--frosted` `--interactive`, `.nv-card-grid` |
| `.nv-dialog` | native `<dialog>`; `--sheet` `--compact` `--wide` |
| `.nv-nav` | `--vertical` `--plain` `--inline`, plus `.nv-nav-tabs`, `.nv-tab-panel` and `.nv-breadcrumbs` |
| `.nv-menu` | anchored popover with `.nv-menu-trigger`, `__item`, `__separator`, `__label`, `__shortcut` |
| `.nv-disclosure` | native `<details>`; `.nv-disclosure-group`, plus `.nv-collapse` for other markup |
| `.nv-scroller` | snapping row inside `.nv-scroller-frame`, with edge fades and optional controls |
| `.nv-bed` | ground that is visibly not ordinary background; `--quiet` `--vivid` `--single` `--flush` |
| `.nv-badge` | state, not an action; `--accent` `--success` `--warning` `--danger`, `--solid` `--count` `--square` |
| `.nv-table` | inside `.nv-table-frame`; `__number` `__actions`, `--compact` `--interactive` |
| `.nv-surface` | materials and elevation as utilities |
Variants are defined by reassigning private `--_*` properties. Interaction —
the lift on hover, the sink on press, the focus ring — is written once per
component, so a new variant cannot drift from how the rest of the system
responds to a pointer.
## Accessibility
- One focus treatment system-wide: a contrast ring under an accent ring, so it
stays visible on any surface including accent-filled controls.
- Controls shorter than 44px still get a 44px pointer target.
- `prefers-reduced-motion` collapses duration and displacement to zero at the
token level — no component opts in individually.
- `prefers-reduced-transparency`, and browsers without `backdrop-filter`, fall
back from frosted and glass to their solid equivalents.
## Tools
```bash
python3 tools/build.py # flatten the @import chain into dist/niveus.css
python3 tools/build.py --check # fail if dist/ is stale (for CI)
python3 tools/check.py # enforce the system's own rules
```
`check.py` catches the failures that do not look broken: a role defined for one
appearance but not the other, a token referenced but never defined, and a
hard-coded color in a component.
## Browser support
Niveus uses `light-dark()`, cascade layers, `:has()`, `@starting-style` and
`allow-discrete` transitions — all baseline in current Chrome, Safari, Firefox
and Edge. `backdrop-filter` degrades to a solid surface where it is missing.
The continuity layer builds on newer platform features, each of which degrades
to the state change simply happening rather than travelling:
| Feature | Used for | Without it |
| ------- | -------- | ---------- |
| View transitions | travelling selection, tab panels, theme cross-fade | the change applies instantly |
| `interpolate-size` | disclosure height | content appears at full height (`.nv-collapse` still animates) |
| Anchor positioning | menus tied to their trigger | the menu centres itself |
| `popover` | menus, light dismiss | needs the application's own toggle |
---
> Niveus is not about making interfaces look physical. It is about making
> digital surfaces feel understandable.
---
Made by **Nexoniarz**.

View file

@ -0,0 +1,393 @@
# Niveus — reference
Every class, token, attribute and function the system exposes, with the name
the rest of the industry uses for it.
Two audiences:
- **People.** Find the thing you mean by its usual name, then use the class
beside it.
- **Models.** This file is the whole API. Hand it over on its own and an
assistant can build correct Niveus markup without seeing the CSS: it does not
need to know *how* `.nv-nav-tabs` works, only that a tab list is spelled that
way here. Nothing below is aspirational — `tools/check.py` fails if a class
exists in the CSS and is missing from this file.
Everything ships inside the `niveus` cascade layer, so unlayered application
styles win without `!important`.
```html
<link rel="stylesheet" href="dist/niveus.css">
<script src="niveus/niveus.js" defer></script> <!-- optional -->
<html data-theme="dark"> <!-- omit to follow the OS -->
<style>:root { --nv-brand: #d92e3f; }</style> <!-- the whole identity -->
```
---
## Button
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-button` | button, secondary button | Raised surface. The default; use it for anything that is not *the* action. |
| `.nv-button--primary` | filled / CTA button | The single affirmative action in a view. |
| `.nv-button--subtle` | tonal button | Accent-tinted fill, no elevation. Mid-emphasis. |
| `.nv-button--ghost` | text / quiet button | No material until hovered. Lowest emphasis. |
| `.nv-button--danger` | destructive button | Irreversible actions only. |
| `.nv-button--frosted` | glass button | For use over imagery or frosted chrome. |
| `.nv-button--float` | FAB, overlay control | Floats over content: grows on hover instead of lifting, and keeps its own positioning via `--nv-button-offset`. |
| `.nv-button--sm` `.nv-button--lg` | size variants | 28px and 44px; default is 36px. |
| `.nv-button--icon` | icon button | Square. Requires `aria-label`. |
| `.nv-button--pill` | pill button | Fully rounded. |
| `.nv-button--block` | full-width button | Fills its container. |
| `.nv-button__icon` | button icon slot | Sizes an inline SVG to the label. |
| `.nv-button-group` | segmented control, button group | Buttons sharing edges; only outer corners round. |
States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
(shows a spinner and keeps the width), `aria-pressed` for toggles.
## Form controls
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-field` | form field, form group | Label + control + hint, correctly spaced. |
| `.nv-field__label` | field label | Add `data-optional="(optional)"` to append a muted suffix. |
| `.nv-field__hint` | helper text | Guidance below the control. |
| `.nv-field__error` | validation message | Pair with `aria-invalid="true"` + `aria-describedby`. |
| `.nv-input` | text field, form control | `input`, `textarea` and `select`. Inset material. |
| `.nv-input--sm` `.nv-input--lg` | size variants | Match the button sizes. |
| `.nv-input--color` | color well / swatch input | `input[type=color]` as a square swatch. |
| `.nv-input-group` | input with adornment | Wrapper for a field with a prefix or suffix. |
| `.nv-input-group__affix` | prefix / suffix adornment | Sits inside the field's recess. First child = leading, last = trailing. |
| `.nv-select` | select, dropdown | Wrapper that draws the chevron around a native `select.nv-input`. |
| `.nv-checkbox` | checkbox | Native input, custom mark. Supports `:indeterminate`. |
| `.nv-radio` | radio button | Native input, custom mark. |
| `.nv-switch` | toggle switch | Inset track, elevated thumb that travels. |
| `.nv-choice` | control with inline label | `<label>` wrapping a control and its text. |
## Card
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-card` | card, panel | Resting container at elevation 1. |
| `.nv-card--elevated` | raised card | Elevation 2 — read before its surroundings. |
| `.nv-card--floating` | floating card | Elevation 3. |
| `.nv-card--inset` | well, sunken panel | Recessed into its parent. |
| `.nv-card--frosted` | glass card | Translucent, for use over imagery or a bed. |
| `.nv-card--interactive` | clickable card | Lifts on hover, sinks on press. |
| `.nv-card--flush` | edge-to-edge card | Removes part padding, for media or tables. |
| `.nv-card__header` | card header | |
| `.nv-card__title` | card title | |
| `.nv-card__subtitle` | card subtitle, supporting text | |
| `.nv-card__body` | card content | |
| `.nv-card__footer` | card actions | |
| `.nv-card__footer--plain` | undivided card actions | Drops the divider above the footer. |
| `.nv-card__media` | card media | 16:9 image slot. |
| `.nv-card__link` | stretched link | Expands one link's hit area over the whole card. |
| `.nv-card-grid` | responsive card grid | Auto-fitting, equal-height columns. |
## Dialog
Built on native `<dialog>`; open with `showModal()` or `nv.openDialog()`.
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-dialog` | modal dialog | Elevation 4 over a blurred scrim. Focus trap and Escape are the platform's. |
| `.nv-dialog--sheet` | bottom sheet | Enters from the block end, flush to the edges. |
| `.nv-dialog--compact` `.nv-dialog--wide` | width variants | 24rem / 44rem; default 30rem. |
| `.nv-dialog__header` | dialog header | |
| `.nv-dialog__title` | dialog title | Point `aria-labelledby` at it. |
| `.nv-dialog__description` | dialog supporting text | |
| `.nv-dialog__body` | dialog content | The only scrolling region. |
| `.nv-dialog__footer` | dialog actions | |
| `.nv-dialog__footer--divided` | grounded dialog actions | For a footer sitting over scrollable content. |
| `.nv-dialog__close` | close button | Optical alignment for a ghost icon button. |
## Menu
Built on the `popover` attribute and anchor positioning. Trigger and menu name
the same anchor through `--nv-anchor`.
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-menu` | dropdown menu, popover menu | Frosted surface anchored to its trigger. |
| `.nv-menu-trigger` | menu button | Sets `anchor-name` from `--nv-anchor`. |
| `.nv-menu__item` | menu item | `aria-checked="true"` draws a tick. |
| `.nv-menu__item--danger` | destructive menu item | |
| `.nv-menu__label` | menu section label / group heading | |
| `.nv-menu__separator` | menu divider | |
| `.nv-menu__shortcut` | keyboard shortcut hint, accelerator | Trailing muted text. |
## Navigation
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-nav` | app bar, navigation bar, header | Frosted, sticky chrome. |
| `.nv-nav--vertical` | sidebar, side navigation | Full-height, solid; drops the translucency. |
| `.nv-nav--plain` | solid bar | Opaque instead of frosted. |
| `.nv-nav--static` | non-sticky bar | |
| `.nv-nav--inline` | inline nav group | A navigation used inside content, not as chrome. |
| `.nv-nav__brand` | brand / logo lockup | |
| `.nv-nav__list` | nav list | |
| `.nv-nav__item` | nav item, nav link | Selected via `aria-current`, `aria-selected` or `--active`. |
| `.nv-nav__item--active` | active nav item | For when no ARIA state fits. |
| `.nv-nav__section` | nav section heading | |
| `.nv-nav__icon` | nav icon slot | |
| `.nv-nav__badge` | count badge, notification badge | |
| `.nv-nav__spacer` | flexible spacer | Pushes what follows to the end. |
| `.nv-nav-tabs` | tab list, segmented tabs | Inset track; the selected tab lifts out of it. |
| `.nv-tab-panel` | tab panel | Carries the shared content name so panels cross-fade. |
| `.nv-breadcrumbs` | breadcrumbs | |
## Disclosure
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-disclosure` | disclosure, accordion item | A `<details>` whose height animates open and closed. |
| `.nv-disclosure--plain` | bare disclosure | No surface of its own. |
| `.nv-disclosure__summary` | disclosure trigger | The `<summary>`. |
| `.nv-disclosure__chevron` | chevron, caret | Rotates rather than swapping glyphs. |
| `.nv-disclosure__body` | disclosure content | |
| `.nv-disclosure-group` | accordion | Stacked disclosures sharing hairlines. |
| `.nv-collapse` | collapsible region | Same relationship for markup that cannot be a `<details>`. Toggle `data-open`. |
## Scroller
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-scroller-frame` | carousel frame | Positions the controls; holds the track. |
| `.nv-scroller` | carousel, snap scroller, rail | Horizontal row with scroll snapping and fading edges. |
| `.nv-scroller--auto` | content-width items | Items size to content instead of the track. |
Set `--nv-scroller-item` for item width, `--nv-scroller-fade` for the edge fade.
## Badge
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-badge` | badge, chip, tag, status pill | Non-interactive statement of state. |
| `.nv-badge--accent` | accent badge | |
| `.nv-badge--success` `.nv-badge--warning` `.nv-badge--danger` | status badges | Tone follows the semantic role. |
| `.nv-badge--solid` | filled badge | For the one badge that must be seen first. |
| `.nv-badge--count` | count badge | Tabular, borderless, min two digits. |
| `.nv-badge--square` | square badge | |
| `.nv-badge__dot` | status dot | |
## Table
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-table-frame` | table wrapper | Scrolls sideways so the page does not have to. |
| `.nv-table` | data table | |
| `.nv-table--compact` | dense table | |
| `.nv-table--interactive` | clickable rows | |
| `.nv-table__number` | numeric cell | End-aligned, tabular figures. |
| `.nv-table__actions` | row actions cell | Shrinks to content. |
## Bed
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-bed` | showcase surface, patterned backdrop | Ground that is visibly not ordinary background: for regions the user should look *at*. Gives translucent materials something to be translucent against. |
| `.nv-bed--quiet` | subdued bed | No hatch, softer blooms. |
| `.nv-bed--vivid` | emphatic bed | |
| `.nv-bed--single` | single-hue bed | One color instead of three. |
| `.nv-bed--flush` | full-bleed bed | Square, no side borders. |
| `.nv-bed__label` | bed label, eyebrow | |
Set `--nv-bed-tint` to lead with a color other than the accent.
## Surface, material and elevation
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-surface` | surface, panel | Plain container on the solid material. |
| `.nv-material-solid` | opaque surface | Content lives here. The default. |
| `.nv-material-elevated` | raised surface | Separated from its context. |
| `.nv-material-inset` | recessed surface | Fields, wells, tracks. |
| `.nv-material-frosted` | frosted glass, blur backdrop | Chrome that content passes under. |
| `.nv-material-glass` | glass | Contextual layers where content behind must stay legible. |
| `.nv-elevation-0` `.nv-elevation-1` `.nv-elevation-2` | elevation / z-depth | flush · resting · raised |
| `.nv-elevation-3` `.nv-elevation-4` `.nv-elevation-5` | elevation / z-depth | floating · lifted · overlay |
| `.nv-elevation-inset-sm` `.nv-elevation-inset-md` `.nv-elevation-inset-lg` | inner shadow | Recessed surfaces. |
| `.nv-divider` | divider, separator, rule | |
| `.nv-divider--vertical` | column rule | Stretches to its flex row. |
Material and elevation are independent: combine freely.
## Type roles
| Class | Known as | Size |
| --- | --- | --- |
| `.nv-display` | display, hero text | 38px |
| `.nv-heading` | heading | 24px |
| `.nv-title` | title, subheading | 17px |
| `.nv-body` | body copy | 15px |
| `.nv-label` | label, UI text | 13px |
| `.nv-caption` | caption, secondary text | 12px |
| `.nv-overline` | overline, eyebrow, kicker | 12px uppercase |
| `.nv-code` | code, monospace | 13px |
| `.nv-text-primary` `.nv-text-secondary` `.nv-text-tertiary` | text color roles | Emphasis, not size. |
| `.nv-text-accent` `.nv-text-success` `.nv-text-warning` `.nv-text-danger` | semantic text colors | |
## Layout utilities
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-container` | page container, wrapper | Max width + gutters. |
| `.nv-stack` | stack, vertical rhythm | Column with a gap. |
| `.nv-stack--tight` `.nv-stack--loose` | stack density | |
| `.nv-cluster` | cluster, wrap row | Wrapping row, vertically centred. |
| `.nv-cluster--tight` | dense cluster | |
| `.nv-cluster--between` | spread cluster | Pushes children to both ends. |
| `.nv-truncate` | ellipsis, text truncation | One line with an ellipsis. |
| `.nv-prose` | prose, measure | Readable line length and leading. |
| `.nv-visually-hidden` | screen-reader-only, sr-only | Available to assistive tech, invisible on screen. |
| `.nv-continuous` | cross-fading region | Content that should cross-fade in place when it changes. |
| `.nv-carried` | (view-transition class) | Applied to nav items so they ride above a travelling marker. |
| `.nv-travelling` | (view-transition class) | Applied to a selection marker so it travels below the labels. |
---
## Tokens
Names are stable; values are not. Never state a color, shadow, radius, size or
duration directly — reference the token.
### Color roles
`--nv-color-` +
```
background background-subtle surface surface-elevated surface-inset
surface-hover surface-active scrim
text-primary text-secondary text-tertiary text-disabled text-inverted
border-subtle border border-strong
accent accent-hover accent-active accent-subtle accent-subtle-hover
accent-border accent-text on-accent
success success-hover success-subtle success-border success-text on-success
warning warning-hover warning-subtle warning-border warning-text on-warning
danger danger-hover danger-active danger-subtle danger-border
danger-text on-danger
shadow-ambient shadow-penumbra shadow-umbra shadow-inset
highlight highlight-strong elevation-tint-1 … -5
material-frosted material-glass material-border
focus-ring focus-ring-contrast selection
```
`on-*` is the text that goes on top of that color, and is derived — never set
it by hand.
### Color inputs
| Token | Default | Does |
| --- | --- | --- |
| `--nv-brand` | `#4f6cff` | The one color everything derives from. |
| `--nv-brand-tint` | `4%` | How much brand bleeds into the greys. |
| `--nv-brand-harmony` | `12%` | How far status colors lean towards the brand. |
| `--nv-contrast-pivot` | `0.7` | Lightness where a color can no longer carry a label. |
| `--nv-contrast-dark` / `-light` | `0.53` / `0.78` | How far either side a color is pushed. |
`--nv-palette-{neutral|accent|success|warning|danger}-{0…1000}` are raw values.
Components must never reference them.
### Scales
| Family | Steps |
| --- | --- |
| `--nv-space-` | `0 px 0-5 1 1-5 2 2-5 3 4 5 6 7 8 10 12 14 16 20 24 32` (suffix = px ÷ 4) |
| `--nv-radius-` | `none xs sm md lg xl 2xl full`, plus `nested-md` `nested-lg` |
| `--nv-font-size-` | `2xs xs sm md lg xl 2xl 3xl 4xl 5xl` |
| `--nv-font-weight-` | `regular medium semibold bold` |
| `--nv-line-height-` | `tight snug normal relaxed` |
| `--nv-letter-spacing-` | `tighter tight normal wide wider` |
| `--nv-font-` | `display heading title body label caption code` (composite shorthands) |
| `--nv-font-family-` | `sans mono` |
| `--nv-measure` | `-narrow` · `--nv-measure` · `-wide` |
| `--nv-control-height-` | `sm md lg` — also `control-padding-` and `control-gap-` |
| `--nv-elevation-` | `0 1 2 3 4 5`, `inset-sm` `inset-md` `inset-lg` |
| `--nv-highlight-` | `top` `top-strong` `edge` |
| `--nv-blur-` | `sm md lg xl` — also `backdrop-frosted` `backdrop-glass` `backdrop-scrim` |
| `--nv-material-` | `{solid,elevated,inset,frosted,glass}-{bg,border,shadow}` |
### Motion
| Family | Steps |
| --- | --- |
| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme` |
| `--nv-ease-` | `standard entrance exit spring emphasized linear` |
| `--nv-transition-` | `colors depth control surface expand` (composites) |
| displacement | `--nv-lift-hover` `--nv-press-depth` `--nv-travel-sm/md/lg` |
`prefers-reduced-motion` collapses every duration and displacement at the token
level. Components never opt in individually.
### Other
`--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
`--nv-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` ·
`--nv-scroller-fade` · `--nv-content-name`
---
## JavaScript
`niveus.js` is optional and dependency-free; it exposes `window.nv`. Without it
everything still renders — state changes simply arrive instead of travelling.
| Function | Signature | Does |
| --- | --- | --- |
| `nv.transition` | `(update, { direction, kind }) => Promise` | Runs `update` inside a view transition so the change can be followed. `direction` is `"forward"`/`"backward"`; `kind: "theme"` slows the cross-fade. Falls back to running `update` directly under reduced motion or without API support. |
| `nv.setTheme` | `("light" \| "dark" \| "system") => Promise` | Cross-fades the whole surface into the new appearance and fires `nv:theme` on `<html>`. |
| `nv.syncThemeControls` | `(theme) => void` | Sets `aria-pressed` on every `[data-nv-theme]`. |
| `nv.openDialog` | `(dialog \| id, trigger?) => dialog` | `showModal()`, plus a transform origin so the dialog expands out of its trigger. |
| `nv.selectTab` | `(tablist, tab) => Promise` | Moves selection and its panel, with direction. |
| `nv.initScroller` | `(frame) => void` | Wires one scroller's controls. |
| `nv.liftLabels` | `(scope) => void` | Names navigation items so a travelling marker passes behind them. |
| `nv.wire` | `(scope = document) => void` | Applies all of the below. Runs once on load; call again after injecting markup. |
| `nv.prefersReducedMotion` | `() => boolean` | |
### Data attributes
| Attribute | On | Does |
| --- | --- | --- |
| `data-theme` | `<html>` | `light` / `dark`. Absent follows the OS. |
| `data-nv-brand` | any element | Re-derives the whole color set for that subtree. |
| `data-nv-theme` | button | Sets the appearance when clicked. |
| `data-nv-dialog` | button | Opens the dialog with that id, from itself. |
| `data-nv-close` | button | Closes its dialog or popover. |
| `data-nv-tabs` | tablist | Wires `[role=tab]` children to their `aria-controls` panels, with arrow keys. |
| `data-nv-scroller` | `.nv-scroller-frame` | Wires its `[data-nv-scroll="prev"\|"next"]` controls. |
| `data-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. |
| `data-nv-direction` | `<html>` | Set during a transition by `nv.transition`. |
| `data-nv-transition` | `<html>` | `"theme"` during an appearance change. |
---
## Rules that bite
1. **`view-transition-name` must be unique per document.** A duplicate makes
the browser skip the transition entirely. Each selection group needs its own
`--nv-selection-name`; `.nv-nav-tabs` defaults to `nv-tabs-selection` and
everything else to `nv-selection`, which is enough for one of each per page.
2. **A menu and its trigger must name the same anchor** through `--nv-anchor`,
and the value must be unique per pair.
3. **Only one `.nv-tab-panel` may be visible at a time** — hide the rest with
`hidden`. They share one content name.
4. **Never reference `--nv-palette-*` from application code.** It is raw, and
does not adapt to the appearance.
5. **A floating control positioned with `translate`** needs
`.nv-button--float` and `--nv-button-offset`, or the hover lift overwrites
its position.
6. **Icon-only buttons need `aria-label`.** The system gives them a 44px target
but cannot give them a name.
---
Made by **Nexoniarz**.

View file

@ -0,0 +1,234 @@
/* ==========================================================================
Lumen Chat — app layout
--------------------------------------------------------------------------
Layout only. The shell is a two-column grid pinned to the viewport, with
exactly one scrolling region; everything visual comes from Niveus.
The avatars below are the one place this app extends the system rather than
consuming it — and even then they are built from tokens, not from values
invented here. That is the correct way to extend: new shapes, old language.
========================================================================== */
html, body {
block-size: 100%;
}
body {
overflow: hidden; /* the transcript scrolls, the page does not */
}
.app {
display: grid;
grid-template-columns: 17rem 1fr;
block-size: 100svh;
}
@media (max-width: 52rem) {
.app {
grid-template-columns: 1fr;
}
.app__sidebar {
display: none;
}
}
/* --- Sidebar ---------------------------------------------------------------- */
.app__sidebar {
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
min-block-size: 0;
padding: var(--nv-space-4) var(--nv-space-3);
background-color: var(--nv-color-background-subtle);
border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.app__threads {
flex: 1;
min-block-size: 0;
overflow-y: auto;
margin-inline: calc(var(--nv-space-1) * -1);
padding-inline: var(--nv-space-1);
}
.thread__title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread__meta {
display: block;
font: var(--nv-font-caption);
color: var(--nv-color-text-tertiary);
font-weight: var(--nv-font-weight-regular);
}
/* This sidebar is a plain aside rather than .nv-nav--vertical, so the list
keeps its default row direction until told otherwise. */
.app__threads .nv-nav__list {
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-0-5);
}
/* A nav item holding two lines needs to stop centring them. */
.app__threads .nv-nav__item {
align-items: flex-start;
flex-direction: column;
gap: 0;
min-inline-size: 0;
}
/* --- Main ------------------------------------------------------------------- */
.app__main {
display: grid;
grid-template-rows: auto 1fr auto;
min-block-size: 0;
min-inline-size: 0;
}
.app__transcript {
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--nv-space-6) var(--nv-layout-gutter);
}
.transcript {
max-inline-size: 48rem;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: var(--nv-space-6);
}
/* --- Messages ---------------------------------------------------------------- */
.message {
display: flex;
gap: var(--nv-space-3);
align-items: flex-start;
}
.message--from-user {
flex-direction: row-reverse;
}
.message__body {
max-inline-size: 42rem;
min-inline-size: 0;
}
.message--from-user .message__body {
padding: var(--nv-space-3) var(--nv-space-4);
background-color: var(--nv-color-accent-subtle);
border: var(--nv-border-width) solid var(--nv-color-accent-border);
border-radius: var(--nv-radius-lg);
border-end-end-radius: var(--nv-radius-xs);
color: var(--nv-color-text-primary);
}
.message__body > * + * {
margin-block-start: var(--nv-space-3);
}
.avatar {
inline-size: var(--nv-space-8);
block-size: var(--nv-space-8);
flex: none;
display: grid;
place-items: center;
border-radius: var(--nv-radius-full);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
}
.avatar--agent {
background-color: var(--nv-color-accent);
color: var(--nv-color-on-accent);
border-color: transparent;
}
.avatar--user {
background-color: var(--nv-color-surface-elevated);
color: var(--nv-color-text-secondary);
}
.code-block {
display: block;
padding: var(--nv-space-4);
border-radius: var(--nv-radius-md);
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: var(--nv-elevation-inset-sm);
overflow-x: auto;
white-space: pre;
}
/* The three dots while an answer is still arriving. */
.thinking {
display: inline-flex;
gap: var(--nv-space-1);
align-items: center;
padding-block: var(--nv-space-2);
}
.thinking span {
inline-size: var(--nv-space-1-5);
block-size: var(--nv-space-1-5);
border-radius: var(--nv-radius-full);
background-color: var(--nv-color-text-tertiary);
animation: thinking 1.2s var(--nv-ease-standard) infinite;
}
.thinking span:nth-child(2) { animation-delay: 0.15s; }
.thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes thinking {
0%, 60%, 100% { opacity: 0.25; translate: 0 0; }
30% { opacity: 1; translate: 0 calc(var(--nv-travel-sm) * -0.5); }
}
@media (prefers-reduced-motion: reduce) {
.thinking span { animation: none; opacity: 0.5; }
}
/* --- Composer ----------------------------------------------------------------- */
.composer {
padding: var(--nv-space-4) var(--nv-layout-gutter) var(--nv-space-5);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
background-color: var(--nv-color-background);
}
.composer__inner {
max-inline-size: 48rem;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: var(--nv-space-2-5);
}
.composer__row {
display: flex;
align-items: flex-end;
gap: var(--nv-space-2);
}
.composer__row .nv-input {
min-block-size: var(--nv-control-height-lg);
max-block-size: 9rem;
}
.composer__hints {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--nv-space-4);
}

View file

@ -0,0 +1,485 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumen Chat — an AI chat app built with Niveus</title>
<link rel="stylesheet" href="../../dist/niveus.css">
<script src="../../niveus/niveus.js" defer></script>
<link rel="stylesheet" href="app.css">
<script>
try {
var saved = localStorage.getItem("lumen-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) {}
</script>
<style>
:root { --nv-brand: #6d5efc; }
</style>
</head>
<body>
<div class="app">
<!-- ================================================================== -->
<aside class="app__sidebar" style="--nv-selection-name: nv-chat-threads">
<a class="nv-nav__brand" href="#" style="padding-inline: var(--nv-space-2)">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v18M3 12h18M6 6l12 12M18 6L6 18" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round"/>
</svg>
Lumen
</a>
<button class="nv-button nv-button--block" id="new-thread">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
New conversation
</button>
<nav class="app__threads" aria-label="Conversations">
<span class="nv-nav__section">Today</span>
<ul class="nv-nav__list" id="threads" data-nv-travel>
<li>
<button class="nv-nav__item" data-thread="latency" aria-current="page">
<span class="thread__title">Checkout latency after #4412</span>
<span class="thread__meta">12 messages</span>
</button>
</li>
<li>
<button class="nv-nav__item" data-thread="migration">
<span class="thread__title">Postgres 15 migration plan</span>
<span class="thread__meta">4 messages</span>
</button>
</li>
</ul>
<span class="nv-nav__section">Earlier</span>
<ul class="nv-nav__list">
<li>
<button class="nv-nav__item" data-thread="audit">
<span class="thread__title">Dependency audit — Q3</span>
<span class="thread__meta">31 messages</span>
</button>
</li>
<li>
<button class="nv-nav__item" data-thread="onboarding">
<span class="thread__title">Why does billing own the cart?</span>
<span class="thread__meta">8 messages</span>
</button>
</li>
</ul>
</nav>
<hr class="nv-divider" style="margin-block: var(--nv-space-2)">
<button class="nv-nav__item nv-menu-trigger" popovertarget="account-menu"
style="--nv-anchor: --account">
<span class="avatar avatar--user" aria-hidden="true">RK</span>
<span class="thread__title">Robin Kade</span>
</button>
<div class="nv-menu" popover id="account-menu" style="--nv-anchor: --account">
<span class="nv-menu__label">Robin Kade</span>
<button class="nv-menu__item" data-nv-dialog="settings">
Settings<span class="nv-menu__shortcut">⌘,</span>
</button>
<button class="nv-menu__item">Keyboard shortcuts</button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Sign out</button>
</div>
</aside>
<!-- ================================================================== -->
<main class="app__main">
<nav class="nv-nav nv-nav--static" aria-label="Conversation">
<h1 class="nv-title nv-truncate" id="thread-heading">Checkout latency after #4412</h1>
<span class="nv-nav__spacer"></span>
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="model-menu"
style="--nv-anchor: --model">
<span id="model-label">Lumen 2</span>
<span class="nv-disclosure__chevron" aria-hidden="true"
style="margin-inline-start: var(--nv-space-1)"></span>
</button>
<div class="nv-menu" popover id="model-menu" style="--nv-anchor: --model">
<span class="nv-menu__label">Model</span>
<button class="nv-menu__item" aria-checked="true" data-model="Lumen 2">
Lumen 2<span class="nv-menu__shortcut">Balanced</span>
</button>
<button class="nv-menu__item" data-model="Lumen 2 Pro">
Lumen 2 Pro<span class="nv-menu__shortcut">Deeper</span>
</button>
<button class="nv-menu__item" data-model="Lumen Mini">
Lumen Mini<span class="nv-menu__shortcut">Fastest</span>
</button>
</div>
<button class="nv-button nv-button--sm nv-button--ghost">Share</button>
<button class="nv-button nv-button--sm nv-button--ghost nv-button--icon"
data-nv-dialog="settings" aria-label="Settings">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="2"/>
<path d="M12 3v2.2M12 18.8V21M21 12h-2.2M5.2 12H3M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6M18.4 18.4l-1.6-1.6M7.2 7.2L5.6 5.6"
stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</nav>
<div class="app__transcript" id="transcript-scroll">
<div class="transcript nv-continuous" id="transcript">
<!-- rendered from the thread data below -->
</div>
</div>
<div class="composer">
<form class="composer__inner" id="composer">
<div class="composer__row">
<button class="nv-button nv-button--lg nv-button--icon" type="button" aria-label="Attach">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M18 11l-6.5 6.5a4 4 0 01-5.6-5.6l7-7a2.8 2.8 0 013.9 4L9.6 16a1.6 1.6 0 01-2.3-2.3l6.2-6.2"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<label class="nv-visually-hidden" for="prompt">Message Lumen</label>
<textarea class="nv-input" id="prompt" rows="1"
placeholder="Ask about this codebase…"></textarea>
<button class="nv-button nv-button--lg nv-button--primary nv-button--icon"
type="submit" aria-label="Send">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h13M13 6l6 6-6 6" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="composer__hints">
<label class="nv-choice nv-caption">
<input class="nv-switch" type="checkbox" checked>
Search the repository
</label>
<label class="nv-choice nv-caption">
<input class="nv-switch" type="checkbox">
Propose changes
</label>
<span class="nv-nav__spacer"></span>
<span class="nv-caption">Enter to send · Shift + Enter for a new line</span>
</div>
</form>
</div>
</main>
</div>
<!-- ==================================================================== -->
<dialog class="nv-dialog nv-dialog--wide" id="settings" aria-labelledby="settings-title">
<div class="nv-dialog__header">
<div>
<h2 class="nv-dialog__title" id="settings-title">Settings</h2>
<p class="nv-dialog__description">Applies to this workspace.</p>
</div>
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
aria-label="Close" data-nv-close>
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="nv-dialog__body">
<div class="nv-nav-tabs" role="tablist" data-nv-tabs aria-label="Settings sections"
style="--nv-selection-name: nv-chat-settings-tabs">
<button class="nv-nav__item" role="tab" id="tab-general"
aria-controls="panel-general" aria-selected="true">General</button>
<button class="nv-nav__item" role="tab" id="tab-model"
aria-controls="panel-model" aria-selected="false" tabindex="-1">Model</button>
<button class="nv-nav__item" role="tab" id="tab-privacy"
aria-controls="panel-privacy" aria-selected="false" tabindex="-1">Privacy</button>
</div>
<div class="nv-tab-panel" id="panel-general" role="tabpanel" aria-labelledby="tab-general"
style="padding-block-start: var(--nv-space-5)">
<div class="nv-stack">
<div class="nv-field">
<label class="nv-field__label" for="ws-name">Workspace name</label>
<input class="nv-input" id="ws-name" value="Northwire Platform">
</div>
<div class="nv-field">
<span class="nv-field__label">Appearance</span>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">System</button>
</div>
<span class="nv-field__hint">The whole interface cross-fades between the two.</span>
</div>
<label class="nv-choice">
<input class="nv-switch" type="checkbox" checked>
Send on Enter
</label>
</div>
</div>
<div class="nv-tab-panel" id="panel-model" role="tabpanel" aria-labelledby="tab-model"
style="padding-block-start: var(--nv-space-5)" hidden>
<div class="nv-stack">
<div class="nv-field">
<label class="nv-field__label" for="default-model">Default model</label>
<div class="nv-select">
<select class="nv-input" id="default-model">
<option selected>Lumen 2 — balanced</option>
<option>Lumen 2 Pro — deeper reasoning</option>
<option>Lumen Mini — fastest</option>
</select>
</div>
<span class="nv-field__hint">Individual conversations can override this.</span>
</div>
<div class="nv-field">
<label class="nv-field__label" for="context">Context window</label>
<div class="nv-input-group">
<input class="nv-input" id="context" value="200000">
<span class="nv-input-group__affix">tokens</span>
</div>
</div>
<label class="nv-choice">
<input class="nv-checkbox" type="checkbox" checked>
Cite sources in every answer
</label>
</div>
</div>
<div class="nv-tab-panel" id="panel-privacy" role="tabpanel" aria-labelledby="tab-privacy"
style="padding-block-start: var(--nv-space-5)" hidden>
<div class="nv-stack">
<div class="nv-bed nv-bed--quiet nv-bed--single">
<p class="nv-body">
Repository contents are used to answer this workspace's questions
and nothing else.
</p>
</div>
<label class="nv-choice">
<input class="nv-switch" type="checkbox">
Keep transcripts after 30 days
</label>
<div class="nv-field">
<span class="nv-field__label">Danger zone</span>
<div class="nv-cluster">
<button class="nv-button nv-button--danger nv-button--sm">Delete all conversations</button>
</div>
</div>
</div>
</div>
</div>
<div class="nv-dialog__footer nv-dialog__footer--divided">
<button class="nv-button" data-nv-close>Cancel</button>
<button class="nv-button nv-button--primary" data-nv-close>Save changes</button>
</div>
</dialog>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("lumen-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
/* ------------------------------------------------------------------
Canned content. A real client would fetch this; what matters here is
that switching threads is a content change inside one surface, so it
goes through nv.transition() and cross-fades in place.
------------------------------------------------------------------ */
const threads = {
latency: {
title: "Checkout latency after #4412",
messages: [
{ from: "user", html: "Why did checkout latency double after Tuesday's deploy?" },
{ from: "agent", html:
`<p>The <code class="nv-code">pricing</code> service began resolving tax rules
per line item instead of per cart in <code class="nv-code">#4412</code>.
Carts with more than two items take the slow path.</p>
<div class="nv-cluster nv-cluster--tight">
<span class="nv-badge nv-badge--warning">p95 +118%</span>
<span class="nv-badge">3 files</span>
<span class="nv-badge">pricing/tax.ts</span>
</div>` },
{ from: "user", html: "Show me the hot path." },
{ from: "agent", html:
`<p>Here is the loop that moved:</p>
<code class="code-block nv-code">for (const line of cart.lines) {
// was: resolved once per cart
const rules = await taxRules.resolve(line.destination);
line.tax = rules.apply(line);
}</code>
<p><code class="nv-code">taxRules.resolve</code> is a network call. Hoisting
it above the loop restores the previous shape.</p>` },
],
},
migration: {
title: "Postgres 15 migration plan",
messages: [
{ from: "user", html: "What breaks if we go to Postgres 15 next sprint?" },
{ from: "agent", html:
`<p>Two things in this codebase, and one in the deploy pipeline.</p>
<div class="nv-cluster nv-cluster--tight">
<span class="nv-badge nv-badge--danger">1 blocking</span>
<span class="nv-badge nv-badge--warning">2 to review</span>
</div>
<p>The blocking one is the <code class="nv-code">reporting</code> service's use
of implicit integer casts in <code class="nv-code">?column?</code> aliases,
removed in 15.</p>` },
],
},
audit: {
title: "Dependency audit — Q3",
messages: [
{ from: "user", html: "Which advisories actually reach our code?" },
{ from: "agent", html:
`<p>Four of the thirty-one. The rest are in transitive packages whose
affected entry points you never call.</p>
<div class="nv-cluster nv-cluster--tight">
<span class="nv-badge nv-badge--danger">2 high</span>
<span class="nv-badge nv-badge--warning">2 moderate</span>
<span class="nv-badge nv-badge--success">27 unreachable</span>
</div>` },
],
},
onboarding: {
title: "Why does billing own the cart?",
messages: [
{ from: "user", html: "Why does billing own the cart model?" },
{ from: "agent", html:
`<p>It did not, until <code class="nv-code">#2180</code> moved it there so
promotions could be priced in one place. The commit message and the
review thread both say this was meant to be temporary.</p>` },
],
},
};
const transcript = document.getElementById("transcript");
const scroller = document.getElementById("transcript-scroll");
const heading = document.getElementById("thread-heading");
function messageHTML(message) {
const isUser = message.from === "user";
return `
<div class="message message--from-${message.from}">
<span class="avatar avatar--${isUser ? "user" : "agent"}" aria-hidden="true">${isUser ? "RK" : "L"}</span>
<div class="message__body">${message.html}</div>
</div>`;
}
function render(key) {
const thread = threads[key];
heading.textContent = thread.title;
transcript.innerHTML = thread.messages.map(messageHTML).join("");
}
render("latency");
/* Switching threads: the selection travels in the sidebar and the
transcript cross-fades, because both are one state change. */
const list = document.getElementById("threads");
document.querySelectorAll("[data-thread]").forEach((item) => {
item.addEventListener("click", () => {
if (item.hasAttribute("aria-current")) return;
nv.transition(() => {
document.querySelectorAll("[data-thread][aria-current]")
.forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
render(item.dataset.thread);
});
scroller.scrollTop = 0;
});
});
/* Choosing a model updates the menu's own checked state. */
document.querySelectorAll("[data-model]").forEach((option) => {
option.addEventListener("click", () => {
document.querySelectorAll("[data-model]").forEach((other) => {
other.setAttribute("aria-checked", String(other === option));
});
document.getElementById("model-label").textContent = option.dataset.model;
document.getElementById("model-menu").hidePopover();
});
});
/* Sending: append, show that something is happening, then answer. The
point is the states, not the reply. */
const form = document.getElementById("composer");
const prompt = document.getElementById("prompt");
function append(html) {
transcript.insertAdjacentHTML("beforeend", html);
scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = prompt.value.trim();
if (!text) return;
append(messageHTML({ from: "user", html: text.replace(/[<>&]/g, (c) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c])) }));
prompt.value = "";
append(`<div class="message message--from-agent" id="pending">
<span class="avatar avatar--agent" aria-hidden="true">L</span>
<div class="message__body">
<span class="thinking" role="status" aria-label="Thinking">
<span></span><span></span><span></span>
</span>
</div>
</div>`);
setTimeout(() => {
document.getElementById("pending")?.remove();
append(messageHTML({ from: "agent", html:
`<p>This example has no model behind it — it exists to show the states
a chat interface moves through: the message landing, something
visibly happening, and the answer arriving in the same column.</p>` }));
}, 1400);
});
prompt.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
document.getElementById("new-thread").addEventListener("click", () => {
nv.transition(() => {
document.querySelectorAll("[data-thread][aria-current]")
.forEach((el) => el.removeAttribute("aria-current"));
heading.textContent = "New conversation";
transcript.innerHTML = `
<div class="nv-bed nv-bed--quiet" style="text-align: center">
<p class="nv-title">Ask about this codebase</p>
<p class="nv-caption" style="margin-block-start: var(--nv-space-2)">
Lumen has indexed 1,284 files across 3 repositories.
</p>
</div>`;
});
prompt.focus();
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,186 @@
/* ==========================================================================
Lumen — page layout
--------------------------------------------------------------------------
Everything in this file is *layout*: where things sit, how wide they get,
when they stack. Not one color, shadow, radius, type size or duration is
stated here — those all come from Niveus, which is the whole point of the
split. If you find yourself writing a hex value in a file like this, the
system is missing a token.
========================================================================== */
.shell {
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
.section {
padding-block: var(--nv-space-20);
}
.section__head {
max-inline-size: var(--nv-measure);
margin-block-end: var(--nv-space-8);
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
}
.section__head--center {
margin-inline: auto;
text-align: center;
align-items: center;
}
/* --- Hero ------------------------------------------------------------------ */
.hero {
display: grid;
gap: var(--nv-space-12);
padding-block: var(--nv-space-16) var(--nv-space-8);
align-items: center;
}
@media (min-width: 62rem) {
.hero {
grid-template-columns: 1fr 1fr;
gap: var(--nv-space-16);
}
}
.hero__copy {
display: flex;
flex-direction: column;
gap: var(--nv-space-5);
align-items: flex-start;
}
.hero__title {
font-size: clamp(var(--nv-font-size-4xl), 6vw, var(--nv-font-size-5xl));
}
/* --- Preview ---------------------------------------------------------------
A mock of the product, sitting on a bed so the frosted panel above it has
something to be frosted against. */
.preview {
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
max-inline-size: 30rem;
margin-inline: auto;
}
.bubble {
padding: var(--nv-space-3) var(--nv-space-4);
border-radius: var(--nv-radius-lg);
max-inline-size: 85%;
}
.bubble--from-user {
align-self: flex-end;
border-end-end-radius: var(--nv-radius-xs);
}
.bubble--from-agent {
align-self: flex-start;
border-end-start-radius: var(--nv-radius-xs);
}
/* --- Logos ------------------------------------------------------------------ */
.logos {
display: flex;
flex-wrap: wrap;
gap: var(--nv-space-8);
justify-content: center;
align-items: center;
padding-block: var(--nv-space-8);
opacity: 0.75;
}
/* --- Features ---------------------------------------------------------------- */
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
gap: var(--nv-space-5);
}
.feature__icon {
inline-size: var(--nv-space-10);
block-size: var(--nv-space-10);
display: grid;
place-items: center;
margin-block-end: var(--nv-space-4);
border-radius: var(--nv-radius-md);
background-color: var(--nv-color-accent-subtle);
color: var(--nv-color-accent-text);
}
.feature__icon svg {
inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
}
/* --- Pricing ------------------------------------------------------------------ */
.pricing {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
gap: var(--nv-space-5);
align-items: start;
}
.price {
display: flex;
align-items: baseline;
gap: var(--nv-space-2);
margin-block: var(--nv-space-4);
}
.price__amount {
font-size: var(--nv-font-size-4xl);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-tighter);
color: var(--nv-color-text-primary);
}
.plan-features {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--nv-space-2-5);
}
.plan-features li {
display: flex;
align-items: flex-start;
gap: var(--nv-space-2-5);
}
.plan-features svg {
inline-size: 1em;
block-size: 1em;
margin-block-start: 0.25em;
flex: none;
color: var(--nv-color-success-text);
}
/* --- Closing ------------------------------------------------------------------- */
.closing {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--nv-space-5);
padding-block: var(--nv-space-12);
}
.site-footer {
padding-block: var(--nv-space-8) var(--nv-space-12);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}

View file

@ -0,0 +1,468 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumen — an AI product landing page built with Niveus</title>
<!-- The system, and its optional continuity helpers. -->
<link rel="stylesheet" href="../../dist/niveus.css">
<script src="../../niveus/niveus.js" defer></script>
<!-- This page's own layout. No colors, no shadows, no type sizes. -->
<link rel="stylesheet" href="app.css">
<script>
/* Applied before first paint so the page never renders in the wrong
appearance and then corrects itself. */
try {
var saved = localStorage.getItem("lumen-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) { /* storage unavailable — follow the system */ }
</script>
<!-- The entire identity of this site is one declaration. -->
<style>
:root { --nv-brand: #6d5efc; }
</style>
</head>
<body>
<nav class="nv-nav" id="topnav" aria-label="Main" style="--nv-selection-name: nv-lumen-nav">
<a class="nv-nav__brand" href="#top">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v18M3 12h18M6 6l12 12M18 6L6 18" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round"/>
</svg>
Lumen
</a>
<ul class="nv-nav__list" data-nv-travel>
<li><a class="nv-nav__item" href="#features" aria-current="page">Features</a></li>
<li><a class="nv-nav__item" href="#build">Build</a></li>
<li><a class="nv-nav__item" href="#pricing">Pricing</a></li>
<li><a class="nv-nav__item" href="#faq">FAQ</a></li>
</ul>
<span class="nv-nav__spacer"></span>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
<a class="nv-button nv-button--sm nv-button--primary" href="#pricing">Get started</a>
</nav>
<main id="top">
<!-- ================================================================== -->
<header class="shell hero">
<div class="hero__copy">
<span class="nv-badge nv-badge--accent">
<span class="nv-badge__dot"></span>
Lumen 2 is available
</span>
<h1 class="nv-display hero__title">The model that reads your whole codebase.</h1>
<p class="nv-prose">
Lumen answers questions about the systems you actually run — not a
snapshot of them. Point it at a repository and it keeps up as the code
changes.
</p>
<div class="nv-cluster">
<a class="nv-button nv-button--primary nv-button--lg" href="#pricing">Start free</a>
<button class="nv-button nv-button--lg" data-nv-dialog="demo">Watch a demo</button>
</div>
<p class="nv-caption">No card required · 14-day trial · SOC 2 Type II</p>
</div>
<div class="nv-bed nv-bed--single">
<span class="nv-bed__label">Live preview</span>
<div class="preview">
<div class="bubble bubble--from-user nv-material-elevated">
Why did checkout latency double after Tuesday's deploy?
</div>
<div class="nv-card nv-card--frosted bubble bubble--from-agent"
style="max-inline-size: 100%; padding: var(--nv-space-4)">
<p class="nv-body">
The <code class="nv-code">pricing</code> service started resolving
tax rules per line item instead of per cart in
<code class="nv-code">#4412</code>. Two of three carts hit the slow
path.
</p>
<div class="nv-cluster nv-cluster--tight" style="margin-block-start: var(--nv-space-3)">
<span class="nv-badge nv-badge--warning">p95 +118%</span>
<span class="nv-badge">3 files</span>
</div>
</div>
<div class="bubble bubble--from-user nv-material-elevated">
Open a PR that reverts just that change.
</div>
</div>
</div>
</header>
<!-- ================================================================== -->
<section class="shell">
<div class="logos">
<span class="nv-overline">Northwire</span>
<span class="nv-overline">Harbour</span>
<span class="nv-overline">Greenhouse Labs</span>
<span class="nv-overline">Quarry</span>
<span class="nv-overline">Signal Systems</span>
</div>
</section>
<!-- ================================================================== -->
<section class="shell section" id="features">
<div class="section__head">
<span class="nv-overline">Why teams switch</span>
<h2 class="nv-heading">Built for the code you already have.</h2>
<p class="nv-prose">
Three things matter when a model works on a real system: that it sees
the current state, that it explains itself, and that it cannot quietly
do something irreversible.
</p>
</div>
<div class="features">
<article class="nv-card">
<div class="nv-card__body">
<span class="feature__icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" stroke-width="2"
stroke-linecap="round"/>
</svg>
</span>
<h3 class="nv-title">Always current</h3>
<p style="margin-block-start: var(--nv-space-2)">
Indexes on every push, so answers describe the branch you are on
rather than the one it was trained on.
</p>
</div>
</article>
<article class="nv-card">
<div class="nv-card__body">
<span class="feature__icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="2"/>
<path d="M16 16l4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</span>
<h3 class="nv-title">Shows its work</h3>
<p style="margin-block-start: var(--nv-space-2)">
Every claim links to the lines it came from. If it cannot cite a
file, it says so instead of guessing.
</p>
</div>
</article>
<article class="nv-card">
<div class="nv-card__body">
<span class="feature__icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z" stroke="currentColor"
stroke-width="2" stroke-linejoin="round"/>
</svg>
</span>
<h3 class="nv-title">Asks before it acts</h3>
<p style="margin-block-start: var(--nv-space-2)">
Writes, merges and deploys are proposals until a human approves
them. Read access is the default.
</p>
</div>
</article>
</div>
</section>
<!-- ================================================================== -->
<section class="shell section" id="build">
<div class="section__head">
<span class="nv-overline">What teams build</span>
<h2 class="nv-heading">One model, several jobs.</h2>
</div>
<div class="nv-scroller-frame" data-nv-scroller>
<div class="nv-scroller">
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Incident triage</h3>
<p class="nv-card__subtitle">Platform teams</p>
</div></div>
<div class="nv-card__body">
Correlates a regression with the change that caused it, and drafts
the revert.
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Review assistant</h3>
<p class="nv-card__subtitle">Every repository</p>
</div></div>
<div class="nv-card__body">
Reads the diff in the context of the whole system, not just the
files that changed.
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Migration planning</h3>
<p class="nv-card__subtitle">Staff engineers</p>
</div></div>
<div class="nv-card__body">
Finds every call site, groups them by risk, and sequences the work.
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Onboarding</h3>
<p class="nv-card__subtitle">New joiners</p>
</div></div>
<div class="nv-card__body">
Answers "why is this here" with the commit, the review and the
issue behind it.
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Dependency audits</h3>
<p class="nv-card__subtitle">Security</p>
</div></div>
<div class="nv-card__body">
Separates the advisories that reach your code from the ones that
do not.
</div>
</article>
</div>
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="prev" aria-label="Previous">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M14 6l-6 6 6 6" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="next" aria-label="Next">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M10 6l6 6-6 6" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</section>
<!-- ================================================================== -->
<section class="shell section" id="pricing">
<div class="section__head section__head--center">
<span class="nv-overline">Pricing</span>
<h2 class="nv-heading">Per seat, not per question.</h2>
<p class="nv-prose">
Usage limits make people ration the tool that is supposed to save them
time.
</p>
</div>
<div class="pricing">
<article class="nv-card">
<div class="nv-card__body">
<h3 class="nv-title">Starter</h3>
<p class="nv-caption">For a single repository.</p>
<p class="price">
<span class="price__amount">$0</span>
<span class="nv-caption">forever</span>
</p>
<ul class="plan-features nv-caption">
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> One repository</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Community support</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Read-only access</li>
</ul>
</div>
<div class="nv-card__footer">
<button class="nv-button nv-button--block">Start free</button>
</div>
</article>
<article class="nv-card nv-card--floating">
<div class="nv-card__body">
<div class="nv-cluster nv-cluster--between">
<h3 class="nv-title">Team</h3>
<span class="nv-badge nv-badge--accent nv-badge--solid">Most chosen</span>
</div>
<p class="nv-caption">For a codebase several people touch.</p>
<p class="price">
<span class="price__amount">$24</span>
<span class="nv-caption">per seat / month</span>
</p>
<ul class="plan-features nv-caption">
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Unlimited repositories</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Pull request proposals</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Shared context across the team</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> SSO and audit log</li>
</ul>
</div>
<div class="nv-card__footer">
<button class="nv-button nv-button--primary nv-button--block">Start 14-day trial</button>
</div>
</article>
<article class="nv-card">
<div class="nv-card__body">
<h3 class="nv-title">Enterprise</h3>
<p class="nv-caption">For code that cannot leave your network.</p>
<p class="price">
<span class="price__amount">Talk</span>
<span class="nv-caption">to us</span>
</p>
<ul class="plan-features nv-caption">
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Self-hosted or VPC</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Custom retention</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Named support engineer</li>
</ul>
</div>
<div class="nv-card__footer">
<button class="nv-button nv-button--block">Contact sales</button>
</div>
</article>
</div>
</section>
<!-- ================================================================== -->
<section class="shell section" id="faq">
<div class="section__head">
<span class="nv-overline">Questions</span>
<h2 class="nv-heading">Before you ask.</h2>
</div>
<div class="nv-disclosure-group" style="max-inline-size: var(--nv-measure-wide)">
<details class="nv-disclosure" open>
<summary class="nv-disclosure__summary">
Do you train on our code?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
No. Repository contents are used to answer your team's questions and
nothing else. Enterprise plans can run entirely inside your own
network, where the question never leaves it either.
</div>
</details>
<details class="nv-disclosure">
<summary class="nv-disclosure__summary">
What happens when it is wrong?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
Every answer cites the files it drew on, so a wrong answer is
checkable rather than merely confident. Anything that writes is a
proposal until a person approves it.
</div>
</details>
<details class="nv-disclosure">
<summary class="nv-disclosure__summary">
How large a codebase can it handle?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
The largest repository we index in production is around eleven
million lines. Indexing is incremental, so size affects the first
run far more than the ones after it.
</div>
</details>
</div>
</section>
<!-- ================================================================== -->
<section class="shell">
<div class="nv-bed nv-bed--vivid closing">
<h2 class="nv-heading">Point it at a repository.</h2>
<p class="nv-prose">
The first answer usually arrives before the tour would have finished.
</p>
<div class="nv-cluster">
<a class="nv-button nv-button--primary nv-button--lg" href="#pricing">Start free</a>
<button class="nv-button nv-button--lg nv-button--frosted" data-nv-dialog="demo">
Watch a demo
</button>
</div>
</div>
</section>
<footer class="shell site-footer">
<div class="nv-cluster nv-cluster--between">
<span class="nv-caption">Lumen — an example interface built with Niveus.</span>
<span class="nv-caption">Made by Nexoniarz</span>
</div>
</footer>
</main>
<dialog class="nv-dialog" id="demo" aria-labelledby="demo-title">
<div class="nv-dialog__header">
<div>
<h2 class="nv-dialog__title" id="demo-title">A two-minute tour</h2>
<p class="nv-dialog__description">
This dialog expanded from the button you pressed, and the page behind
it is blurred rather than hidden.
</p>
</div>
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
aria-label="Close" data-nv-close>
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="nv-dialog__body">
<div class="nv-bed nv-bed--quiet" style="display: grid; place-items: center; min-block-size: 12rem">
<span class="nv-caption">Where the video would be.</span>
</div>
</div>
<div class="nv-dialog__footer">
<button class="nv-button" data-nv-close>Close</button>
<a class="nv-button nv-button--primary" href="#pricing" data-nv-close>Start free</a>
</div>
</dialog>
<script>
document.addEventListener("DOMContentLoaded", () => {
/* niveus.js wires the appearance buttons; the page decides what
"remember it" means. */
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("lumen-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
/* The nav's selection travels to whatever section you jump to. */
const nav = document.querySelector("[data-nv-travel]");
nav.addEventListener("click", (event) => {
const item = event.target.closest(".nv-nav__item");
if (!item || item.hasAttribute("aria-current")) return;
nv.transition(() => {
nav.querySelectorAll("[aria-current]").forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
});
});
/* The bar earns its shadow only once content has passed under it. */
const topnav = document.getElementById("topnav");
const onScroll = () => topnav.toggleAttribute("data-scrolled", scrollY > 8);
addEventListener("scroll", onScroll, { passive: true });
onScroll();
});
</script>
</body>
</html>

View file

@ -0,0 +1,153 @@
/* ==========================================================================
Northwire Ops — dashboard layout
--------------------------------------------------------------------------
Layout only. A data-dense view is where a design system is tested hardest:
there are more things on screen than anywhere else, so any inconsistency in
spacing or weight shows up immediately. Nothing here restates the language —
even the chart draws itself in semantic colors.
========================================================================== */
html, body { block-size: 100%; }
.app {
display: grid;
grid-template-columns: 15rem 1fr;
min-block-size: 100svh;
}
@media (max-width: 62rem) {
.app { grid-template-columns: 1fr; }
.app__sidebar { display: none; }
}
.app__main {
min-inline-size: 0;
display: flex;
flex-direction: column;
}
.app__content {
flex: 1;
padding: var(--nv-space-6) var(--nv-layout-gutter) var(--nv-space-12);
display: flex;
flex-direction: column;
gap: var(--nv-space-6);
max-inline-size: 80rem;
inline-size: 100%;
margin-inline: auto;
}
.page-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--nv-space-4);
}
/* --- Stat tiles -------------------------------------------------------------- */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
gap: var(--nv-space-4);
}
.stat__value {
display: block;
margin-block: var(--nv-space-2) var(--nv-space-1);
font-size: var(--nv-font-size-3xl);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-tighter);
color: var(--nv-color-text-primary);
font-variant-numeric: tabular-nums;
}
/* --- Chart --------------------------------------------------------------------
Drawn in semantic colors, so it follows the brand and both appearances with
no second palette to maintain. */
.chart {
inline-size: 100%;
block-size: 14rem;
display: block;
overflow: visible;
}
.chart__grid {
stroke: var(--nv-color-border-subtle);
stroke-width: 1;
}
.chart__line {
fill: none;
stroke: var(--nv-color-accent);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.chart__line--compare {
stroke: var(--nv-color-border-strong);
stroke-dasharray: 4 4;
}
.chart__area {
fill: url(#chart-fill);
}
.chart__labels {
display: flex;
justify-content: space-between;
margin-block-start: var(--nv-space-2);
}
/* --- Layout of the lower half --------------------------------------------------- */
.split {
display: grid;
gap: var(--nv-space-6);
align-items: start;
}
@media (min-width: 70rem) {
.split { grid-template-columns: 1.6fr 1fr; }
}
.activity {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.activity li {
display: flex;
gap: var(--nv-space-3);
padding-block: var(--nv-space-3);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.activity li:last-child { border-block-end: 0; }
.activity__mark {
inline-size: var(--nv-space-2);
block-size: var(--nv-space-2);
border-radius: var(--nv-radius-full);
margin-block-start: 0.45em;
flex: none;
background-color: var(--nv-color-border-strong);
}
.activity__mark--danger { background-color: var(--nv-color-danger); }
.activity__mark--success { background-color: var(--nv-color-success); }
.activity__mark--accent { background-color: var(--nv-color-accent); }
.activity__mark--warning { background-color: var(--nv-color-warning); }
.service-name {
display: flex;
align-items: center;
gap: var(--nv-space-2-5);
}

View file

@ -0,0 +1,578 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Northwire Ops — a dashboard built with Niveus</title>
<link rel="stylesheet" href="../../dist/niveus.css">
<script src="../../niveus/niveus.js" defer></script>
<link rel="stylesheet" href="app.css">
<script>
try {
var saved = localStorage.getItem("ops-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) {}
</script>
<style>
:root { --nv-brand: #0d9488; }
</style>
</head>
<body>
<div class="app">
<aside class="nv-nav nv-nav--vertical app__sidebar" aria-label="Sections"
style="--nv-selection-name: nv-ops-sidebar">
<a class="nv-nav__brand" href="#">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 18V9M10 18V5M16 18v-6M22 18H2" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round"/>
</svg>
Northwire Ops
</a>
<span class="nv-nav__section">Monitor</span>
<ul class="nv-nav__list" data-nv-travel="local">
<li><a class="nv-nav__item" href="#" aria-current="page">Overview</a></li>
<li><a class="nv-nav__item" href="#">Services <span class="nv-nav__badge">6</span></a></li>
<li><a class="nv-nav__item" href="#">Incidents <span class="nv-nav__badge">1</span></a></li>
<li><a class="nv-nav__item" href="#">Traces</a></li>
</ul>
<span class="nv-nav__section">Manage</span>
<ul class="nv-nav__list">
<li><a class="nv-nav__item" href="#">Deployments</a></li>
<li><a class="nv-nav__item" href="#">On-call</a></li>
<li><span class="nv-nav__item" aria-disabled="true">Cost (soon)</span></li>
</ul>
<span class="nv-nav__spacer"></span>
<div class="nv-bed nv-bed--quiet nv-bed--single" style="padding: var(--nv-space-4)">
<p class="nv-label">Retention</p>
<p class="nv-caption" style="margin-block: var(--nv-space-1) var(--nv-space-3)">
14 of 30 days used.
</p>
<button class="nv-button nv-button--sm nv-button--primary nv-button--block">Upgrade plan</button>
</div>
</aside>
<main class="app__main">
<nav class="nv-nav nv-nav--static" aria-label="Toolbar">
<ol class="nv-breadcrumbs">
<li><a href="#">Northwire</a></li>
<li><a href="#">Platform</a></li>
<li aria-current="page">Overview</li>
</ol>
<span class="nv-nav__spacer"></span>
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="range-menu"
style="--nv-anchor: --range">
<span id="range-label">Last 24 hours</span>
<span class="nv-disclosure__chevron" aria-hidden="true"
style="margin-inline-start: var(--nv-space-1)"></span>
</button>
<div class="nv-menu" popover id="range-menu" style="--nv-anchor: --range">
<button class="nv-menu__item" aria-checked="true" data-range="Last 24 hours">Last 24 hours</button>
<button class="nv-menu__item" data-range="Last 7 days">Last 7 days</button>
<button class="nv-menu__item" data-range="Last 30 days">Last 30 days</button>
<hr class="nv-menu__separator">
<button class="nv-menu__item" data-range="Custom range">Custom range…</button>
</div>
<button class="nv-button nv-button--sm nv-button--ghost">Export</button>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
</nav>
<div class="app__content">
<div class="page-head">
<div>
<h1 class="nv-heading">Platform overview</h1>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">
Six services · updated 40 seconds ago
</p>
</div>
<div class="nv-cluster">
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>1 incident open</span>
<button class="nv-button nv-button--sm nv-button--primary" data-nv-dialog="incident">
Review incident
</button>
</div>
</div>
<!-- Stat tiles ------------------------------------------------------ -->
<div class="stats">
<article class="nv-card"><div class="nv-card__body">
<span class="nv-overline">Requests</span>
<span class="stat__value">14.2k</span>
<span class="nv-badge nv-badge--success">+6.1% vs yesterday</span>
</div></article>
<article class="nv-card"><div class="nv-card__body">
<span class="nv-overline">Error rate</span>
<span class="stat__value">0.42%</span>
<span class="nv-badge nv-badge--danger">+0.29 pts</span>
</div></article>
<article class="nv-card"><div class="nv-card__body">
<span class="nv-overline">p95 latency</span>
<span class="stat__value">318 ms</span>
<span class="nv-badge nv-badge--warning">+118%</span>
</div></article>
<article class="nv-card"><div class="nv-card__body">
<span class="nv-overline">Uptime</span>
<span class="stat__value">99.71%</span>
<span class="nv-badge">30-day window</span>
</div></article>
</div>
<!-- Chart ------------------------------------------------------------ -->
<article class="nv-card">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">p95 latency</h2>
<p class="nv-card__subtitle">Solid: today · dashed: same day last week</p>
</div>
<span class="nv-badge nv-badge--warning">Regression at 14:00</span>
</div>
<div class="nv-card__body">
<svg class="chart" viewBox="0 0 600 200" preserveAspectRatio="none"
role="img" aria-label="p95 latency over 24 hours, rising sharply after 14:00">
<defs>
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--nv-color-accent)" stop-opacity="0.28"/>
<stop offset="100%" stop-color="var(--nv-color-accent)" stop-opacity="0"/>
</linearGradient>
</defs>
<g class="chart__grid">
<line x1="0" y1="40" x2="600" y2="40"/>
<line x1="0" y1="90" x2="600" y2="90"/>
<line x1="0" y1="140" x2="600" y2="140"/>
<line x1="0" y1="190" x2="600" y2="190"/>
</g>
<path class="chart__area"
d="M0,150 L60,146 L120,152 L180,143 L240,148 L300,140 L360,132 L420,60 L480,44 L540,52 L600,40 L600,190 L0,190 Z"/>
<polyline class="chart__line"
points="0,150 60,146 120,152 180,143 240,148 300,140 360,132 420,60 480,44 540,52 600,40"/>
<polyline class="chart__line chart__line--compare"
points="0,152 60,149 120,151 180,146 240,150 300,145 360,148 420,143 480,147 540,141 600,145"/>
</svg>
<div class="chart__labels nv-caption">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>now</span>
</div>
</div>
</article>
<!-- Services and activity -------------------------------------------- -->
<div class="split">
<article class="nv-card nv-card--flush">
<div class="nv-card__header" style="padding: var(--nv-space-5)">
<div>
<h2 class="nv-card__title">Services</h2>
<p class="nv-card__subtitle">Health across the platform</p>
</div>
<div class="nv-nav-tabs" role="tablist" data-nv-tabs aria-label="Service view"
style="--nv-selection-name: nv-ops-tabs">
<button class="nv-nav__item" role="tab" id="tab-all"
aria-controls="panel-all" aria-selected="true">All</button>
<button class="nv-nav__item" role="tab" id="tab-attention"
aria-controls="panel-attention" aria-selected="false" tabindex="-1">Needs attention</button>
</div>
</div>
<div class="nv-tab-panel" id="panel-all" role="tabpanel" aria-labelledby="tab-all">
<div class="nv-table-frame">
<table class="nv-table">
<thead>
<tr>
<th scope="col">Service</th>
<th scope="col" class="nv-table__number">Uptime</th>
<th scope="col" class="nv-table__number">p95</th>
<th scope="col" class="nv-table__number">req/min</th>
<th scope="col"><span class="nv-visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
Checkout API
</span>
</td>
<td class="nv-table__number">99.98%</td>
<td class="nv-table__number">142 ms</td>
<td class="nv-table__number">2.4k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-1" style="--nv-anchor: --svc1" aria-label="Options for Checkout API">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-1" style="--nv-anchor: --svc1">
<span class="nv-menu__label">Checkout API</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>Degraded</span>
Pricing
</span>
</td>
<td class="nv-table__number">99.71%</td>
<td class="nv-table__number">318 ms</td>
<td class="nv-table__number">1.9k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-2" style="--nv-anchor: --svc2" aria-label="Options for Pricing">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-2" style="--nv-anchor: --svc2">
<span class="nv-menu__label">Pricing</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
Identity
</span>
</td>
<td class="nv-table__number">99.99%</td>
<td class="nv-table__number">88 ms</td>
<td class="nv-table__number">6.1k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-3" style="--nv-anchor: --svc3" aria-label="Options for Identity">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-3" style="--nv-anchor: --svc3">
<span class="nv-menu__label">Identity</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
Search
</span>
</td>
<td class="nv-table__number">99.95%</td>
<td class="nv-table__number">204 ms</td>
<td class="nv-table__number">3.3k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-4" style="--nv-anchor: --svc4" aria-label="Options for Search">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-4" style="--nv-anchor: --svc4">
<span class="nv-menu__label">Search</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Failing</span>
Notifications
</span>
</td>
<td class="nv-table__number">97.20%</td>
<td class="nv-table__number">—</td>
<td class="nv-table__number">0</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-5" style="--nv-anchor: --svc5" aria-label="Options for Notifications">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-5" style="--nv-anchor: --svc5">
<span class="nv-menu__label">Notifications</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
Reporting
</span>
</td>
<td class="nv-table__number">99.90%</td>
<td class="nv-table__number">512 ms</td>
<td class="nv-table__number">410</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-6" style="--nv-anchor: --svc6" aria-label="Options for Reporting">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-6" style="--nv-anchor: --svc6">
<span class="nv-menu__label">Reporting</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="nv-tab-panel" id="panel-attention" role="tabpanel"
aria-labelledby="tab-attention" hidden>
<div class="nv-table-frame">
<table class="nv-table">
<thead>
<tr>
<th scope="col">Service</th>
<th scope="col" class="nv-table__number">Uptime</th>
<th scope="col" class="nv-table__number">p95</th>
<th scope="col" class="nv-table__number">req/min</th>
<th scope="col"><span class="nv-visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>Degraded</span>
Pricing
</span>
</td>
<td class="nv-table__number">99.71%</td>
<td class="nv-table__number">318 ms</td>
<td class="nv-table__number">1.9k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-2" style="--nv-anchor: --svc2" aria-label="Options for Pricing">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-2" style="--nv-anchor: --svc2">
<span class="nv-menu__label">Pricing</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Failing</span>
Notifications
</span>
</td>
<td class="nv-table__number">97.20%</td>
<td class="nv-table__number">—</td>
<td class="nv-table__number">0</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-5" style="--nv-anchor: --svc5" aria-label="Options for Notifications">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-5" style="--nv-anchor: --svc5">
<span class="nv-menu__label">Notifications</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</article>
<div class="nv-stack">
<article class="nv-card">
<div class="nv-card__header">
<div><h2 class="nv-card__title">Activity</h2></div>
</div>
<div class="nv-card__body" style="padding-block-start: 0">
<ul class="activity nv-caption">
<li>
<span class="activity__mark activity__mark--danger"></span>
<span><strong class="nv-text-primary">Notifications</strong> stopped responding
<span style="display: block">14:12 · alert #881</span></span>
</li>
<li>
<span class="activity__mark activity__mark--warning"></span>
<span><strong class="nv-text-primary">Pricing</strong> p95 crossed 300 ms
<span style="display: block">14:04 · alert #880</span></span>
</li>
<li>
<span class="activity__mark activity__mark--accent"></span>
<span><strong class="nv-text-primary">#4412</strong> deployed to production
<span style="display: block">13:58 · by robin</span></span>
</li>
<li>
<span class="activity__mark activity__mark--success"></span>
<span><strong class="nv-text-primary">Search</strong> recovered
<span style="display: block">09:31 · alert #879</span></span>
</li>
</ul>
</div>
</article>
<div class="nv-disclosure-group">
<details class="nv-disclosure" open>
<summary class="nv-disclosure__summary">
What changed at 14:00?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
One deploy landed in the window: <code class="nv-code">#4412</code>,
which moved tax resolution inside the cart loop.
</div>
</details>
<details class="nv-disclosure">
<summary class="nv-disclosure__summary">
Who is on call?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
Robin Kade until 18:00, then Sam Oyelaran. Escalation goes to
the platform channel after 10 minutes.
</div>
</details>
</div>
</div>
</div>
<footer class="nv-cluster nv-cluster--between" style="padding-block-start: var(--nv-space-6)">
<span class="nv-caption">Northwire Ops — an example interface built with Niveus.</span>
<span class="nv-caption">Made by Nexoniarz</span>
</footer>
</div>
</main>
</div>
<dialog class="nv-dialog" id="incident" aria-labelledby="incident-title">
<div class="nv-dialog__header">
<div>
<h2 class="nv-dialog__title" id="incident-title">Incident #881</h2>
<p class="nv-dialog__description">Notifications stopped responding at 14:12.</p>
</div>
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
aria-label="Close" data-nv-close>
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="nv-dialog__body">
<div class="nv-stack nv-stack--tight">
<div class="nv-cluster nv-cluster--tight">
<span class="nv-badge nv-badge--danger nv-badge--solid">Sev 2</span>
<span class="nv-badge">27 minutes open</span>
<span class="nv-badge">Robin Kade</span>
</div>
<p class="nv-body">
The queue consumer exited after the pricing service began timing out,
and did not restart because the health check still reported ready.
</p>
<div class="nv-field">
<label class="nv-field__label" for="note">Add a note</label>
<textarea class="nv-input" id="note" rows="3"
placeholder="What did you find?"></textarea>
</div>
</div>
</div>
<div class="nv-dialog__footer nv-dialog__footer--divided">
<button class="nv-button" data-nv-close>Close</button>
<button class="nv-button nv-button--danger">Page the team</button>
<button class="nv-button nv-button--primary" data-nv-close>Acknowledge</button>
</div>
</dialog>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("ops-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
const sidebar = document.querySelector('[data-nv-travel="local"]');
sidebar.addEventListener("click", (event) => {
const item = event.target.closest(".nv-nav__item");
if (!item || item.hasAttribute("aria-current")) return;
event.preventDefault();
nv.transition(() => {
sidebar.querySelectorAll("[aria-current]").forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
});
});
document.querySelectorAll("[data-range]").forEach((option) => {
option.addEventListener("click", () => {
document.querySelectorAll("[data-range]").forEach((other) => {
other.setAttribute("aria-checked", String(other === option));
});
document.getElementById("range-label").textContent = option.dataset.range;
document.getElementById("range-menu").hidePopover();
});
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,251 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Niveus — examples</title>
<link rel="stylesheet" href="../dist/niveus.css">
<script src="../niveus/niveus.js" defer></script>
<script>
try {
var saved = localStorage.getItem("niveus-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) {}
</script>
<style>
/* Layout only — see each example's app.css for the same discipline. */
.shell {
max-inline-size: 64rem;
margin-inline: auto;
padding: var(--nv-space-10) var(--nv-layout-gutter) var(--nv-space-20);
display: flex;
flex-direction: column;
gap: var(--nv-space-10);
}
.intro {
max-inline-size: var(--nv-measure);
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
}
.examples {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(19rem, 100%), 1fr));
gap: var(--nv-space-5);
}
/* Each card previews its own brand by re-deriving the set for itself. */
.swatches {
display: flex;
gap: var(--nv-space-1-5);
margin-block-start: var(--nv-space-1);
}
.swatches span {
inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
border-radius: var(--nv-radius-xs);
border: var(--nv-border-width) solid var(--nv-color-border);
}
.site-footer {
padding-block-start: var(--nv-space-8);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
</style>
</head>
<body>
<nav class="nv-nav" aria-label="Main">
<a class="nv-nav__brand" href="../index.html">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="6" stroke="currentColor" stroke-width="1.6"/>
<path d="M8 15.5V8.5l8 7v-7" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Niveus
</a>
<ul class="nv-nav__list">
<li><a class="nv-nav__item" href="../index.html">Specimen</a></li>
<li><span class="nv-nav__item" aria-current="page">Examples</span></li>
<li><a class="nv-nav__item" href="../REFERENCE.md">Reference</a></li>
</ul>
<span class="nv-nav__spacer"></span>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
</nav>
<main class="shell">
<header class="intro">
<span class="nv-overline">Examples</span>
<h1 class="nv-display">Four products, one system.</h1>
<p class="nv-prose">
Each of these is a complete interface built from Niveus and nothing else.
They look like different products because they are — but none of them
overrides a component, forks a token, or writes a color. What differs is
one declaration of <code class="nv-code">--nv-brand</code>, and the
layout each one needs for its own content.
</p>
<p class="nv-prose">
Open any <code class="nv-code">app.css</code> beside them: it contains
grids, widths and breakpoints, and not a single hex value. That split —
the system owns the language, the application owns the layout — is the
thing these examples are really demonstrating.
</p>
</header>
<div class="examples">
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #6d5efc">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">
<a class="nv-card__link" href="ai-landing/index.html"
style="color: inherit; text-decoration: none">Lumen</a>
</h2>
<p class="nv-card__subtitle">AI product landing page</p>
</div>
<span class="nv-badge nv-badge--accent">Marketing</span>
</div>
<div class="nv-card__body">
Hero, feature cards, a scrolling row, pricing and an FAQ. Shows the
bed as a showcase surface and the disclosure group.
<div class="swatches" aria-hidden="true">
<span style="background: var(--nv-color-accent)"></span>
<span style="background: var(--nv-color-accent-subtle)"></span>
<span style="background: var(--nv-color-surface)"></span>
<span style="background: var(--nv-color-background)"></span>
</div>
</div>
<div class="nv-card__footer">
<span class="nv-caption nv-code">--nv-brand: #6d5efc</span>
</div>
</article>
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #6d5efc">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">
<a class="nv-card__link" href="ai-chat/index.html"
style="color: inherit; text-decoration: none">Lumen Chat</a>
</h2>
<p class="nv-card__subtitle">AI chat application</p>
</div>
<span class="nv-badge nv-badge--accent">Web app</span>
</div>
<div class="nv-card__body">
A full app shell: sidebar with travelling selection, a transcript that
cross-fades between threads, a composer, and settings in a dialog with
tabs.
<div class="swatches" aria-hidden="true">
<span style="background: var(--nv-color-accent)"></span>
<span style="background: var(--nv-color-accent-subtle)"></span>
<span style="background: var(--nv-color-surface)"></span>
<span style="background: var(--nv-color-background)"></span>
</div>
</div>
<div class="nv-card__footer">
<span class="nv-caption nv-code">--nv-brand: #6d5efc</span>
</div>
</article>
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #ea580c">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">
<a class="nv-card__link" href="it-shop/index.html"
style="color: inherit; text-decoration: none">Northwire</a>
</h2>
<p class="nv-card__subtitle">IT hardware shop</p>
</div>
<span class="nv-badge nv-badge--accent">Commerce</span>
</div>
<div class="nv-card__body">
Filters, a product grid, stock states in the status colors, and a
basket in a dialog with a table. Same components, warm brand.
<div class="swatches" aria-hidden="true">
<span style="background: var(--nv-color-accent)"></span>
<span style="background: var(--nv-color-accent-subtle)"></span>
<span style="background: var(--nv-color-surface)"></span>
<span style="background: var(--nv-color-background)"></span>
</div>
</div>
<div class="nv-card__footer">
<span class="nv-caption nv-code">--nv-brand: #ea580c</span>
</div>
</article>
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #0d9488">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">
<a class="nv-card__link" href="dashboard/index.html"
style="color: inherit; text-decoration: none">Northwire Ops</a>
</h2>
<p class="nv-card__subtitle">Monitoring dashboard</p>
</div>
<span class="nv-badge nv-badge--accent">Data app</span>
</div>
<div class="nv-card__body">
Stat tiles, a chart drawn in semantic colors, a service table with
per-row menus, and tabs that filter it. The densest test of the system.
<div class="swatches" aria-hidden="true">
<span style="background: var(--nv-color-accent)"></span>
<span style="background: var(--nv-color-accent-subtle)"></span>
<span style="background: var(--nv-color-surface)"></span>
<span style="background: var(--nv-color-background)"></span>
</div>
</div>
<div class="nv-card__footer">
<span class="nv-caption nv-code">--nv-brand: #0d9488</span>
</div>
</article>
</div>
<div class="nv-bed">
<span class="nv-bed__label">How to read these</span>
<div class="nv-stack" style="max-inline-size: var(--nv-measure)">
<p class="nv-body">
Every example links exactly two files from the system —
<code class="nv-code">dist/niveus.css</code> and the optional
<code class="nv-code">niveus/niveus.js</code> — then its own
<code class="nv-code">app.css</code> for layout. Nothing else is shared
between them.
</p>
<p class="nv-body">
Where one of them needed something the system did not have, the answer
was to add it to the system, not to the page: the badge and table
components exist because these examples asked for them.
</p>
</div>
</div>
<footer class="site-footer">
<div class="nv-cluster nv-cluster--between">
<span class="nv-caption">Niveus — define the system once, implement it everywhere.</span>
<span class="nv-caption">Made by Nexoniarz</span>
</div>
</footer>
</main>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("niveus-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
});
</script>
</body>
</html>

View file

@ -0,0 +1,145 @@
/* ==========================================================================
Northwire — shop layout
--------------------------------------------------------------------------
Layout only. Note what this shop did *not* need in order to look like a
different product from the AI site next door: a second stylesheet, an
override file, or a single color. It set --nv-brand and kept going.
========================================================================== */
.shell {
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
.section {
padding-block: var(--nv-space-10);
}
.section__head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--nv-space-4);
margin-block-end: var(--nv-space-6);
}
/* --- Search sits inside the bar, so it must not grow forever --------------- */
.search {
flex: 1;
max-inline-size: 26rem;
min-inline-size: 8rem;
}
/* --- Catalogue -------------------------------------------------------------- */
.catalogue {
display: grid;
gap: var(--nv-space-8);
align-items: start;
}
@media (min-width: 60rem) {
.catalogue {
grid-template-columns: 15rem 1fr;
}
}
.filters {
display: flex;
flex-direction: column;
gap: var(--nv-space-6);
position: sticky;
inset-block-start: calc(var(--nv-control-height-lg) + var(--nv-space-6));
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--nv-space-2-5);
}
.products {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
gap: var(--nv-space-4);
}
/* --- Product ------------------------------------------------------------------
The illustration tile is a bed: it gives the line art a ground that is
visibly not the card, without inventing a colour for it. */
.product__art {
display: grid;
place-items: center;
aspect-ratio: 4 / 3;
border-radius: 0;
border-inline: 0;
border-block-start: 0;
padding: var(--nv-space-4);
}
.product__art svg {
inline-size: 60%;
max-inline-size: 8rem;
color: var(--nv-color-text-tertiary);
}
.product__price {
font-size: var(--nv-font-size-lg);
font-weight: var(--nv-font-weight-semibold);
color: var(--nv-color-text-primary);
font-variant-numeric: tabular-nums;
}
.product__was {
font: var(--nv-font-caption);
color: var(--nv-color-text-tertiary);
text-decoration: line-through;
}
/* --- Cart ---------------------------------------------------------------------- */
.cart-line__art {
inline-size: var(--nv-space-10);
block-size: var(--nv-space-10);
display: grid;
place-items: center;
border-radius: var(--nv-radius-sm);
background-color: var(--nv-color-surface-inset);
box-shadow: var(--nv-elevation-inset-sm);
color: var(--nv-color-text-tertiary);
}
.cart-line__art svg {
inline-size: 60%;
}
.cart-totals {
display: flex;
flex-direction: column;
gap: var(--nv-space-2);
padding-block-start: var(--nv-space-4);
}
.cart-totals__row {
display: flex;
justify-content: space-between;
gap: var(--nv-space-4);
font-variant-numeric: tabular-nums;
}
.cart-totals__row--due {
padding-block-start: var(--nv-space-3);
border-block-start: var(--nv-border-width) solid var(--nv-color-border);
font-size: var(--nv-font-size-lg);
font-weight: var(--nv-font-weight-semibold);
color: var(--nv-color-text-primary);
}
.site-footer {
padding-block: var(--nv-space-8) var(--nv-space-12);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}

View file

@ -0,0 +1,519 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Northwire — an IT shop built with Niveus</title>
<link rel="stylesheet" href="../../dist/niveus.css">
<script src="../../niveus/niveus.js" defer></script>
<link rel="stylesheet" href="app.css">
<script>
try {
var saved = localStorage.getItem("northwire-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) {}
</script>
<!-- A shop, not an AI product. One declaration is the whole difference. -->
<style>
:root { --nv-brand: #ea580c; }
</style>
</head>
<body>
<nav class="nv-nav" id="topnav" aria-label="Main" style="--nv-selection-name: nv-shop-nav">
<a class="nv-nav__brand" href="#top">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16l-1.5 12h-13z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M9 7V5.5a3 3 0 016 0V7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
Northwire
</a>
<div class="nv-input-group search">
<span class="nv-input-group__affix">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" style="inline-size: 1em; block-size: 1em">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="2"/>
<path d="M16 16l4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</span>
<label class="nv-visually-hidden" for="search">Search products</label>
<input class="nv-input nv-input--sm" id="search" type="search" placeholder="Search 4,200 products…">
</div>
<span class="nv-nav__spacer"></span>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
<button class="nv-button nv-button--sm nv-button--primary" data-nv-dialog="basket">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16l-1.5 12h-13z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
Basket
<span class="nv-badge nv-badge--count nv-badge--solid" id="basket-count">3</span>
</button>
</nav>
<main id="top">
<section class="shell section">
<div class="nv-bed nv-bed--single">
<div class="nv-cluster nv-cluster--between">
<div style="max-inline-size: var(--nv-measure)">
<span class="nv-overline">Clearance</span>
<h1 class="nv-heading" style="margin-block: var(--nv-space-2)">
Ex-demo workstations, checked and warrantied.
</h1>
<p class="nv-prose">
Everything here has been bench-tested and comes with the same
two-year return window as new stock.
</p>
</div>
<button class="nv-button nv-button--lg nv-button--primary">See all 74 deals</button>
</div>
</div>
</section>
<section class="shell section">
<div class="section__head">
<div>
<span class="nv-overline">This week</span>
<h2 class="nv-heading">Reduced while stock lasts</h2>
</div>
</div>
<div class="nv-scroller-frame" data-nv-scroller>
<div class="nv-scroller" style="--nv-scroller-item: 15rem">
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Forge 14 Developer</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Ryzen 9 · 32 GB · 1 TB NVMe</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$1,849</p>
</div>
</article>
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4.5" width="18" height="12" rx="1.8" stroke="currentColor" stroke-width="1.6"/><path d="M9.5 19.5h5M12 16.5v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Clarity 27 4K</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">27″ · 3840×2160 · 144 Hz</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$629</p>
</div>
</article>
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Anvil TKL</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Tactile · hot-swap · USB-C</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$139</p>
</div>
</article>
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7.5" width="19" height="9" rx="1.6" stroke="currentColor" stroke-width="1.6"/><circle cx="8.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/><circle cx="15.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Halo X900 16 GB</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Triple fan · 2.6 GHz boost</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$1,099</p>
</div>
</article>
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Vault 4 TB NVMe</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Gen4 · 7,400 MB/s read</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$289</p>
</div>
</article>
</div>
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="prev" aria-label="Previous">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M14 6l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="next" aria-label="Next">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M10 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</section>
<section class="shell section" id="catalogue">
<div class="catalogue">
<aside class="filters" aria-label="Filters">
<div class="filter-group">
<span class="nv-overline">Category</span>
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox" checked> Workstations</label>
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox" checked> Displays</label>
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox"> Networking</label>
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox"> Peripherals</label>
</div>
<hr class="nv-divider" style="margin: 0">
<div class="filter-group">
<span class="nv-overline">Price</span>
<div class="nv-cluster nv-cluster--tight" style="flex-wrap: nowrap">
<div class="nv-input-group">
<span class="nv-input-group__affix">$</span>
<label class="nv-visually-hidden" for="min">Minimum price</label>
<input class="nv-input nv-input--sm" id="min" inputmode="numeric" value="0">
</div>
<span class="nv-caption">to</span>
<div class="nv-input-group">
<span class="nv-input-group__affix">$</span>
<label class="nv-visually-hidden" for="max">Maximum price</label>
<input class="nv-input nv-input--sm" id="max" inputmode="numeric" value="2000">
</div>
</div>
</div>
<hr class="nv-divider" style="margin: 0">
<div class="filter-group">
<span class="nv-overline">Availability</span>
<label class="nv-choice nv-caption"><input class="nv-switch" type="checkbox" checked> In stock only</label>
<label class="nv-choice nv-caption"><input class="nv-switch" type="checkbox"> Ex-demo</label>
</div>
<hr class="nv-divider" style="margin: 0">
<div class="filter-group">
<span class="nv-overline">Condition</span>
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition" checked> Any</label>
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition"> New</label>
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition"> Refurbished</label>
</div>
<button class="nv-button nv-button--ghost nv-button--sm">Clear filters</button>
</aside>
<div>
<div class="section__head">
<div>
<h2 class="nv-title">Workstations &amp; displays</h2>
<p class="nv-caption">8 of 412 products</p>
</div>
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="sort-menu"
style="--nv-anchor: --sort">
Sort: <span id="sort-label">Relevance</span>
<span class="nv-disclosure__chevron" aria-hidden="true"
style="margin-inline-start: var(--nv-space-1)"></span>
</button>
<div class="nv-menu" popover id="sort-menu" style="--nv-anchor: --sort">
<button class="nv-menu__item" aria-checked="true" data-sort="Relevance">Relevance</button>
<button class="nv-menu__item" data-sort="Price, low to high">Price, low to high</button>
<button class="nv-menu__item" data-sort="Price, high to low">Price, high to low</button>
<button class="nv-menu__item" data-sort="Newest">Newest</button>
</div>
</div>
<div class="products">
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Forge 14 Developer</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Ryzen 9 · 32 GB · 1 TB NVMe</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$1,849</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Forge 14 Developer">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4.5" width="18" height="12" rx="1.8" stroke="currentColor" stroke-width="1.6"/><path d="M9.5 19.5h5M12 16.5v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Clarity 27 4K</h3>
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>4 left</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">27″ · 3840×2160 · 144 Hz</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$629</span>
<span class="product__was">$749</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Clarity 27 4K">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Anvil TKL</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Tactile · hot-swap · USB-C</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$139</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Anvil TKL">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7.5" width="19" height="9" rx="1.6" stroke="currentColor" stroke-width="1.6"/><circle cx="8.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/><circle cx="15.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Halo X900 16 GB</h3>
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Backorder</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Triple fan · 2.6 GHz boost</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$1,099</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Halo X900 16 GB">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Vault 4 TB NVMe</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Gen4 · 7,400 MB/s read</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$289</span>
<span class="product__was">$329</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Vault 4 TB NVMe">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="13" width="18" height="6" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M7 16.5h.01M10 16.5h.01M8 9.5a5 5 0 018 0M11 6.2a8.5 8.5 0 012 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Mesh Six Pro</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Wi-Fi 6E · 3-pack · 2.5 GbE</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$399</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Mesh Six Pro">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 15v-3a7 7 0 0114 0v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect x="3" y="14" width="4" height="6" rx="1.6" stroke="currentColor" stroke-width="1.6"/><rect x="17" y="14" width="4" height="6" rx="1.6" stroke="currentColor" stroke-width="1.6"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Quiet Field 2</h3>
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>2 left</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">ANC · 40 h · dual link</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$219</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Quiet Field 2">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="9" width="18" height="7" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M7 12.5h2M11 12.5h2M15.5 12.5h2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Bridge 13-in-1</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Thunderbolt 4 · 96 W PD</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$179</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Bridge 13-in-1">Add to basket</button>
</div>
</article>
</div>
</div>
</div>
</section>
<footer class="shell site-footer">
<div class="nv-cluster nv-cluster--between">
<span class="nv-caption">Northwire — an example interface built with Niveus.</span>
<span class="nv-caption">Made by Nexoniarz</span>
</div>
</footer>
</main>
<dialog class="nv-dialog nv-dialog--wide" id="basket" aria-labelledby="basket-title">
<div class="nv-dialog__header">
<div>
<h2 class="nv-dialog__title" id="basket-title">Your basket</h2>
<p class="nv-dialog__description">Three items · delivered Thursday</p>
</div>
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
aria-label="Close" data-nv-close>
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="nv-dialog__body">
<div class="nv-table-frame">
<table class="nv-table">
<thead>
<tr>
<th scope="col">Item</th>
<th scope="col" class="nv-table__number">Qty</th>
<th scope="col" class="nv-table__number">Price</th>
<th scope="col"><span class="nv-visually-hidden">Remove</span></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="nv-cluster" style="flex-wrap: nowrap">
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span>
Forge 14 Developer
<span class="nv-caption" style="display: block">Ryzen 9 · 32 GB</span>
</span>
</div>
</td>
<td class="nv-table__number">1</td>
<td class="nv-table__number">$1,849</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Forge 14 Developer">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</td>
</tr>
<tr>
<td>
<div class="nv-cluster" style="flex-wrap: nowrap">
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span>
Vault 4 TB NVMe
<span class="nv-caption" style="display: block">Gen4 NVMe</span>
</span>
</div>
</td>
<td class="nv-table__number">1</td>
<td class="nv-table__number">$289</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Vault 4 TB NVMe">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</td>
</tr>
<tr>
<td>
<div class="nv-cluster" style="flex-wrap: nowrap">
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span>
Anvil TKL
<span class="nv-caption" style="display: block">Tactile · hot-swap</span>
</span>
</div>
</td>
<td class="nv-table__number">1</td>
<td class="nv-table__number">$139</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Anvil TKL">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cart-totals">
<div class="cart-totals__row nv-caption"><span>Subtotal</span><span>$2,277.00</span></div>
<div class="cart-totals__row nv-caption"><span>Delivery</span><span>Free</span></div>
<div class="cart-totals__row nv-caption"><span>VAT (20%)</span><span>$455.40</span></div>
<div class="cart-totals__row cart-totals__row--due"><span>Due today</span><span>$2,732.40</span></div>
</div>
</div>
<div class="nv-dialog__footer nv-dialog__footer--divided">
<button class="nv-button" data-nv-close>Keep shopping</button>
<button class="nv-button nv-button--primary">Checkout</button>
</div>
</dialog>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("northwire-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
/* Adding to the basket: the count changes, so it should be watchable. */
const count = document.getElementById("basket-count");
document.querySelectorAll("[data-add]").forEach((button) => {
button.addEventListener("click", () => {
nv.transition(() => {
count.textContent = String(Number(count.textContent) + 1);
});
button.textContent = "Added";
setTimeout(() => { button.textContent = "Add to basket"; }, 1200);
});
});
document.querySelectorAll("[data-sort]").forEach((option) => {
option.addEventListener("click", () => {
document.querySelectorAll("[data-sort]").forEach((other) => {
other.setAttribute("aria-checked", String(other === option));
});
document.getElementById("sort-label").textContent = option.dataset.sort;
document.getElementById("sort-menu").hidePopover();
});
});
const topnav = document.getElementById("topnav");
const onScroll = () => topnav.toggleAttribute("data-scrolled", scrollY > 8);
addEventListener("scroll", onScroll, { passive: true });
onScroll();
});
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,197 @@
/* ==========================================================================
Niveus — Base
--------------------------------------------------------------------------
The minimum an application needs before any component renders: a predictable
box model, the canvas, the default type, and the system-wide focus and
selection treatment.
This layer styles elements, never classes with intent. Anything opinionated
enough to need a name belongs in components/.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100svh;
background-color: var(--nv-color-background);
color: var(--nv-color-text-primary);
font: var(--nv-font-body);
letter-spacing: var(--nv-letter-spacing-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-synthesis-weight: none;
/* The canvas glides into a new appearance with everything standing on it,
rather than cutting while the surfaces above it fade. */
transition: var(--nv-transition-colors);
}
/* --- Rhythm ---------------------------------------------------------------
Block elements carry no outer margin of their own; spacing between them is
the layout's decision, set with gap or the spacing tokens. */
h1, h2, h3, h4, h5, h6,
p, figure, blockquote, dl, dd, pre {
margin: 0;
}
h1, h2, h3, h4, h5, h6 {
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-tight);
line-height: var(--nv-line-height-snug);
text-wrap: balance;
}
h1 { font-size: var(--nv-font-size-3xl); letter-spacing: var(--nv-letter-spacing-tighter); }
h2 { font-size: var(--nv-font-size-2xl); }
h3 { font-size: var(--nv-font-size-xl); }
h4 { font-size: var(--nv-font-size-lg); }
h5 { font-size: var(--nv-font-size-md); }
h6 { font-size: var(--nv-font-size-sm); }
p {
text-wrap: pretty;
}
small {
font-size: var(--nv-font-size-xs);
}
code, kbd, samp, pre {
font-family: var(--nv-font-family-mono);
font-size: var(--nv-font-size-sm);
}
/* --- Links ----------------------------------------------------------------- */
a {
color: var(--nv-color-accent-text);
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
text-underline-offset: 0.18em;
transition: var(--nv-transition-colors);
}
a:hover {
text-decoration-color: currentColor;
}
/* --- Media ----------------------------------------------------------------- */
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
max-width: 100%;
}
svg {
flex: none;
}
hr {
border: 0;
border-top: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin: 0;
}
/* --- Forms -----------------------------------------------------------------
Form controls do not inherit type by default; without this every field in
the system would silently fall back to the browser's own font. */
button, input, optgroup, select, textarea {
font: inherit;
color: inherit;
margin: 0;
}
textarea {
resize: vertical;
}
:disabled {
cursor: not-allowed;
}
/* --- Focus -----------------------------------------------------------------
One focus treatment for the whole system. Components that need the ring to
follow their own shape override box-shadow, not outline-color. */
:focus-visible {
outline: var(--nv-focus-ring-width) solid var(--nv-color-focus-ring);
outline-offset: 2px;
border-radius: var(--nv-radius-xs);
}
::selection {
background-color: var(--nv-color-selection);
color: var(--nv-color-text-primary);
}
/* --- Scrollbars ------------------------------------------------------------ */
* {
scrollbar-color: var(--nv-color-border-strong) transparent;
scrollbar-width: thin;
}
/* ==========================================================================
Type roles
--------------------------------------------------------------------------
Named roles from tokens/typography.css, available as classes so hierarchy
stays consistent even when the markup cannot use a heading element.
========================================================================== */
.nv-display {
font: var(--nv-font-display);
letter-spacing: var(--nv-letter-spacing-tighter);
text-wrap: balance;
}
.nv-heading {
font: var(--nv-font-heading);
letter-spacing: var(--nv-letter-spacing-tight);
text-wrap: balance;
}
.nv-title {
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
}
.nv-body {
font: var(--nv-font-body);
text-wrap: pretty;
}
.nv-label {
font: var(--nv-font-label);
}
.nv-caption {
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
}
.nv-overline {
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
.nv-code {
font: var(--nv-font-code);
}
/* Text color roles, for when the role is about emphasis rather than size. */
.nv-text-primary { color: var(--nv-color-text-primary); }
.nv-text-secondary { color: var(--nv-color-text-secondary); }
.nv-text-tertiary { color: var(--nv-color-text-tertiary); }
.nv-text-accent { color: var(--nv-color-accent-text); }
.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); }

View file

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

View file

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

View file

@ -0,0 +1,282 @@
/* ==========================================================================
Niveus — Button
--------------------------------------------------------------------------
.nv-button secondary by default — a raised surface
.nv-button--primary the single affirmative action in a view
.nv-button--ghost low-emphasis, no material until hovered
.nv-button--danger destructive, and only when it is
.nv-button--frosted for use over imagery or frosted chrome
.nv-button--sm | --lg 36px by default
.nv-button--icon square, for a lone icon (needs a label)
.nv-button--block fills its container
Every variant is defined purely by reassigning the private --_* properties
below. Interaction — the lift on hover, the sink on press, the focus ring —
lives once in the base rule, so a new variant can never drift from the way
the rest of the system responds to a pointer.
========================================================================== */
.nv-button {
/* Variant surface, overridden per modifier. */
--_bg: var(--nv-color-surface-elevated);
--_bg-hover: var(--nv-color-surface-hover);
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-primary);
--_border: var(--nv-color-border);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
--_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-3);
--_shadow-active: var(--nv-elevation-inset-sm);
/* Size, overridden per size modifier. */
--_height: var(--nv-control-height-md);
--_padding: var(--nv-control-padding-md);
--_gap: var(--nv-control-gap-md);
--_font-size: var(--nv-font-size-sm);
--_radius: var(--nv-radius-md);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--_gap);
block-size: var(--_height);
padding-inline: var(--_padding);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--_radius);
background-color: var(--_bg);
color: var(--_fg);
box-shadow: var(--_shadow);
font-family: var(--nv-font-family-sans);
font-size: var(--_font-size);
font-weight: var(--nv-font-weight-medium);
line-height: var(--nv-line-height-snug);
letter-spacing: var(--nv-letter-spacing-normal);
white-space: nowrap;
text-decoration: none;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: var(--nv-transition-control);
}
/* A control smaller than the minimum target still gets a full-size hit area:
the pseudo-element grows outward without changing the visual size. */
.nv-button::after {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
translate: -50% -50%;
inline-size: max(100%, var(--nv-target-min));
block-size: max(100%, var(--nv-target-min));
}
@media (hover: hover) {
.nv-button:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-hover);
box-shadow: var(--_shadow-hover);
translate: 0 var(--nv-lift-hover);
}
}
.nv-button:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-active);
box-shadow: var(--_shadow-active);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
}
.nv-button:focus-visible {
outline: none;
box-shadow: var(--_shadow), var(--nv-focus-ring);
}
.nv-button:disabled,
.nv-button[aria-disabled="true"] {
background-color: var(--nv-color-surface-inset);
color: var(--nv-color-text-disabled);
border-color: var(--nv-color-border-subtle);
box-shadow: none;
translate: none;
cursor: not-allowed;
}
/* --- Variants --------------------------------------------------------------- */
.nv-button--primary {
--_bg: var(--nv-color-accent);
--_bg-hover: var(--nv-color-accent-hover);
--_bg-active: var(--nv-color-accent-active);
--_fg: var(--nv-color-on-accent);
--_border: transparent;
font-weight: var(--nv-font-weight-semibold);
}
.nv-button--danger {
--_bg: var(--nv-color-danger);
--_bg-hover: var(--nv-color-danger-hover);
--_bg-active: var(--nv-color-danger-active);
--_fg: var(--nv-color-on-danger);
--_border: transparent;
--nv-color-focus-ring: var(--nv-color-danger-border);
font-weight: var(--nv-font-weight-semibold);
}
.nv-button--ghost {
--_bg: transparent;
--_bg-hover: var(--nv-color-surface-hover);
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-secondary);
--_border: transparent;
--_shadow: none;
--_shadow-hover: none;
--_shadow-active: var(--nv-elevation-inset-sm);
}
@media (hover: hover) {
.nv-button--ghost:hover:not(:disabled, [aria-disabled="true"]) {
color: var(--nv-color-text-primary);
translate: none; /* nothing to lift — a ghost has no material to raise */
}
}
.nv-button--subtle {
--_bg: var(--nv-color-accent-subtle);
--_bg-hover: var(--nv-color-accent-subtle-hover);
--_bg-active: var(--nv-color-accent-subtle-hover);
--_fg: var(--nv-color-accent-text);
--_border: var(--nv-color-accent-border);
--_shadow: none;
--_shadow-hover: var(--nv-elevation-1);
}
.nv-button--frosted {
--_bg: var(--nv-material-glass-bg);
--_bg-hover: var(--nv-material-frosted-bg);
--_bg-active: var(--nv-material-frosted-bg);
--_border: var(--nv-material-glass-border);
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Sizes ------------------------------------------------------------------- */
.nv-button--sm {
--_height: var(--nv-control-height-sm);
--_padding: var(--nv-control-padding-sm);
--_gap: var(--nv-control-gap-sm);
--_font-size: var(--nv-font-size-xs);
--_radius: var(--nv-radius-sm);
}
.nv-button--lg {
--_height: var(--nv-control-height-lg);
--_padding: var(--nv-control-padding-lg);
--_gap: var(--nv-control-gap-lg);
--_font-size: var(--nv-font-size-md);
}
.nv-button--icon {
--_padding: 0;
inline-size: var(--_height);
}
.nv-button--pill {
--_radius: var(--nv-radius-full);
}
.nv-button--block {
display: flex;
inline-size: 100%;
}
/* --- Floating ------------------------------------------------------------------
A control that sits *over* content rather than in the flow: a carousel arrow,
a floating action. Two things change.
It is already off the surface, so there is nothing to lift it from — it grows
instead. And it is almost always positioned with `translate`, which the hover
lift would overwrite, throwing the button half its own height down the page
the moment a pointer touched it. --nv-button-offset carries that positioning
through every state. */
.nv-button--float {
--_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
--_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-4);
--_shadow-active: var(--nv-highlight-top), var(--nv-elevation-2);
translate: var(--nv-button-offset, 0 0);
}
@media (hover: hover) {
.nv-button--float:hover:not(:disabled, [aria-disabled="true"]) {
translate: var(--nv-button-offset, 0 0);
scale: 1.06;
}
}
.nv-button--float:active:not(:disabled, [aria-disabled="true"]) {
translate: var(--nv-button-offset, 0 0);
scale: 0.97;
}
/* --- Parts --------------------------------------------------------------------- */
.nv-button__icon {
inline-size: 1em;
block-size: 1em;
font-size: 1.15em;
opacity: 0.9;
}
/* Busy: the label stays in place to keep the button from resizing mid-action,
and the spinner replaces it. */
.nv-button[aria-busy="true"] {
color: transparent;
pointer-events: none;
}
.nv-button[aria-busy="true"]::before {
content: "";
position: absolute;
inline-size: 1em;
block-size: 1em;
border: 2px solid var(--_fg);
border-block-start-color: transparent;
border-radius: var(--nv-radius-full);
animation: nv-spin var(--nv-duration-slower) var(--nv-ease-linear) infinite;
}
@keyframes nv-spin {
to { rotate: 1turn; }
}
/* --- Group ---------------------------------------------------------------------
Segmented controls: buttons share an edge, so only the outer corners round
and the shared border is not drawn twice. */
.nv-button-group {
display: inline-flex;
align-items: center;
}
.nv-button-group > .nv-button:not(:first-child) {
border-start-start-radius: 0;
border-end-start-radius: 0;
margin-inline-start: calc(var(--nv-border-width) * -1);
}
.nv-button-group > .nv-button:not(:last-child) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.nv-button-group > .nv-button:hover,
.nv-button-group > .nv-button:focus-visible {
z-index: 1;
translate: none;
}

View file

@ -0,0 +1,171 @@
/* ==========================================================================
Niveus — Card
--------------------------------------------------------------------------
.nv-card resting surface, elevation 1
.nv-card--elevated separated from its context, elevation 2
.nv-card--inset recessed into its parent
.nv-card--frosted over imagery or scrolling content
.nv-card--interactive the whole card is one target
.nv-card__header / __body / __footer / __media
A card is a container, not a look. If a card needs a shadow that is not on
the elevation scale, the answer is a different elevation — not a different
shadow.
========================================================================== */
.nv-card {
--_bg: var(--nv-color-surface);
--_border: var(--nv-color-border-subtle);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-1);
--_padding: var(--nv-space-5);
position: relative;
display: flex;
flex-direction: column;
min-inline-size: 0;
background-color: var(--_bg);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--nv-radius-lg);
box-shadow: var(--_shadow);
/* Children are clipped to the corner radius so media and inset regions
cannot square off the card. */
overflow: hidden;
transition: var(--nv-transition-surface);
}
.nv-card--elevated {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
}
.nv-card--floating {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
}
.nv-card--inset {
--_bg: var(--nv-color-surface-inset);
--_shadow: var(--nv-elevation-inset-md);
--_border: var(--nv-color-border-subtle);
}
.nv-card--frosted {
--_bg: var(--nv-material-frosted-bg);
--_border: var(--nv-material-frosted-border);
--_shadow: var(--nv-material-frosted-shadow);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
.nv-card--flush {
--_padding: 0;
}
/* --- Interactive ------------------------------------------------------------
Rises on hover and sinks on press, exactly like a button — one physical
language for anything that responds to a pointer. */
.nv-card--interactive {
cursor: pointer;
text-align: start;
text-decoration: none;
}
@media (hover: hover) {
.nv-card--interactive:hover {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
translate: 0 calc(var(--nv-lift-hover) * 2);
}
}
.nv-card--interactive:active {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
}
.nv-card--interactive:focus-visible {
outline: none;
box-shadow: var(--_shadow), var(--nv-focus-ring);
}
/* Cards whose whole area is clickable via a link inside: the link's target is
stretched over the card, so the markup stays semantic and only the link text
is announced. */
.nv-card__link::after {
content: "";
position: absolute;
inset: 0;
}
/* --- Parts ------------------------------------------------------------------- */
.nv-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--nv-space-4);
padding: var(--_padding);
padding-block-end: var(--nv-space-3);
}
.nv-card__title {
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
margin: 0;
}
.nv-card__subtitle {
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
margin-block-start: var(--nv-space-1);
}
.nv-card__body {
flex: 1;
padding: var(--_padding);
color: var(--nv-color-text-secondary);
font: var(--nv-font-body);
}
/* A header already opened the block, so the body should not open it again. */
.nv-card__header + .nv-card__body {
padding-block-start: 0;
}
.nv-card__footer {
display: flex;
align-items: center;
gap: var(--nv-space-2);
padding: var(--_padding);
padding-block-start: var(--nv-space-3);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block-start: auto;
}
.nv-card__footer--plain {
border-block-start: 0;
padding-block-start: 0;
}
.nv-card__media {
display: block;
inline-size: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background-color: var(--nv-color-surface-inset);
}
/* --- Group ---------------------------------------------------------------------
Equal-height cards on one rhythm, collapsing to a single column without a
media query. */
.nv-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: var(--nv-space-4);
align-items: stretch;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,349 @@
/* ==========================================================================
Niveus — Navigation
--------------------------------------------------------------------------
.nv-nav horizontal bar, frosted, sticky by default
.nv-nav--vertical sidebar
.nv-nav--plain solid instead of frosted
.nv-nav__brand / __list / __item / __section / __spacer
.nv-nav-tabs in-page tab strip with a sliding indicator
Navigation is chrome: it sits above content rather than in it. That is why
the bar is frosted — the blur is not decoration, it is the signal that
content is passing underneath. A sidebar that scrolls with the page is not
chrome, so .nv-nav--vertical drops the translucency by default.
========================================================================== */
.nv-nav {
position: sticky;
inset-block-start: 0;
z-index: 10;
display: flex;
align-items: center;
gap: var(--nv-space-4);
padding: var(--nv-space-2) var(--nv-space-4);
min-block-size: var(--nv-control-height-lg);
background-color: var(--nv-material-frosted-bg);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
/* The travelling selection marker sits behind its item at z-index -1; the
bar has to be its own stacking context or the marker would fall behind
the bar's background and disappear. */
isolation: isolate;
box-shadow: var(--nv-highlight-top);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
transition: var(--nv-transition-surface);
}
/* The bar only earns a shadow once something is behind it. Set data-scrolled
from the application on scroll. */
.nv-nav[data-scrolled] {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
transition: box-shadow var(--nv-duration-normal) var(--nv-ease-standard);
}
.nv-nav--plain {
background-color: var(--nv-color-surface);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-nav--static {
position: static;
}
/* A navigation group used inside content rather than as page chrome: no bar,
no border, no blur — just the items and their travelling selection. */
.nv-nav--inline {
position: static;
display: inline-flex;
min-block-size: 0;
padding: var(--nv-space-1);
background-color: transparent;
border-block-end: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-nav__brand {
display: flex;
align-items: center;
gap: var(--nv-space-2);
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
color: var(--nv-color-text-primary);
text-decoration: none;
padding-inline: var(--nv-space-1);
}
.nv-nav__list {
display: flex;
align-items: center;
gap: var(--nv-space-1);
margin: 0;
padding: 0;
list-style: none;
min-inline-size: 0;
}
.nv-nav__spacer {
flex: 1;
}
/* --- Item --------------------------------------------------------------------
Items are flat until they are touched. The active item is the only one with
material, which keeps the bar quiet while still answering "where am I". */
.nv-nav__item {
/* Rides above a travelling marker once niveus.js has given it a name. */
view-transition-class: nv-carried;
position: relative;
display: flex;
align-items: center;
gap: var(--nv-space-2);
padding: var(--nv-space-2) var(--nv-space-3);
min-block-size: var(--nv-control-height-md);
border: var(--nv-border-width) solid transparent;
border-radius: var(--nv-radius-md);
background-color: transparent;
color: var(--nv-color-text-secondary);
font: var(--nv-font-label);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: var(--nv-transition-control);
}
@media (hover: hover) {
.nv-nav__item:hover {
background-color: var(--nv-color-surface-hover);
color: var(--nv-color-text-primary);
}
}
.nv-nav__item:active {
background-color: var(--nv-color-surface-active);
box-shadow: var(--nv-elevation-inset-sm);
transition-duration: var(--nv-duration-instant);
}
.nv-nav__item:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
/* Selection is a material that *moves*. The emphasis lives in a pseudo-element
rather than on the item itself, and carries a view-transition-name: when the
selection changes inside a transition, the browser interpolates the marker's
old box into its new one, so the highlight slides from the previous item to
the next instead of blinking out and in somewhere else.
Only one item in a group may be selected at a time — that is what keeps the
name unique. A page with several selection groups must give each its own
--nv-selection-name, or the duplicate name aborts the transition. */
/* Selection changes color and gains material — but not weight. Bolding the
selected item re-measures its text, which reflows every item beside it on
each change, and makes the old and new labels cross-fade against each other
at different widths. The pill is the cue; the weight was costing a flicker
for nothing. */
.nv-nav__item[aria-current],
.nv-nav__item--active,
.nv-nav__item[aria-selected="true"] {
color: var(--nv-color-text-primary);
}
.nv-nav__item[aria-current]::before,
.nv-nav__item--active::before,
.nv-nav__item[aria-selected="true"]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-color: var(--nv-color-surface-elevated);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: inherit;
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
view-transition-name: var(--nv-selection-name);
view-transition-class: nv-travelling;
}
/* Hovering an already-selected item should not restate the emphasis. */
@media (hover: hover) {
.nv-nav__item[aria-current]:hover,
.nv-nav__item--active:hover,
.nv-nav__item[aria-selected="true"]:hover {
background-color: transparent;
}
}
.nv-nav__item[aria-disabled="true"] {
color: var(--nv-color-text-disabled);
pointer-events: none;
}
.nv-nav__icon {
inline-size: 1.15em;
block-size: 1.15em;
flex: none;
opacity: 0.85;
}
.nv-nav__badge {
margin-inline-start: auto;
padding-inline: var(--nv-space-1-5);
min-inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--nv-color-accent-subtle);
color: var(--nv-color-accent-text);
border-radius: var(--nv-radius-full);
font: var(--nv-font-caption);
font-size: var(--nv-font-size-2xs);
font-weight: var(--nv-font-weight-semibold);
}
/* --- Vertical ------------------------------------------------------------------ */
.nv-nav--vertical {
position: sticky;
inset-block-start: 0;
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-1);
inline-size: 15rem;
block-size: 100svh;
padding: var(--nv-space-4) var(--nv-space-3);
background-color: var(--nv-color-background-subtle);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border-block-end: 0;
border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: none;
overflow-y: auto;
}
.nv-nav--vertical .nv-nav__list {
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-0-5);
}
.nv-nav--vertical .nv-nav__item {
justify-content: flex-start;
}
.nv-nav--vertical .nv-nav__brand {
padding: var(--nv-space-2) var(--nv-space-3);
margin-block-end: var(--nv-space-3);
}
.nv-nav__section {
padding: var(--nv-space-4) var(--nv-space-3) var(--nv-space-1-5);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
.nv-nav__section:first-child {
padding-block-start: 0;
}
/* --- Tabs ------------------------------------------------------------------------
For switching views inside a page, not for moving between pages. The strip
itself is inset, so the selected tab reads as lifted out of the track. */
.nv-nav-tabs {
display: inline-flex;
align-items: center;
gap: var(--nv-space-0-5);
padding: var(--nv-space-1);
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-elevation-inset-sm);
overflow-x: auto;
scrollbar-width: none;
isolation: isolate;
}
.nv-nav-tabs::-webkit-scrollbar {
display: none;
}
.nv-nav-tabs .nv-nav__item {
border-radius: var(--nv-radius-sm);
min-block-size: var(--nv-control-height-sm);
padding-block: var(--nv-space-1);
}
/* Tabs are a selection group of their own, so they get their own marker name;
sharing the navigation bar's would mean two visible elements with the same
view-transition-name. */
.nv-nav-tabs {
--nv-selection-name: nv-tabs-selection;
}
.nv-nav-tabs .nv-nav__item[aria-selected="true"]::before {
border-radius: var(--nv-radius-sm);
}
/* --- Panels ----------------------------------------------------------------------
Switching tabs should feel like moving through one surface rather than
swapping the interface. The panel carries the shared content name, so the
outgoing and incoming panels cross-fade inside the same box — and slide
with the direction of travel when the application supplies one. */
.nv-tab-panel {
view-transition-name: var(--nv-content-name, nv-content);
}
/* --- Breadcrumbs -------------------------------------------------------------------- */
.nv-breadcrumbs {
display: flex;
align-items: center;
gap: var(--nv-space-2);
margin: 0;
padding: 0;
list-style: none;
font: var(--nv-font-caption);
color: var(--nv-color-text-tertiary);
}
.nv-breadcrumbs li + li::before {
content: "/";
margin-inline-end: var(--nv-space-2);
color: var(--nv-color-border-strong);
}
.nv-breadcrumbs a {
color: var(--nv-color-text-secondary);
text-decoration: none;
}
.nv-breadcrumbs a:hover {
color: var(--nv-color-text-primary);
}
.nv-breadcrumbs [aria-current] {
color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-medium);
}

View file

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

View file

@ -0,0 +1,116 @@
/* ==========================================================================
Niveus — Surface
--------------------------------------------------------------------------
The material vocabulary, expressed as classes. Every other component is
built from these; applications use them directly for anything the system
does not ship as a named component.
.nv-surface a container using the solid material
.nv-material-{type} apply a material to any element
.nv-elevation-{0..5} apply a depth level to any element
A material and an elevation are independent: .nv-material-solid
.nv-elevation-3 is a legitimate combination. What is not legitimate is a
raw box-shadow that belongs to neither scale.
========================================================================== */
.nv-surface {
background-color: var(--nv-color-surface);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: var(--nv-radius-lg);
transition: var(--nv-transition-surface);
}
/* --- Materials ------------------------------------------------------------- */
.nv-material-solid {
background-color: var(--nv-material-solid-bg);
border: var(--nv-border-width) solid var(--nv-material-solid-border);
box-shadow: var(--nv-material-solid-shadow);
backdrop-filter: none;
}
.nv-material-elevated {
background-color: var(--nv-material-elevated-bg);
border: var(--nv-border-width) solid var(--nv-material-elevated-border);
box-shadow: var(--nv-material-elevated-shadow);
backdrop-filter: none;
}
.nv-material-inset {
background-color: var(--nv-material-inset-bg);
border: var(--nv-border-width) solid var(--nv-material-inset-border);
box-shadow: var(--nv-material-inset-shadow);
backdrop-filter: none;
}
.nv-material-frosted {
background-color: var(--nv-material-frosted-bg);
border: var(--nv-border-width) solid var(--nv-material-frosted-border);
box-shadow: var(--nv-material-frosted-shadow);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
.nv-material-glass {
background-color: var(--nv-material-glass-bg);
border: var(--nv-border-width) solid var(--nv-material-glass-border);
box-shadow: var(--nv-material-glass-shadow);
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Elevation -------------------------------------------------------------
Elevation utilities carry three things at once, because a lifted surface is
never only a shadow: the shadow itself, the highlight along its top edge,
and — on a dark canvas, where a shadow has nothing left to darken — a tint
that lets more light reach the surface the higher it sits. The tint is
transparent in light appearance, so the two read as one scale. */
.nv-elevation-0 { box-shadow: var(--nv-elevation-0); }
.nv-elevation-1 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
background-image: linear-gradient(var(--nv-color-elevation-tint-1) 0 0);
}
.nv-elevation-2 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
background-image: linear-gradient(var(--nv-color-elevation-tint-2) 0 0);
}
.nv-elevation-3 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
background-image: linear-gradient(var(--nv-color-elevation-tint-3) 0 0);
}
.nv-elevation-4 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-4);
background-image: linear-gradient(var(--nv-color-elevation-tint-4) 0 0);
}
.nv-elevation-5 {
box-shadow: var(--nv-highlight-top-strong), var(--nv-elevation-5);
background-image: linear-gradient(var(--nv-color-elevation-tint-5) 0 0);
}
.nv-elevation-inset-sm { box-shadow: var(--nv-elevation-inset-sm); }
.nv-elevation-inset-md { box-shadow: var(--nv-elevation-inset-md); }
.nv-elevation-inset-lg { box-shadow: var(--nv-elevation-inset-lg); }
/* --- Divider ---------------------------------------------------------------- */
.nv-divider {
border: 0;
border-top: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block: var(--nv-space-4);
}
.nv-divider--vertical {
border-top: 0;
border-left: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block: 0;
margin-inline: var(--nv-space-4);
align-self: stretch;
}

View file

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

View file

@ -0,0 +1,209 @@
/* ==========================================================================
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 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.
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;
}
::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. */
::view-transition-old(nv-content) {
animation: nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both;
}
::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;
}
:root[data-nv-direction="forward"]::view-transition-old(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;
}
:root[data-nv-direction="forward"]::view-transition-new(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;
}
:root[data-nv-direction="backward"]::view-transition-old(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;
}
:root[data-nv-direction="backward"]::view-transition-new(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;
}
@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);
}
/* 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;
}
}

View file

@ -0,0 +1,59 @@
/* ==========================================================================
Niveus — a calm, adaptive interface language.
Made by Nexoniarz.
--------------------------------------------------------------------------
The single entry point. An application links this file and nothing else:
<link rel="stylesheet" href="niveus/niveus.css">
<script src="niveus/niveus.js"></script> — optional, see below
<html data-theme="dark"> — or omit data-theme to follow the system
niveus.js is not required for anything to render. It exists only so state
changes can be *continuous*: it wraps an update in a view transition so the
browser can animate between the two states. Without it, every component
still works — changes simply arrive rather than travel.
Everything ships inside the `niveus` cascade layer, so unlayered application
styles always win without needing !important or selector escalation. The
sub-layers are ordered below; that statement is what fixes their precedence,
independently of the order the files happen to load in.
========================================================================== */
@layer niveus.tokens, niveus.theme, niveus.base, niveus.continuity,
niveus.components, niveus.utilities;
/* --- Tokens: the values, appearance-agnostic ------------------------------- */
@import url("tokens/color.css") layer(niveus.tokens);
@import url("tokens/typography.css") layer(niveus.tokens);
@import url("tokens/spacing.css") layer(niveus.tokens);
@import url("tokens/radius.css") layer(niveus.tokens);
@import url("tokens/elevation.css") layer(niveus.tokens);
@import url("tokens/motion.css") layer(niveus.tokens);
@import url("tokens/materials.css") layer(niveus.tokens);
/* --- Themes: what those values become in each appearance -------------------- */
@import url("themes/light.css") layer(niveus.theme);
@import url("themes/dark.css") layer(niveus.theme);
/* --- Base: elements, before any component ----------------------------------- */
@import url("base.css") layer(niveus.base);
/* --- Continuity: how one state becomes the next ----------------------------- */
@import url("continuity.css") layer(niveus.continuity);
/* --- Components -------------------------------------------------------------- */
@import url("components/surface.css") layer(niveus.components);
@import url("components/button.css") layer(niveus.components);
@import url("components/input.css") layer(niveus.components);
@import url("components/card.css") layer(niveus.components);
@import url("components/dialog.css") layer(niveus.components);
@import url("components/navigation.css") layer(niveus.components);
@import url("components/menu.css") layer(niveus.components);
@import url("components/disclosure.css") layer(niveus.components);
@import url("components/scroller.css") layer(niveus.components);
@import url("components/bed.css") layer(niveus.components);
@import url("components/badge.css") layer(niveus.components);
@import url("components/table.css") layer(niveus.components);
/* --- Utilities ----------------------------------------------------------------- */
@import url("utilities.css") layer(niveus.utilities);

View file

@ -0,0 +1,322 @@
/* ==========================================================================
Niveus — continuity helpers
--------------------------------------------------------------------------
Optional. Nothing in Niveus needs this file to render; every component works
without it. What it adds is continuity for the changes CSS cannot animate on
its own — a selection moving to a different element, a panel replacing
another, the whole surface changing appearance. All three are the same
problem: two states that exist at different moments, with no single element
transitioning between them. A view transition is the browser's answer, and
this file is the small amount of wiring it needs.
Everything here is a thin wrapper. An application that already owns its
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.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
Markup wiring, applied automatically on load:
[data-nv-theme="dark"] button that sets the appearance
[data-nv-dialog="id"] button that opens a dialog from itself
[data-nv-tabs] tablist whose [role=tab] children switch
their aria-controls panels
[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
Wiring also names every navigation item, which is what lets a travelling
selection marker pass *behind* the labels rather than over them.
Reduced motion is honoured by skipping the view transition entirely: the
update still runs, it simply arrives instead of travelling.
========================================================================== */
(function (global) {
"use strict";
var root = document.documentElement;
function prefersReducedMotion() {
return global.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function canTransition() {
return typeof document.startViewTransition === "function" && !prefersReducedMotion();
}
/**
* 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.
*/
function transition(update, options) {
options = options || {};
if (!canTransition()) {
return Promise.resolve(update());
}
if (options.direction) root.dataset.nvDirection = options.direction;
if (options.kind) root.dataset.nvTransition = options.kind;
var view = document.startViewTransition(update);
function clear() {
delete root.dataset.nvDirection;
delete root.dataset.nvTransition;
}
view.finished.then(clear, clear);
return view.finished;
}
/** "light" | "dark" | "system" — system means: follow the environment. */
function setTheme(theme) {
var done = transition(function () {
if (theme === "system") {
root.removeAttribute("data-theme");
} else {
root.setAttribute("data-theme", theme);
}
syncThemeControls(theme);
}, { kind: "theme" });
root.dispatchEvent(new CustomEvent("nv:theme", { detail: { theme: theme } }));
return done;
}
function syncThemeControls(theme) {
var controls = document.querySelectorAll("[data-nv-theme]");
for (var i = 0; i < controls.length; i++) {
controls[i].setAttribute("aria-pressed", String(controls[i].dataset.nvTheme === theme));
}
}
/**
* Open a <dialog> so it appears to come from `trigger`: the entrance scales
* away from the trigger's centre instead of from the middle of the screen.
*/
function openDialog(dialog, trigger) {
if (typeof dialog === "string") dialog = document.getElementById(dialog);
if (!dialog) return null;
dialog.showModal();
if (trigger) {
// showModal() has already laid the dialog out, so both boxes are final.
var from = trigger.getBoundingClientRect();
var to = dialog.getBoundingClientRect();
// A trigger far outside the viewport — one the page has scrolled past —
// would otherwise put the origin hundreds of pixels away and turn a
// 3% scale into a lunge across the screen. One dialog-length in any
// direction is enough to say where it came from.
var clamp = function (value, size) {
return Math.max(-size, Math.min(value, size * 2));
};
dialog.style.setProperty(
"--nv-origin",
clamp(from.left + from.width / 2 - to.left, to.width).toFixed(1) + "px " +
clamp(from.top + from.height / 2 - to.top, to.height).toFixed(1) + "px"
);
} else {
dialog.style.removeProperty("--nv-origin");
}
return dialog;
}
function tabsOf(tablist) {
return Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]'));
}
/** Move the selection, and the panel, in the direction the user is going. */
function selectTab(tablist, tab) {
var tabs = tabsOf(tablist);
var next = tabs.indexOf(tab);
var current = -1;
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].getAttribute("aria-selected") === "true") current = i;
}
if (next < 0 || next === current) return Promise.resolve();
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"));
if (panel) panel.hidden = !selected;
});
}, { direction: next > current ? "forward" : "backward" });
tab.focus();
return done;
}
function initTabs(tablist) {
tablist.addEventListener("click", function (event) {
var tab = event.target.closest('[role="tab"]');
if (tab && tablist.contains(tab)) {
event.preventDefault();
selectTab(tablist, tab);
}
});
// The ARIA tabs pattern: arrows move selection, Home and End jump to the
// ends, and only the selected tab is in the tab order.
tablist.addEventListener("keydown", function (event) {
var tabs = tabsOf(tablist);
var index = tabs.indexOf(document.activeElement);
if (index < 0) return;
var vertical = tablist.getAttribute("aria-orientation") === "vertical";
var back = vertical ? "ArrowUp" : "ArrowLeft";
var forward = vertical ? "ArrowDown" : "ArrowRight";
var target = null;
if (event.key === back) target = tabs[(index - 1 + tabs.length) % tabs.length];
else if (event.key === forward) target = tabs[(index + 1) % tabs.length];
else if (event.key === "Home") target = tabs[0];
else if (event.key === "End") target = tabs[tabs.length - 1];
if (target) {
event.preventDefault();
selectTab(tablist, target);
}
});
}
/**
* Wire a scroller's prev/next controls. Scrolling itself is the browser's;
* this only gives pointers without a horizontal axis somewhere to click, and
* hides a control once there is nothing left in that direction.
*/
function initScroller(frame) {
var track = frame.querySelector(".nv-scroller");
if (!track) return;
var controls = frame.querySelectorAll("[data-nv-scroll]");
function step() {
var item = track.firstElementChild;
if (!item) return track.clientWidth * 0.8;
var gap = parseFloat(getComputedStyle(track).columnGap) || 0;
return item.getBoundingClientRect().width + gap;
}
function update() {
// A sub-pixel slack, or a fractional scrollWidth leaves a control
// enabled at an end it cannot move past.
var atStart = track.scrollLeft <= 1;
var atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
controls.forEach(function (control) {
control.disabled = control.dataset.nvScroll === "prev" ? atStart : atEnd;
});
// The same two facts decide which edge is allowed to fade.
frame.toggleAttribute("data-nv-at-start", atStart);
frame.toggleAttribute("data-nv-at-end", atEnd);
}
controls.forEach(function (control) {
control.addEventListener("click", function () {
var direction = control.dataset.nvScroll === "next" ? 1 : -1;
track.scrollBy({ left: direction * step(), behavior: "smooth" });
});
});
track.addEventListener("scroll", update, { passive: true });
if (typeof ResizeObserver === "function") new ResizeObserver(update).observe(track);
update();
}
/**
* 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.
*
* 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.
*/
var carried = 0;
function liftLabels(scope) {
var groups = scope.querySelectorAll(".nv-nav, .nv-nav-tabs, [data-nv-travel]");
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;
}
}
}
function wire(scope) {
scope = scope || document;
scope.querySelectorAll("[data-nv-theme]").forEach(function (control) {
control.addEventListener("click", function () {
setTheme(control.dataset.nvTheme);
});
});
scope.querySelectorAll("[data-nv-dialog]").forEach(function (trigger) {
trigger.addEventListener("click", function () {
openDialog(trigger.dataset.nvDialog, trigger);
});
});
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
liftLabels(scope);
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
control.addEventListener("click", function () {
var owner = control.closest("dialog, [popover]");
if (!owner) return;
if (owner.tagName === "DIALOG") owner.close();
else owner.hidePopover();
});
});
// A dialog closes when the scrim is clicked, but not when a drag merely
// ends there.
scope.querySelectorAll("dialog.nv-dialog").forEach(function (dialog) {
dialog.addEventListener("mousedown", function (event) {
if (event.target === dialog) dialog.close();
});
});
}
global.nv = {
transition: transition,
setTheme: setTheme,
syncThemeControls: syncThemeControls,
openDialog: openDialog,
selectTab: selectTab,
initScroller: initScroller,
liftLabels: liftLabels,
wire: wire,
prefersReducedMotion: prefersReducedMotion,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { wire(); });
} else {
wire();
}
})(window);

View file

@ -0,0 +1,120 @@
/* ==========================================================================
Niveus — Dark appearance
--------------------------------------------------------------------------
Values for the dark side of every semantic role, resolved through
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.
========================================================================== */
:root[data-theme="dark"] {
color-scheme: dark;
}
: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);
/* --- 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-inverted: var(--nv-palette-neutral-975);
/* --- Borders ------------------------------------------------------------ */
--nv-dark-border-subtle: rgb(255 255 255 / 0.06);
--nv-dark-border: rgb(255 255 255 / 0.1);
--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);
--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-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);
--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-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-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);
/* --- 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);
--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-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-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);
--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-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-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);
/* 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-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);
/* --- 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);
}

View file

@ -0,0 +1,158 @@
/* ==========================================================================
Niveus — Light appearance
--------------------------------------------------------------------------
Values for the light side of every semantic role. Nothing here is consumed
by components directly; tokens/color.css resolves these through light-dark().
Almost nothing below is a literal color. Each role is *derived* from
--nv-brand, so setting one value re-colours the entire system: the accent
and its states, what text goes on top of it, how the status colors lean, and
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.
========================================================================== */
:root {
/* Follow the environment unless the application states an appearance. */
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
/* [data-nv-brand] re-derives the whole set for a subtree, so one region can
carry a different brand without a second theme. */
:root,
[data-nv-brand] {
/* --- Canvas and surfaces -----------------------------------------------
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);
/* --- Text ---------------------------------------------------------------
Primary text stays neutral — it carries the reading contrast. The quieter
two lean towards the brand, where a tint reads as warmth rather than as a
loss of legibility. */
--nv-light-text-primary: var(--nv-palette-neutral-900);
--nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%);
--nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%);
--nv-light-text-disabled: var(--nv-palette-neutral-400);
--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);
/* --- Accent --------------------------------------------------------------
Every filled color is pushed away from the middle of the lightness range
before it is used. A mid-tone is the one place where neither white nor
black text reaches 4.5:1, so a brand that lands there is darkened until
white works — or, if it is already light, lightened until black does. The
brand's hue and chroma are untouched; only its lightness moves, and only
as far as it has to. */
--nv-light-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-light-accent-hover: oklch(from var(--nv-light-accent) calc(l - 0.045) c h);
--nv-light-accent-active: oklch(from var(--nv-light-accent) calc(l - 0.09) c h);
--nv-light-accent-subtle: oklch(from var(--nv-light-accent) 0.965 calc(c * 0.16) h);
--nv-light-accent-subtle-hover: oklch(from var(--nv-light-accent) 0.93 calc(c * 0.22) h);
--nv-light-accent-border: oklch(from var(--nv-light-accent) l c h / 0.32);
--nv-light-accent-text: oklch(from var(--nv-light-accent) clamp(0.3, calc(l - 0.12), 0.5) c h);
/* Text that lands on the color itself. The clamp is a step function: below
the lightness threshold it resolves to 1 and the text goes near-white,
above it to 0 and the text goes near-black — so a yellow brand gets dark
labels and a navy one gets light labels, with no second token to set. */
--nv-light-on-accent: oklch(from var(--nv-light-accent)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Success ------------------------------------------------------------- */
--nv-light-success: oklch(from
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-light-success-hover: oklch(from var(--nv-light-success) calc(l - 0.05) c h);
--nv-light-success-subtle: oklch(from var(--nv-light-success) 0.965 calc(c * 0.16) h);
--nv-light-success-border: oklch(from var(--nv-light-success) l c h / 0.3);
--nv-light-success-text: oklch(from var(--nv-light-success) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-success: oklch(from var(--nv-light-success)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Warning -------------------------------------------------------------- */
/* Amber starts above the pivot on purpose. Pushed the other way it lands as
brown, which reads as neither a warning nor a color anyone chose. */
--nv-light-warning: oklch(from
color-mix(in oklab, var(--nv-palette-warning-400), 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-light-warning-hover: oklch(from var(--nv-light-warning) calc(l - 0.05) c h);
--nv-light-warning-subtle: oklch(from var(--nv-light-warning) 0.965 calc(c * 0.16) h);
--nv-light-warning-border: oklch(from var(--nv-light-warning) l c h / 0.32);
--nv-light-warning-text: oklch(from var(--nv-light-warning) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-warning: oklch(from var(--nv-light-warning)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Danger ---------------------------------------------------------------- */
--nv-light-danger: oklch(from
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-light-danger-hover: oklch(from var(--nv-light-danger) calc(l - 0.05) c h);
--nv-light-danger-active: oklch(from var(--nv-light-danger) calc(l - 0.1) c h);
--nv-light-danger-subtle: oklch(from var(--nv-light-danger) 0.965 calc(c * 0.16) h);
--nv-light-danger-border: oklch(from var(--nv-light-danger) l c h / 0.3);
--nv-light-danger-text: oklch(from var(--nv-light-danger) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-danger: oklch(from var(--nv-light-danger)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- 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-highlight-strong: rgb(255 255 255 / 1);
/* 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;
--nv-light-elevation-tint-2: transparent;
--nv-light-elevation-tint-3: transparent;
--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);
/* --- Interaction ----------------------------------------------------------- */
--nv-light-focus-ring: oklch(from var(--nv-light-accent) l c h / 0.4);
--nv-light-focus-ring-contrast: rgb(255 255 255 / 0.9);
--nv-light-selection: oklch(from var(--nv-light-accent) l c h / 0.2);
}

View file

@ -0,0 +1,218 @@
/* ==========================================================================
Niveus — Color
--------------------------------------------------------------------------
Two levels of tokens live here:
1. Palette Raw, appearance-agnostic values. Never referenced by
components. Prefixed --nv-palette-*.
2. Semantic The roles components actually consume (background, surface,
text-primary, accent, danger, …). Each role resolves with
light-dark(), pulling its value from the matching appearance
file: themes/light.css (--nv-light-*) or themes/dark.css
(--nv-dark-*).
The appearance is chosen by `color-scheme`, which themes/*.css bind to the
[data-theme] attribute. A component therefore never knows — and must never
ask — which appearance is active.
========================================================================== */
/* ==========================================================================
Brand
--------------------------------------------------------------------------
The whole color system has one input. Set --nv-brand and the accent, its
hover and active states, the text that lands on it, the focus ring, the
selection highlight, the tint in the greys and the lean of the status colors
are all re-derived from it — in both appearances, with no second value to
keep in sync.
:root { --nv-brand: #d92e3f; }
Set it on any element with [data-nv-brand] to re-brand a subtree.
========================================================================== */
:root {
--nv-brand: #4f6cff;
/* How much of the brand bleeds into the greys. Enough that the neutrals
belong to the same family as the accent; not enough to read as colored.
0% gives strictly neutral greys. */
--nv-brand-tint: 4%;
/* How far the status colors lean towards the brand. Red still reads as
danger and green still reads as success — they just stop looking borrowed
from another system. 0% keeps them literal. */
--nv-brand-harmony: 12%;
/* Where "too mid-toned to carry a label" begins, and how far either side a
color is pushed to escape it. Raising the pivot sends more brands dark
(white labels); lowering it sends more of them light (dark labels). */
--nv-contrast-pivot: 0.7;
--nv-contrast-dark: 0.53;
--nv-contrast-light: 0.78;
}
:root {
/* --- Neutral ---------------------------------------------------------- */
--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;
/* --- Accent (indigo) --------------------------------------------------- */
--nv-palette-accent-50: #eef2ff;
--nv-palette-accent-100: #dfe6ff;
--nv-palette-accent-200: #c4d0ff;
--nv-palette-accent-300: #9fb3ff;
--nv-palette-accent-400: #7691ff;
--nv-palette-accent-500: #4f6cff;
--nv-palette-accent-600: #3f5cf0;
--nv-palette-accent-700: #3450d6;
--nv-palette-accent-800: #2a3fa8;
--nv-palette-accent-900: #22347f;
/* --- Success (green) --------------------------------------------------- */
--nv-palette-success-50: #e8f9ef;
--nv-palette-success-100: #ccf0dc;
--nv-palette-success-200: #9ee2bc;
--nv-palette-success-300: #62cd96;
--nv-palette-success-400: #33b478;
--nv-palette-success-500: #189a60;
--nv-palette-success-600: #0f7d4d;
--nv-palette-success-700: #0c6440;
--nv-palette-success-800: #0a5034;
--nv-palette-success-900: #08412b;
/* --- Warning (amber) --------------------------------------------------- */
--nv-palette-warning-50: #fff6e6;
--nv-palette-warning-100: #ffe9c2;
--nv-palette-warning-200: #ffd68d;
--nv-palette-warning-300: #fbbe52;
--nv-palette-warning-400: #eda520;
--nv-palette-warning-500: #cf8809;
--nv-palette-warning-600: #a96d05;
--nv-palette-warning-700: #855505;
--nv-palette-warning-800: #6a4406;
--nv-palette-warning-900: #563806;
/* --- Danger (red) ------------------------------------------------------ */
--nv-palette-danger-50: #ffeff1;
--nv-palette-danger-100: #ffdbde;
--nv-palette-danger-200: #ffb9bf;
--nv-palette-danger-300: #ff8b96;
--nv-palette-danger-400: #f95b6c;
--nv-palette-danger-500: #e2334a;
--nv-palette-danger-600: #c01f36;
--nv-palette-danger-700: #9c172b;
--nv-palette-danger-800: #7e1524;
--nv-palette-danger-900: #67141f;
}
/* ==========================================================================
Semantic roles
========================================================================== */
/* The subtree selector has to be repeated here, not just on the theme files:
var() inside a custom property is substituted where that property is
*declared*, so a --nv-color-* declared only on :root would bake in the root
brand and inherit the result unchanged. Re-declaring on [data-nv-brand]
re-runs the substitution against the subtree's own brand. */
:root,
[data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */
--nv-color-background: light-dark(var(--nv-light-background), var(--nv-dark-background));
--nv-color-background-subtle: light-dark(var(--nv-light-background-subtle), var(--nv-dark-background-subtle));
--nv-color-surface: light-dark(var(--nv-light-surface), var(--nv-dark-surface));
--nv-color-surface-elevated: light-dark(var(--nv-light-surface-elevated), var(--nv-dark-surface-elevated));
--nv-color-surface-inset: light-dark(var(--nv-light-surface-inset), var(--nv-dark-surface-inset));
--nv-color-surface-hover: light-dark(var(--nv-light-surface-hover), var(--nv-dark-surface-hover));
--nv-color-surface-active: light-dark(var(--nv-light-surface-active), var(--nv-dark-surface-active));
--nv-color-scrim: light-dark(var(--nv-light-scrim), var(--nv-dark-scrim));
/* --- Text --------------------------------------------------------------- */
--nv-color-text-primary: light-dark(var(--nv-light-text-primary), var(--nv-dark-text-primary));
--nv-color-text-secondary: light-dark(var(--nv-light-text-secondary), var(--nv-dark-text-secondary));
--nv-color-text-tertiary: light-dark(var(--nv-light-text-tertiary), var(--nv-dark-text-tertiary));
--nv-color-text-disabled: light-dark(var(--nv-light-text-disabled), var(--nv-dark-text-disabled));
--nv-color-text-inverted: light-dark(var(--nv-light-text-inverted), var(--nv-dark-text-inverted));
/* --- Borders ------------------------------------------------------------ */
--nv-color-border-subtle: light-dark(var(--nv-light-border-subtle), var(--nv-dark-border-subtle));
--nv-color-border: light-dark(var(--nv-light-border), var(--nv-dark-border));
--nv-color-border-strong: light-dark(var(--nv-light-border-strong), var(--nv-dark-border-strong));
/* --- Accent ------------------------------------------------------------- */
--nv-color-accent: light-dark(var(--nv-light-accent), var(--nv-dark-accent));
--nv-color-accent-hover: light-dark(var(--nv-light-accent-hover), var(--nv-dark-accent-hover));
--nv-color-accent-active: light-dark(var(--nv-light-accent-active), var(--nv-dark-accent-active));
--nv-color-accent-subtle: light-dark(var(--nv-light-accent-subtle), var(--nv-dark-accent-subtle));
--nv-color-accent-subtle-hover: light-dark(var(--nv-light-accent-subtle-hover), var(--nv-dark-accent-subtle-hover));
--nv-color-accent-border: light-dark(var(--nv-light-accent-border), var(--nv-dark-accent-border));
--nv-color-accent-text: light-dark(var(--nv-light-accent-text), var(--nv-dark-accent-text));
--nv-color-on-accent: light-dark(var(--nv-light-on-accent), var(--nv-dark-on-accent));
/* --- Success ------------------------------------------------------------ */
--nv-color-success: light-dark(var(--nv-light-success), var(--nv-dark-success));
--nv-color-success-hover: light-dark(var(--nv-light-success-hover), var(--nv-dark-success-hover));
--nv-color-success-subtle: light-dark(var(--nv-light-success-subtle), var(--nv-dark-success-subtle));
--nv-color-success-border: light-dark(var(--nv-light-success-border), var(--nv-dark-success-border));
--nv-color-success-text: light-dark(var(--nv-light-success-text), var(--nv-dark-success-text));
--nv-color-on-success: light-dark(var(--nv-light-on-success), var(--nv-dark-on-success));
/* --- Warning ------------------------------------------------------------ */
--nv-color-warning: light-dark(var(--nv-light-warning), var(--nv-dark-warning));
--nv-color-warning-hover: light-dark(var(--nv-light-warning-hover), var(--nv-dark-warning-hover));
--nv-color-warning-subtle: light-dark(var(--nv-light-warning-subtle), var(--nv-dark-warning-subtle));
--nv-color-warning-border: light-dark(var(--nv-light-warning-border), var(--nv-dark-warning-border));
--nv-color-warning-text: light-dark(var(--nv-light-warning-text), var(--nv-dark-warning-text));
--nv-color-on-warning: light-dark(var(--nv-light-on-warning), var(--nv-dark-on-warning));
/* --- Danger ------------------------------------------------------------- */
--nv-color-danger: light-dark(var(--nv-light-danger), var(--nv-dark-danger));
--nv-color-danger-hover: light-dark(var(--nv-light-danger-hover), var(--nv-dark-danger-hover));
--nv-color-danger-active: light-dark(var(--nv-light-danger-active), var(--nv-dark-danger-active));
--nv-color-danger-subtle: light-dark(var(--nv-light-danger-subtle), var(--nv-dark-danger-subtle));
--nv-color-danger-border: light-dark(var(--nv-light-danger-border), var(--nv-dark-danger-border));
--nv-color-danger-text: light-dark(var(--nv-light-danger-text), var(--nv-dark-danger-text));
--nv-color-on-danger: light-dark(var(--nv-light-on-danger), var(--nv-dark-on-danger));
/* --- Depth and material ------------------------------------------------- */
--nv-color-shadow-ambient: light-dark(var(--nv-light-shadow-ambient), var(--nv-dark-shadow-ambient));
--nv-color-shadow-penumbra: light-dark(var(--nv-light-shadow-penumbra), var(--nv-dark-shadow-penumbra));
--nv-color-shadow-umbra: light-dark(var(--nv-light-shadow-umbra), var(--nv-dark-shadow-umbra));
--nv-color-shadow-inset: light-dark(var(--nv-light-shadow-inset), var(--nv-dark-shadow-inset));
--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));
/* 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. */
--nv-color-elevation-tint-1: light-dark(var(--nv-light-elevation-tint-1), var(--nv-dark-elevation-tint-1));
--nv-color-elevation-tint-2: light-dark(var(--nv-light-elevation-tint-2), var(--nv-dark-elevation-tint-2));
--nv-color-elevation-tint-3: light-dark(var(--nv-light-elevation-tint-3), var(--nv-dark-elevation-tint-3));
--nv-color-elevation-tint-4: light-dark(var(--nv-light-elevation-tint-4), var(--nv-dark-elevation-tint-4));
--nv-color-elevation-tint-5: light-dark(var(--nv-light-elevation-tint-5), var(--nv-dark-elevation-tint-5));
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
--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));
/* --- 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));
--nv-color-selection: light-dark(var(--nv-light-selection), var(--nv-dark-selection));
}

View file

@ -0,0 +1,74 @@
/* ==========================================================================
Niveus — Elevation
--------------------------------------------------------------------------
Depth is a scale, not a free choice. Each level describes one relationship
between a surface and what sits behind it:
0 flush painted on the canvas, no separation
1 resting cards, list rows, static panels
2 raised buttons, controls, anything that reacts to a pointer
3 floating popovers, menus, dropdowns, tooltips
4 lifted dialogs, sheets, detached panels
5 overlay the topmost layer, used sparingly
Every level is built from the same three shadow colors so depth stays
consistent between appearances: an ambient contact shadow that keeps the
edge crisp, a soft penumbra for the spread, and an umbra that only appears
once a surface is genuinely far from the canvas.
========================================================================== */
:root {
--nv-elevation-0: none;
--nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient),
0 1px 3px -1px var(--nv-color-shadow-penumbra);
--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);
--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);
--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);
--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);
/* --- 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);
--nv-elevation-inset-lg:
inset 0 2px 3px var(--nv-color-shadow-inset),
inset 0 6px 12px -3px var(--nv-color-shadow-inset);
/* --- Highlight -----------------------------------------------------------
The thin catch of light along the top edge of a raised surface. This is
what makes Niveus read as material rather than as a rectangle with a
shadow. Always paired with an elevation, never used alone. */
--nv-highlight-top: inset 0 1px 0 var(--nv-color-highlight);
--nv-highlight-top-strong: inset 0 1px 0 var(--nv-color-highlight-strong);
--nv-highlight-edge:
inset 0 1px 0 var(--nv-color-highlight),
inset 0 0 0 1px var(--nv-color-border-subtle);
/* --- Focus ---------------------------------------------------------------
A contrast ring sits under the accent ring so focus stays visible on both
light and dark surfaces, and on accent-filled controls. */
--nv-focus-ring-width: 3px;
--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);
}

View file

@ -0,0 +1,77 @@
/* ==========================================================================
Niveus — Materials
--------------------------------------------------------------------------
A material is a surface treatment: what it is made of, not where it sits.
Elevation answers "how far from the canvas"; material answers "what does
light do when it hits this".
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
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.
========================================================================== */
:root {
/* --- Blur ---------------------------------------------------------------- */
--nv-blur-sm: 8px;
--nv-blur-md: 16px;
--nv-blur-lg: 28px;
--nv-blur-xl: 44px;
/* Backdrops lose saturation when blurred; a touch of saturate() puts the
color back so frosted surfaces do not read as gray films. */
--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%);
/* --- Material recipes -----------------------------------------------------
Each recipe is the full set of properties that defines the material.
Applied by the .nv-material-* classes in components/surface.css. */
--nv-material-solid-bg: var(--nv-color-surface);
--nv-material-solid-border: var(--nv-color-border);
--nv-material-solid-shadow: var(--nv-elevation-0);
--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-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-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-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);
}
/* Where translucency cannot be honoured — reduced transparency, or a browser
without backdrop-filter — materials fall back to their solid equivalents
rather than becoming unreadable washes. */
@media (prefers-reduced-transparency: reduce) {
:root {
--nv-material-frosted-bg: var(--nv-color-surface-elevated);
--nv-material-glass-bg: var(--nv-color-surface-elevated);
--nv-backdrop-frosted: none;
--nv-backdrop-glass: none;
--nv-backdrop-scrim: none;
}
}
@supports not (backdrop-filter: blur(1px)) {
:root {
--nv-material-frosted-bg: var(--nv-color-surface-elevated);
--nv-material-glass-bg: var(--nv-color-surface-elevated);
}
}

View file

@ -0,0 +1,107 @@
/* ==========================================================================
Niveus — Motion
--------------------------------------------------------------------------
Motion explains state and spatial relationship. It is short enough to feel
immediate and eased enough to feel physical.
Rule of thumb: the smaller the element, the faster it moves. A button's
hover is 120ms; a dialog's entrance is 240ms.
========================================================================== */
:root {
/* --- Duration ------------------------------------------------------------ */
--nv-duration-instant: 0ms;
--nv-duration-fast: 120ms; /* hover, press, color change */
--nv-duration-quick: 160ms; /* small elements entering */
--nv-duration-normal: 220ms; /* popovers, menus, expansion */
--nv-duration-slow: 320ms; /* dialogs, sheets, large surfaces */
--nv-duration-slower: 480ms; /* full-screen transitions */
/* --- Easing ---------------------------------------------------------------
`standard` covers almost everything. Entrance decelerates into place, exit
accelerates away, and `spring` is reserved for confirmations — never for
anything a user triggers repeatedly. */
--nv-ease-standard: cubic-bezier(0.2, 0.05, 0, 1);
--nv-ease-entrance: cubic-bezier(0.05, 0.7, 0.1, 1);
--nv-ease-exit: cubic-bezier(0.3, 0, 0.8, 0.15);
--nv-ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
--nv-ease-linear: linear;
/* --- Continuity -------------------------------------------------------------
Durations for changes the user is meant to *follow*, not merely notice.
They are longer than an interaction response because the eye has further
to travel: emphasis crossing a navigation bar, a panel growing open, a
whole surface changing appearance. */
--nv-duration-travel: 260ms; /* emphasis moving from one element to another */
--nv-duration-expand: 280ms; /* content growing out of, or back into, place */
--nv-duration-theme: 400ms; /* the entire surface changing appearance */
/* Leaves quickly, lands softly. The right curve for something travelling a
distance the user is meant to follow with their eye. */
--nv-ease-emphasized: cubic-bezier(0.32, 0.04, 0.06, 1);
/* --- Composites ------------------------------------------------------------
`translate` and `scale` are listed rather than `transform`, because the
components animate the individual properties — a transition on `transform`
does not cover them, and the movement snaps. */
--nv-transition-colors:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-depth:
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-control:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard),
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
/* Containers: the same as depth, plus the colors, so a surface glides into
its new appearance when the theme changes instead of snapping. */
--nv-transition-surface:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard),
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-expand:
block-size var(--nv-duration-expand) var(--nv-ease-standard),
grid-template-rows var(--nv-duration-expand) var(--nv-ease-standard),
opacity var(--nv-duration-expand) var(--nv-ease-standard);
/* --- Displacement ----------------------------------------------------------
How far things move. Presses sink by 1px; surfaces enter from 8px away. */
--nv-lift-hover: -1px;
--nv-press-depth: 1px;
--nv-travel-sm: 4px;
--nv-travel-md: 8px;
--nv-travel-lg: 16px;
}
/* Reduced motion is a system-wide contract, not a per-component opt-in: cut
duration to near-zero and neutralise displacement, but keep transitions
alive so state changes still resolve rather than snapping mid-flight. */
@media (prefers-reduced-motion: reduce) {
:root {
--nv-duration-fast: 1ms;
--nv-duration-quick: 1ms;
--nv-duration-normal: 1ms;
--nv-duration-slow: 1ms;
--nv-duration-slower: 1ms;
--nv-duration-travel: 1ms;
--nv-duration-expand: 1ms;
--nv-duration-theme: 1ms;
--nv-ease-spring: var(--nv-ease-standard);
--nv-lift-hover: 0px;
--nv-press-depth: 0px;
--nv-travel-sm: 0px;
--nv-travel-md: 0px;
--nv-travel-lg: 0px;
}
}

View file

@ -0,0 +1,23 @@
/* ==========================================================================
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.
========================================================================== */
: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-full: 999rem; /* — pills, avatars, switches */
/* Nested radius: an inner surface inset by N should be N smaller than its
parent, or the corners read as off-center. */
--nv-radius-nested-md: calc(var(--nv-radius-lg) - var(--nv-space-2));
--nv-radius-nested-lg: calc(var(--nv-radius-xl) - var(--nv-space-3));
}

View file

@ -0,0 +1,54 @@
/* ==========================================================================
Niveus — Spacing and size
--------------------------------------------------------------------------
A 4px rhythm. The numeric suffix is the value in quarter-steps, so
--nv-space-4 is 16px and --nv-space-6 is 24px. Anything not on the scale is
application layout, not design-system spacing.
========================================================================== */
:root {
--nv-space-0: 0;
--nv-space-px: 1px;
--nv-space-0-5: 0.125rem; /* 2px */
--nv-space-1: 0.25rem; /* 4px */
--nv-space-1-5: 0.375rem; /* 6px */
--nv-space-2: 0.5rem; /* 8px */
--nv-space-2-5: 0.625rem; /* 10px */
--nv-space-3: 0.75rem; /* 12px */
--nv-space-4: 1rem; /* 16px */
--nv-space-5: 1.25rem; /* 20px */
--nv-space-6: 1.5rem; /* 24px */
--nv-space-7: 1.75rem; /* 28px */
--nv-space-8: 2rem; /* 32px */
--nv-space-10: 2.5rem; /* 40px */
--nv-space-12: 3rem; /* 48px */
--nv-space-14: 3.5rem; /* 56px */
--nv-space-16: 4rem; /* 64px */
--nv-space-20: 5rem; /* 80px */
--nv-space-24: 6rem; /* 96px */
--nv-space-32: 8rem; /* 128px */
/* --- Control sizing -------------------------------------------------------
Shared by every interactive control so a button, an input and a select
line up on the same row without per-component tuning. */
--nv-control-height-sm: 1.75rem; /* 28px */
--nv-control-height-md: 2.25rem; /* 36px */
--nv-control-height-lg: 2.75rem; /* 44px */
--nv-control-padding-sm: var(--nv-space-2-5);
--nv-control-padding-md: var(--nv-space-3);
--nv-control-padding-lg: var(--nv-space-4);
--nv-control-gap-sm: var(--nv-space-1-5);
--nv-control-gap-md: var(--nv-space-2);
--nv-control-gap-lg: var(--nv-space-2-5);
/* Minimum pointer target, independent of visual control height. */
--nv-target-min: 2.75rem; /* 44px */
/* --- Layout ---------------------------------------------------------------- */
--nv-layout-gutter: var(--nv-space-6);
--nv-layout-max-width: 72rem;
--nv-border-width: 1px;
--nv-border-width-strong: 2px;
}

View file

@ -0,0 +1,73 @@
/* ==========================================================================
Niveus — Typography
--------------------------------------------------------------------------
One type scale, used everywhere. Sizes are in rem so they respect the user's
browser font size; line heights are unitless so they inherit correctly.
========================================================================== */
:root {
/* --- Families ----------------------------------------------------------- */
--nv-font-family-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",
"Consolas", monospace;
/* --- Size scale ---------------------------------------------------------- */
--nv-font-size-2xs: 0.6875rem; /* 11px — dense metadata, never body text */
--nv-font-size-xs: 0.75rem; /* 12px */
--nv-font-size-sm: 0.8125rem; /* 13px */
--nv-font-size-md: 0.9375rem; /* 15px — default body size */
--nv-font-size-lg: 1.0625rem; /* 17px */
--nv-font-size-xl: 1.25rem; /* 20px */
--nv-font-size-2xl: 1.5rem; /* 24px */
--nv-font-size-3xl: 1.875rem; /* 30px */
--nv-font-size-4xl: 2.375rem; /* 38px */
--nv-font-size-5xl: 3rem; /* 48px */
/* --- Weight -------------------------------------------------------------- */
--nv-font-weight-regular: 400;
--nv-font-weight-medium: 500;
--nv-font-weight-semibold: 600;
--nv-font-weight-bold: 700;
/* --- Line height --------------------------------------------------------- */
--nv-line-height-tight: 1.18; /* display and large headings */
--nv-line-height-snug: 1.32; /* headings, single-line controls */
--nv-line-height-normal: 1.5; /* body copy */
--nv-line-height-relaxed: 1.68; /* long-form reading */
/* --- Letter spacing -------------------------------------------------------
Large text needs negative tracking to stay optically even; small caps-ish
labels need positive tracking to stay readable. */
--nv-letter-spacing-tighter: -0.022em;
--nv-letter-spacing-tight: -0.012em;
--nv-letter-spacing-normal: 0em;
--nv-letter-spacing-wide: 0.02em;
--nv-letter-spacing-wider: 0.06em;
/* --- Composite roles ------------------------------------------------------
Applied as classes in base.css (.nv-display, .nv-heading, …). Kept as
tokens so applications can compose their own elements from the same
values instead of guessing. */
--nv-font-display: var(--nv-font-weight-semibold) var(--nv-font-size-4xl) /
var(--nv-line-height-tight) var(--nv-font-family-sans);
--nv-font-heading: var(--nv-font-weight-semibold) var(--nv-font-size-2xl) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-title: var(--nv-font-weight-semibold) var(--nv-font-size-lg) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-body: var(--nv-font-weight-regular) var(--nv-font-size-md) /
var(--nv-line-height-normal) var(--nv-font-family-sans);
--nv-font-label: var(--nv-font-weight-medium) var(--nv-font-size-sm) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-caption: var(--nv-font-weight-regular) var(--nv-font-size-xs) /
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);
/* --- Measure --------------------------------------------------------------- */
--nv-measure-narrow: 44ch;
--nv-measure: 68ch;
--nv-measure-wide: 82ch;
}

View file

@ -0,0 +1,64 @@
/* ==========================================================================
Niveus — Utilities
--------------------------------------------------------------------------
A deliberately short list. Utilities exist for the handful of patterns that
appear in every application and have exactly one correct implementation —
not as a general-purpose styling language. Anything that needs a second
utility to look right is a component.
========================================================================== */
/* Content for assistive technology only. Not display:none, which removes it
from the accessibility tree as well. */
.nv-visually-hidden:not(:focus):not(:active) {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.nv-container {
inline-size: 100%;
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
/* Vertical rhythm between siblings, on the spacing scale. */
.nv-stack {
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
}
.nv-stack--tight { gap: var(--nv-space-2); }
.nv-stack--loose { gap: var(--nv-space-6); }
/* A row that wraps instead of overflowing. */
.nv-cluster {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--nv-space-3);
}
.nv-cluster--tight { gap: var(--nv-space-2); }
.nv-cluster--between { justify-content: space-between; }
.nv-truncate {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Long-form text at a readable measure. */
.nv-prose {
max-inline-size: var(--nv-measure);
line-height: var(--nv-line-height-relaxed);
color: var(--nv-color-text-secondary);
}

View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Bundle the Niveus sources into a single stylesheet.
`niveus/niveus.css` is the authored entry point and works as-is over HTTP: the
browser resolves the @import chain itself. That costs a request per file and,
more importantly, blocks rendering until the whole chain has loaded — so for
anything shipped, this script flattens the chain into `dist/niveus.css`.
Each `@import url("…") layer(name)` becomes a `@layer name { … }` block holding
the file's contents, which is exactly equivalent to what the browser does with
the import. Nothing is minified and nothing is reordered; the output is meant
to stay readable and to diff cleanly against the sources.
python3 tools/build.py [--check]
--check verifies dist/ is current without writing, for use in CI.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ENTRY = ROOT / "niveus" / "niveus.css"
OUTPUT = ROOT / "dist" / "niveus.css"
IMPORT = re.compile(
r"""@import\s+url\(\s*["']([^"']+)["']\s*\)\s*(?:layer\(\s*([^)]+?)\s*\))?\s*;""",
re.VERBOSE,
)
def indent(text: str, prefix: str = " ") -> str:
return "\n".join(prefix + line if line.strip() else line for line in text.split("\n"))
def bundle(path: Path, seen: set[Path] | None = None) -> str:
"""Inline every @import in `path`, depth first."""
seen = seen if seen is not None else set()
resolved = path.resolve()
if resolved in seen:
raise SystemExit(f"circular @import involving {resolved.relative_to(ROOT)}")
seen.add(resolved)
source = path.read_text(encoding="utf-8")
out: list[str] = []
cursor = 0
for match in IMPORT.finditer(source):
out.append(source[cursor : match.start()])
cursor = match.end()
target = (path.parent / match.group(1)).resolve()
if not target.is_file():
raise SystemExit(f"{path.relative_to(ROOT)}: cannot resolve {match.group(1)}")
# The import's own comment header is kept; it explains the section.
contents = bundle(target, seen).strip("\n")
layer = match.group(2)
if layer:
out.append(f"@layer {layer} {{\n{indent(contents)}\n}}")
else:
out.append(contents)
out.append(source[cursor:])
seen.discard(resolved)
return "".join(out)
def build() -> str:
header = (
"/* Niveus — generated bundle. Do not edit.\n"
" Source: niveus/niveus.css and the files it imports.\n"
" Rebuild: python3 tools/build.py\n"
"*/\n\n"
)
body = bundle(ENTRY)
# Collapse the runs of blank lines left behind by the inlined imports.
body = re.sub(r"\n{3,}", "\n\n", body).strip() + "\n"
return header + body
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="fail if dist/niveus.css differs from the sources",
)
args = parser.parse_args()
result = build()
if args.check:
current = OUTPUT.read_text(encoding="utf-8") if OUTPUT.is_file() else None
if current != result:
print("dist/niveus.css is out of date — run: python3 tools/build.py")
return 1
print("dist/niveus.css is up to date")
return 0
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(result, encoding="utf-8")
size = len(result.encode("utf-8"))
print(f"wrote {OUTPUT.relative_to(ROOT)} ({size:,} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Check that the Niveus sources hold to their own rules.
Four things go wrong in a design system long before anything looks broken, and
all four are mechanical:
1. A semantic token is defined for one appearance but not the other, so a
role silently resolves to nothing in light or in dark.
2. A component references a token that does not exist — a typo that degrades
to "no style" instead of to an error.
3. A visual value is hard-coded in a component — or in an example, which
claims to be built from the system and nothing else — instead of coming
from a token, which is how a system stops being a single source of truth.
4. dist/ drifts from the sources.
This script catches the first three; `build.py --check` covers the fourth.
python3 tools/check.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "niveus"
DEFINITION = re.compile(r"(--[a-zA-Z0-9_-]+)\s*:")
REFERENCE = re.compile(r"var\(\s*(--[a-zA-Z0-9_-]+)")
COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
# A raw color or a raw shadow geometry in a component means the design language
# was restated locally instead of referenced.
HARD_CODED = [
(re.compile(r"#[0-9a-fA-F]{3,8}\b"), "hard-coded color"),
(re.compile(r"\b(?:rgb|rgba|hsl|hsla|oklch|lab)\(", re.IGNORECASE), "hard-coded color"),
]
# Properties the *application* supplies per instance, not the system. A menu
# and its trigger have to name the same anchor, and only the markup knows which
# pair belongs together.
CONSUMER_DEFINED = {"--nv-anchor"}
TOKEN_FILES = sorted((SRC / "tokens").glob("*.css")) + sorted((SRC / "themes").glob("*.css"))
CONSUMER_FILES = (
sorted((SRC / "components").glob("*.css"))
+ [SRC / "base.css", SRC / "utilities.css"]
)
def strip_comments(text: str) -> str:
return COMMENT.sub("", text)
def collect(paths: list[Path]) -> tuple[set[str], dict[str, list[tuple[Path, int, str]]]]:
"""Return every custom property defined across `paths`, and every reference."""
defined: set[str] = set()
references: dict[str, list[tuple[Path, int, str]]] = {}
for path in paths:
source = strip_comments(path.read_text(encoding="utf-8"))
for line_no, line in enumerate(source.split("\n"), start=1):
for name in DEFINITION.findall(line):
defined.add(name)
for name in REFERENCE.findall(line):
references.setdefault(name, []).append((path, line_no, line.strip()))
return defined, references
def rel(path: Path) -> str:
return str(path.relative_to(ROOT))
def check_theme_parity(failures: list[str]) -> None:
light = SRC / "themes" / "light.css"
dark = SRC / "themes" / "dark.css"
light_roles = {
name[len("--nv-light-") :]
for name in DEFINITION.findall(strip_comments(light.read_text(encoding="utf-8")))
if name.startswith("--nv-light-")
}
dark_roles = {
name[len("--nv-dark-") :]
for name in DEFINITION.findall(strip_comments(dark.read_text(encoding="utf-8")))
if name.startswith("--nv-dark-")
}
for role in sorted(light_roles - dark_roles):
failures.append(f"themes/dark.css: missing --nv-dark-{role} (light defines it)")
for role in sorted(dark_roles - light_roles):
failures.append(f"themes/light.css: missing --nv-light-{role} (dark defines it)")
if not failures:
print(f" theme parity: {len(light_roles)} roles defined in both appearances")
def check_references(failures: list[str]) -> None:
all_files = TOKEN_FILES + CONSUMER_FILES
defined, references = collect(all_files)
unresolved = []
for name, sites in sorted(references.items()):
if name.startswith("--_"):
continue # component-private, defined and used in the same rule
if name in CONSUMER_DEFINED:
continue
if name in defined:
continue
# var() with a fallback is an intentional escape hatch.
if all("," in line.split(f"var({name}")[1][:40] for _, _, line in sites):
continue
unresolved.append((name, sites[0]))
for name, (path, line_no, _) in unresolved:
failures.append(f"{rel(path)}:{line_no}: {name} is referenced but never defined")
if not unresolved:
print(f" references: {len(references)} tokens referenced, all resolve")
def check_hard_coded(failures: list[str]) -> None:
found = 0
for path in CONSUMER_FILES:
source = strip_comments(path.read_text(encoding="utf-8"))
for line_no, line in enumerate(source.split("\n"), start=1):
for pattern, label in HARD_CODED:
if pattern.search(line):
failures.append(f"{rel(path)}:{line_no}: {label} — {line.strip()}")
found += 1
if not found:
print(f" no hard-coded colors in {len(CONSUMER_FILES)} component and base files")
REFERENCE_DOC = ROOT / "REFERENCE.md"
EXAMPLES = ROOT / "examples"
# The one literal color an example is allowed: the input to the system.
BRAND_INPUT = re.compile(r"--nv-brand\s*:")
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE)
INLINE_STYLE = re.compile(r"""\sstyle=["']([^"']*)["']""")
def check_examples(failures: list[str]) -> None:
"""The examples claim to be built from the system and nothing else.
That claim is only worth making if it is enforced: an example that quietly
reaches for a hex value is no longer demonstrating the system, it is
demonstrating how to work around one. The single exception is --nv-brand,
which is the input the whole color system derives from — a literal there is
the point.
"""
if not EXAMPLES.is_dir():
return
scanned = 0
for path in sorted(EXAMPLES.rglob("*")):
if path.suffix not in {".css", ".html"}:
continue
scanned += 1
raw = path.read_text(encoding="utf-8")
if path.suffix == ".css":
regions = [(1, strip_comments(raw))]
else:
# Only the parts of an HTML file that can carry style.
regions = []
for match in STYLE_BLOCK.finditer(raw):
line = raw[: match.start()].count("\n") + 1
regions.append((line, strip_comments(match.group(1))))
for match in INLINE_STYLE.finditer(raw):
line = raw[: match.start()].count("\n") + 1
regions.append((line, match.group(1)))
for base_line, region in regions:
for offset, line in enumerate(region.split("\n")):
if BRAND_INPUT.search(line):
continue
for pattern, label in HARD_CODED:
if pattern.search(line):
failures.append(
f"{rel(path)}:{base_line + offset}: {label} in an example "
f"— {line.strip()[:70]}"
)
if path.name == "index.html" and "dist/niveus.css" not in raw:
failures.append(f"{rel(path)}: does not link the system stylesheet")
if not failures:
print(f" examples: {scanned} files, no color stated outside --nv-brand")
def check_reference(failures: list[str]) -> None:
"""Every class the CSS defines must appear in REFERENCE.md, spelled in full.
The reference claims to be the entire API — which is the only reason it is
safe to hand to someone, or to a model, instead of the source. A class that
exists but is undocumented breaks that claim silently, and a documented
class that no longer exists is worse: it reads as real.
"""
if not REFERENCE_DOC.is_file():
return
documented = REFERENCE_DOC.read_text(encoding="utf-8")
defined: set[str] = set()
sources = (
sorted((SRC / "components").glob("*.css"))
+ [SRC / "utilities.css", SRC / "base.css", SRC / "continuity.css"]
)
for path in sources:
source = strip_comments(path.read_text(encoding="utf-8"))
defined.update(re.findall(r"\.(nv-[a-z0-9_-]+)", source))
missing = sorted(name for name in defined if f".{name}" not in documented)
for name in missing:
failures.append(f"REFERENCE.md: .{name} is defined in the CSS but not documented")
# And the reverse: nothing documented that no longer exists.
mentioned = set(re.findall(r"`\.(nv-[a-z0-9_-]+)`", documented))
for name in sorted(mentioned - defined):
failures.append(f"REFERENCE.md: .{name} is documented but not defined anywhere")
if not missing:
print(f" reference: {len(defined)} classes, all documented")
def main() -> int:
failures: list[str] = []
print("Niveus checks")
check_theme_parity(failures)
check_references(failures)
check_hard_coded(failures)
check_examples(failures)
check_reference(failures)
if failures:
print(f"\n{len(failures)} problem(s):")
for failure in failures:
print(f" {failure}")
return 1
print("\nAll checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,701 +0,0 @@
/* SPDX-License-Identifier: Apache-2.0
*
* The palette is not chosen here. It is include/theme.h, transcribed, because
* a site that describes an operating system should look like the operating
* system. That includes the rule theme.h enforces:
*
* normal states are calm and dark; only failure is loud.
*
* PSOGM purple (--psogm) therefore appears exactly once on this page, in the
* crash-screen section. If it ever appears anywhere else, delete it there.
*/
:root {
/* include/theme.h */
--bg: #0C0E13; /* SCH_BG */
--fg: #E6E8EC; /* SCH_FG */
--dim: #707784; /* SCH_DIM */
--accent: #7C5CFF; /* SCH_ACCENT */
--accent-dim: #2A2440; /* SCH_ACCENT_DIM */
--ok: #4ADE80; /* SCH_OK */
--warn: #FBBF24; /* SCH_WARN */
--psogm: #4B1D9A; /* PSOGM_BG — failure, and nothing else */
/* Surfaces lifted off the ground, keeping its blue bias rather than
sliding toward neutral grey. */
--surface: #12151C;
--surface-2: #171B24;
--line: #212632;
--sans: "Roboto", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "Cascadia Code",
Menlo, Consolas, monospace;
--measure: 66ch;
--gap: clamp(1rem, 2.5vw, 1.75rem);
--section: clamp(4rem, 10vw, 7.5rem);
}
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--sans);
font-size: clamp(1rem, 0.95rem + 0.2vw, 1.0625rem);
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
/* ---- shared shells ------------------------------------------------- */
.wrap {
width: min(100% - 2.5rem, 68rem);
margin-inline: auto;
}
section { padding-block: var(--section); }
.eyebrow {
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 0.75rem;
}
h1, h2, h3 { text-wrap: balance; line-height: 1.15; margin: 0; }
h2 {
font-size: clamp(1.75rem, 1.3rem + 2vw, 2.75rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.lede {
max-width: var(--measure);
color: var(--dim);
font-size: clamp(1.05rem, 1rem + 0.35vw, 1.2rem);
margin: 1.25rem 0 0;
}
p { max-width: var(--measure); }
a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 3px; }
a:hover { color: var(--fg); }
a:focus-visible,
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 2px;
}
code, .mono { font-family: var(--mono); font-size: 0.9em; }
code { color: var(--fg); background: var(--surface-2); padding: 0.1em 0.4em; border-radius: 3px; }
/* ---- header -------------------------------------------------------- */
header.top {
position: sticky;
top: 0;
z-index: 10;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.top .wrap {
display: flex;
align-items: center;
gap: 1.5rem;
padding-block: 0.85rem;
}
.brand {
display: flex; align-items: center; gap: 0.6rem;
font-weight: 700; letter-spacing: -0.01em;
color: inherit; text-decoration: none;
}
.brand img { width: 26px; height: 26px; display: block; }
.top nav { margin-left: auto; display: flex; gap: 1.5rem; font-size: 0.9rem; }
.top nav a { color: var(--dim); text-decoration: none; }
.top nav a:hover { color: var(--fg); }
@media (max-width: 46rem) { .top nav { display: none; } }
/* ---- hero ---------------------------------------------------------- */
.hero { padding-block: clamp(3.5rem, 9vw, 6.5rem) var(--section); }
.hero .wrap {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
gap: clamp(2rem, 5vw, 4rem);
align-items: center;
}
@media (max-width: 56rem) {
.hero .wrap { grid-template-columns: 1fr; }
}
.hero h1 {
font-size: clamp(2.75rem, 1.8rem + 4.2vw, 4.75rem);
font-weight: 700;
letter-spacing: -0.035em;
}
.hero h1 .mark {
display: inline-block;
width: 0.95em;
height: 0.95em;
vertical-align: -0.08em;
margin-right: 0.22em;
}
.hero p { color: var(--dim); font-size: clamp(1.05rem, 1rem + 0.4vw, 1.25rem); margin-top: 1.25rem; }
.hero .cta { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 2rem; }
.btn {
display: inline-block;
padding: 0.65rem 1.25rem;
border-radius: 4px;
font-size: 0.925rem;
font-weight: 500;
text-decoration: none;
border: 1px solid var(--line);
color: var(--fg);
background: var(--surface);
}
.btn:hover { border-color: var(--accent); color: var(--fg); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #0B0714; font-weight: 700; }
.btn.primary:hover { background: #8F73FF; border-color: #8F73FF; color: #0B0714; }
/* ---- console ------------------------------------------------------- */
.console {
background: #090B0F;
border: 1px solid var(--line);
border-radius: 6px;
overflow: hidden;
box-shadow: 0 24px 60px -30px #000;
}
.console-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
border-bottom: 1px solid var(--line);
background: var(--surface);
font-family: var(--mono);
font-size: 0.72rem;
color: var(--dim);
letter-spacing: 0.06em;
}
.console-bar .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--line); }
.console-bar .dot.live { background: var(--ok); }
.console-bar span { margin-left: auto; }
.console pre {
margin: 0;
padding: 1rem 1.1rem 1.4rem;
font-family: var(--mono);
font-size: clamp(0.72rem, 0.62rem + 0.35vw, 0.82rem);
line-height: 1.85;
color: var(--fg);
overflow-x: auto;
}
/* The hero's console reserves its height so the typing animation does not
push the page around as it fills. A static transcript sizes to its own
content instead, which is why this is not on .console pre. */
.hero .console pre { min-height: 16.5rem; }
.console.transcript { margin-top: 3rem; }
.console .t { color: var(--dim); }
.console .k { color: var(--accent); }
.console .g { color: var(--ok); }
.console .cursor {
display: inline-block;
width: 0.55em;
height: 1.05em;
background: var(--accent);
vertical-align: -0.18em;
animation: blink 1.1s step-end infinite;
}
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .console .cursor { animation: none; } }
/* ---- origin timeline ----------------------------------------------- */
.origin-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: var(--gap);
margin-top: 3rem;
}
.step {
border-left: 2px solid var(--accent-dim);
padding-left: 1.1rem;
}
.step h3 { font-size: 1.05rem; font-weight: 700; margin-bottom: 0.4rem; }
.step .when { font-family: var(--mono); font-size: 0.72rem; color: var(--accent); letter-spacing: 0.08em; text-transform: uppercase; display: block; margin-bottom: 0.5rem; }
.step p { color: var(--dim); font-size: 0.925rem; margin: 0; }
/* ---- the stack ----------------------------------------------------- */
.stack { display: flex; flex-direction: column; gap: 3px; margin-top: 3rem; }
.layer {
display: grid;
grid-template-columns: 10.5rem 1fr;
gap: 1.25rem;
align-items: baseline;
padding: 0.95rem 1.15rem;
background: var(--surface);
border: 1px solid transparent;
border-radius: 3px;
}
.layer:hover { border-color: var(--accent-dim); background: var(--surface-2); }
.layer .name { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); letter-spacing: 0.04em; }
.layer .what { color: var(--dim); font-size: 0.9rem; }
.layer.seam { background: var(--accent-dim); }
.layer.seam .name { color: var(--fg); }
.layer.seam .what { color: #C9BEEC; }
@media (max-width: 42rem) {
.layer { grid-template-columns: 1fr; gap: 0.3rem; }
}
.seam-note { margin-top: 1.5rem; color: var(--dim); font-size: 0.9rem; }
/* ---- features ------------------------------------------------------ */
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
gap: var(--gap);
margin-top: 3rem;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 5px;
padding: 1.4rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.card h3 { font-size: 1.05rem; font-weight: 700; }
.card p { color: var(--dim); font-size: 0.925rem; margin: 0; max-width: none; }
.tag {
align-self: flex-start;
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.2rem 0.5rem;
border-radius: 3px;
border: 1px solid var(--line);
color: var(--dim);
}
.tag.runs { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
.tag.partial{ color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
.tag.planned{ color: var(--dim); }
/* ---- numbers ------------------------------------------------------- */
.figures {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: var(--gap);
margin-top: 3rem;
}
.figure { border-top: 1px solid var(--line); padding-top: 1rem; }
.figure b {
display: block;
font-family: var(--mono);
font-size: clamp(1.6rem, 1.2rem + 1.6vw, 2.25rem);
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.02em;
}
.figure span { color: var(--dim); font-size: 0.85rem; }
/* ---- the crash screen: the one loud thing --------------------------- */
.crash { background: var(--psogm); color: #fff; }
.crash .wrap {
display: grid;
grid-template-columns: 8rem minmax(0, 1fr);
gap: clamp(1.5rem, 4vw, 3rem);
align-items: start;
}
@media (max-width: 44rem) { .crash .wrap { grid-template-columns: 1fr; } }
/* Flat white, not the logo's own violets. This is what the kernel actually
draws: PSOGM composites the coverage mask in one colour, so a full-colour
mark here would show something the crash screen never shows -- and those
violets are close enough to this purple to half-disappear against it. */
.crash img {
width: 100%;
max-width: 8rem;
height: auto;
filter: brightness(0) invert(1);
}
.crash .eyebrow { color: #C3AEEC; }
.crash h2 { color: #fff; }
.crash p { color: #DBCEF5; }
.crash code { background: rgba(0, 0, 0, 0.28); color: #fff; }
/* ---- closing links -------------------------------------------------- */
.links { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: var(--gap); margin-top: 2.5rem; }
.link-card {
display: block;
padding: 1.2rem;
border: 1px solid var(--line);
border-radius: 5px;
text-decoration: none;
color: var(--fg);
background: var(--surface);
}
.link-card:hover { border-color: var(--accent); color: var(--fg); }
.link-card b { display: block; margin-bottom: 0.3rem; }
.link-card span { color: var(--dim); font-size: 0.875rem; }
footer {
border-top: 1px solid var(--line);
padding-block: 2.5rem 3.5rem;
color: var(--dim);
font-size: 0.85rem;
}
footer .wrap { display: flex; flex-wrap: wrap; gap: 1rem 2rem; align-items: center; }
footer .mono { font-size: 0.78rem; }
/* ---- reveal on scroll ---------------------------------------------- */
.reveal { opacity: 0; transform: translateY(12px); transition: opacity .5s ease, transform .5s ease; }
.reveal.in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
.reveal { opacity: 1; transform: none; transition: none; }
}
/* The SchFS mark, set beside its own card heading. Baseline-aligned rather
than centred: it sits next to a word, so it follows the word. */
.card h3 .cardmark {
width: 1.15em;
height: 1.15em;
vertical-align: -0.22em;
margin-right: 0.4em;
}
/* =====================================================================
The pride mark
---------------------------------------------------------------------
Small, and permanent. It is in the top bar in November the same way it
is in June, which is the only version of this that means anything --
support with an end date is marketing.
Deliberately not bigger than this. The site is about an operating
system; the flag is a statement, not a subject.
===================================================================== */
.pride {
display: inline-flex;
align-items: center;
line-height: 0;
border-radius: 2px;
text-decoration: none;
}
.pride img {
width: 26px;
height: auto;
display: block;
border-radius: 2px;
/* Sits down slightly against the dark ground so it reads as part of the
bar rather than as a sticker on top of it; full colour on hover. */
opacity: 0.85;
transition: opacity .18s ease, transform .18s ease;
}
.pride:hover img { opacity: 1; transform: translateY(-1px); }
@media (prefers-reduced-motion: reduce) {
.pride img { transition: none; }
.pride:hover img { transform: none; }
}
.pride-note {
display: flex;
align-items: center;
gap: 0.6rem;
flex-basis: 100%;
color: var(--dim);
font-size: 0.82rem;
}
.pride-note img { width: 20px; height: auto; border-radius: 2px; flex: none; }
/* =====================================================================
The documentation reader
===================================================================== */
body.reading { display: flex; flex-direction: column; min-height: 100vh; }
body.reading footer { margin-top: auto; }
.docshell {
width: min(100% - 2.5rem, 84rem);
margin-inline: auto;
display: grid;
grid-template-columns: 15rem minmax(0, 1fr) 13rem;
gap: clamp(1.5rem, 3vw, 3rem);
align-items: start;
padding-block: 2.5rem 4rem;
}
@media (max-width: 72rem) { .docshell { grid-template-columns: 14rem minmax(0, 1fr); } .rail { display: none; } }
@media (max-width: 54rem) { .docshell { grid-template-columns: 1fr; } .sidebar .sticky { position: static; max-height: none; } }
.sticky {
position: sticky;
top: 4.5rem;
max-height: calc(100vh - 6rem);
overflow-y: auto;
scrollbar-width: thin;
}
.navgroup {
font-family: var(--mono);
font-size: 0.68rem;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--dim);
margin: 1.6rem 0 0.5rem;
}
.navgroup:first-child { margin-top: 0; }
#docnav ul, #toc ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
#docnav a, #toc a {
display: block;
padding: 0.28rem 0.6rem;
margin-left: -0.6rem;
border-radius: 3px;
color: var(--dim);
text-decoration: none;
font-size: 0.875rem;
line-height: 1.45;
}
#docnav a:hover, #toc a:hover { color: var(--fg); background: var(--surface); }
#docnav a.on {
color: var(--fg);
background: var(--accent-dim);
box-shadow: inset 2px 0 0 var(--accent);
}
#toc .lvl3 a { padding-left: 1.5rem; font-size: 0.8rem; }
/* ---- prose ---------------------------------------------------------- */
.prose { max-width: 46rem; }
.prose .loading { color: var(--dim); font-family: var(--mono); font-size: 0.85rem; }
.prose h1 {
font-size: clamp(1.9rem, 1.4rem + 2.2vw, 2.6rem);
font-weight: 700;
letter-spacing: -0.025em;
margin: 0 0 1.25rem;
}
.prose h2 {
font-size: clamp(1.3rem, 1.1rem + 1vw, 1.65rem);
font-weight: 700;
letter-spacing: -0.015em;
margin: 2.75rem 0 0.9rem;
padding-top: 1.6rem;
border-top: 1px solid var(--line);
}
.prose h3 { font-size: 1.1rem; font-weight: 700; margin: 2rem 0 0.6rem; }
.prose h4, .prose h5, .prose h6 { font-size: 0.95rem; font-weight: 700; margin: 1.5rem 0 0.5rem; color: var(--dim); }
.prose p, .prose li { color: #C6CAD3; }
.prose p { margin: 0 0 1.1rem; max-width: none; }
.prose strong { color: var(--fg); }
.prose a { color: var(--accent); }
.prose ul, .prose ol { margin: 0 0 1.2rem; padding-left: 1.35rem; display: flex; flex-direction: column; gap: 0.4rem; }
.prose li { line-height: 1.6; }
.prose li::marker { color: var(--accent); }
.prose hr { border: 0; border-top: 1px solid var(--line); margin: 2.25rem 0; }
.prose blockquote {
margin: 1.4rem 0;
padding: 0.2rem 0 0.2rem 1.1rem;
border-left: 2px solid var(--accent);
color: var(--dim);
}
.prose blockquote p:last-child { margin-bottom: 0; }
.prose pre.code {
background: #090B0F;
border: 1px solid var(--line);
border-radius: 5px;
padding: 0.95rem 1.1rem;
overflow-x: auto;
margin: 0 0 1.3rem;
position: relative;
}
.prose pre.code code {
font-family: var(--mono);
font-size: 0.815rem;
line-height: 1.7;
color: #CBD1DC;
background: none;
padding: 0;
white-space: pre;
}
.prose pre.code[data-lang]::after {
content: attr(data-lang);
position: absolute;
top: 0.5rem;
right: 0.7rem;
font-family: var(--mono);
font-size: 0.62rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--dim);
}
.prose :not(pre) > code {
font-family: var(--mono);
font-size: 0.85em;
background: var(--surface-2);
border: 1px solid var(--line);
color: #D8DCE4;
padding: 0.08em 0.36em;
border-radius: 3px;
}
.tablewrap { overflow-x: auto; margin: 0 0 1.4rem; }
.prose table { border-collapse: collapse; width: 100%; font-size: 0.9rem; }
.prose th, .prose td {
text-align: left;
padding: 0.55rem 0.85rem;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.prose th {
font-family: var(--mono);
font-size: 0.72rem;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--accent);
border-bottom-color: var(--accent-dim);
white-space: nowrap;
}
.prose td { color: #C6CAD3; }
.prose tbody tr:hover td { background: var(--surface); }
.prose img { max-width: 100%; height: auto; }
.top nav a.on { color: var(--fg); }
.readerlink { margin-top: 2rem; }
/* A footer note that is a paragraph rather than a credit line. Same width
discipline as body text: these are read, not scanned. */
footer .note {
flex-basis: 100%;
max-width: 54rem;
color: var(--dim);
font-size: 0.82rem;
line-height: 1.7;
}
footer .note b { color: var(--fg); font-weight: 700; }
footer .note a { color: var(--accent); }
/* The two notes sit below the identity line, not beside it. */
footer .wrap { row-gap: 1.5rem; }
/* ---- the download page --------------------------------------------- */
/*
* Same components as the front page, arranged for a page with one job. The
* hero here is a two-column split rather than a pitch: the action on the left,
* and immediately beside it the thing a download page usually buries, which is
* what this has actually been run on.
*/
.getit .wrap { align-items: start; }
.getit h1 { font-size: clamp(2rem, 1.5rem + 2.4vw, 3.1rem); }
.getit p { color: var(--dim); }
h2.small {
font-size: clamp(1.15rem, 1rem + 0.6vw, 1.4rem);
letter-spacing: -0.01em;
margin: 0;
}
.getpanel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 6px;
padding: clamp(1.25rem, 3vw, 1.75rem);
}
.getpanel p { font-size: 0.92rem; margin: 0.85rem 0 0; }
/* Not a data table -- a two-column list of claims, where the claim on the
right is the whole content and should line up down the page. */
.support {
width: 100%;
border-collapse: collapse;
margin-top: 1.25rem;
font-size: 0.9rem;
}
.support td { padding: 0.55rem 0; border-top: 1px solid var(--line); }
.support tr:first-child td { border-top: 0; }
.support td:last-child { text-align: right; white-space: nowrap; }
.dimtext { color: var(--dim); }
.getit .filemeta {
margin-top: 1.25rem;
font-family: var(--mono);
font-size: 0.78rem;
color: var(--dim);
}
.fineprint, .getpanel .fineprint, .card .fineprint {
color: var(--dim);
font-size: 0.82rem;
line-height: 1.65;
}
/* Two columns rather than as-many-as-fit: these two are a pair to be compared,
and a three-up row would put the destructive one somewhere it reads as an
equal option. */
.cards.two { grid-template-columns: repeat(auto-fit, minmax(21rem, 1fr)); }
/* A command you are meant to type, as opposed to output you are meant to read
-- which is what .console is for. The prompt is dim because it is not part
of what you type, and the part to substitute is the accent, because that is
the only decision in the line. */
.cmd {
margin: 1.1rem 0 0;
padding: 0.8rem 0.9rem;
background: #090B0F;
border: 1px solid var(--line);
border-radius: 4px;
font-family: var(--mono);
font-size: 0.78rem;
line-height: 1.8;
color: var(--fg);
overflow-x: auto;
}
.cmd.block { margin-top: 2.5rem; font-size: 0.82rem; padding: 1.1rem 1.2rem; }
.cmd .p { color: var(--dim); user-select: none; }
.cmd .v { color: var(--accent); }
.cmd .c { color: var(--dim); }
/* The closing band on the front page. Same two-column shape as the crash
section, so the page ends the way it turns: a mark on the left, the thing to
do on the right. */
.getband { border-top: 1px solid var(--line); }
.getband .wrap {
display: grid;
grid-template-columns: clamp(5rem, 10vw, 8rem) minmax(0, 1fr);
gap: clamp(1.5rem, 4vw, 3rem);
align-items: center;
}
.getband img { width: 100%; height: auto; display: block; }
.getband .readerlink { display: flex; flex-wrap: wrap; gap: 0.75rem; }
@media (max-width: 40rem) {
.getband .wrap { grid-template-columns: 1fr; }
.getband img { width: 5rem; }
}

443
docs/niveus/README.md Normal file
View file

@ -0,0 +1,443 @@
# Niveus
A calm, adaptive interface language built around clarity, depth, material and
consistency.
Niveus is a **design-system layer**, not a component library bolted onto an
application. Applications consume it; they do not redefine it. The visual rules
live here and can change without touching a line of application logic.
```html
<link rel="stylesheet" href="dist/niveus.css">
```
```html
<html data-theme="dark"> <!-- omit data-theme to follow the system -->
```
Open `index.html` for the full specimen: materials, the elevation scale, every
semantic color, the type scale, and each component in both appearances.
`examples/` holds four complete products built with it.
**[REFERENCE.md](REFERENCE.md) is the complete API** — every class, token,
attribute and function, each listed beside the name the rest of the industry
uses for it. It is written to be read by a person *or* handed to a model on its
own: an assistant that has only that file can write correct Niveus markup
without seeing a line of the CSS. `tools/check.py` fails if a class exists in
the CSS and is missing from it.
---
## Using it
Reference the system, never restate it:
```css
/* No */
.thing { background: #ffffff; border-radius: 12px; padding: 10px 16px; }
/* Yes */
.thing {
background: var(--nv-color-surface-elevated);
border-radius: var(--nv-radius-md);
padding: var(--nv-space-2-5) var(--nv-space-4);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
}
```
Application-specific values are fine when they describe *your* content and
layout — a sidebar that happens to be 15rem wide, an aspect ratio, a grid
template. They are not fine when they restate the design language.
Everything ships inside the `niveus` cascade layer, so unlayered application
styles always win. Overriding a Niveus component never requires `!important`
or a longer selector.
## Structure
```
niveus/
├── tokens/ the values, appearance-agnostic
│ ├── color.css palette + semantic roles
│ ├── typography.css families, scale, weights, composite roles
│ ├── spacing.css 4px rhythm + control sizing
│ ├── radius.css corner scale
│ ├── elevation.css the 0–5 depth scale, inset, highlight, focus ring
│ ├── motion.css durations, easings, displacement
│ └── materials.css blur, backdrop recipes, material definitions
│
├── themes/ what those values become in each appearance
│ ├── light.css
│ └── dark.css
│
├── base.css elements, before any component
├── continuity.css how one state becomes the next
├── components/ surface, button, input, card, dialog, navigation,
│ menu, disclosure, scroller, bed, badge, table
├── utilities.css the short list
├── niveus.css the entry point
└── niveus.js optional — continuity for changes CSS cannot animate
examples/ four complete interfaces built from the system
├── ai-landing/ marketing page
├── ai-chat/ chat web app
├── it-shop/ hardware shop
└── dashboard/ monitoring dashboard
```
`niveus/niveus.css` is the authored entry point and works directly over HTTP.
`dist/niveus.css` is the same thing with the `@import` chain flattened into one
request — use it for anything shipped.
## Theming
A component never names a color. It names a **role**, and the appearance decides
what that role is made of:
`background` · `surface` · `surface-elevated` · `surface-inset` ·
`text-primary` · `text-secondary` · `text-tertiary` · `border` · `accent` ·
`success` · `warning` · `danger`
Each role resolves through `light-dark()`, pulling from `--nv-light-*` in
`themes/light.css` or `--nv-dark-*` in `themes/dark.css`. The appearance is
selected by `color-scheme`, which the theme files bind to `[data-theme]`:
| `data-theme` | Result |
| ------------ | ----------------------------- |
| *(absent)* | follows the operating system |
| `light` | light, always |
| `dark` | dark, always |
Dark is not light inverted. Surfaces get *lighter* as they rise, shadows deepen
rather than disappear, the accent moves up its ramp to stay legible, and the top
highlight thins to a faint rim.
## Materials and depth
**Material** answers what light does when it hits a surface. **Elevation**
answers how far that surface is from the canvas. They are independent.
| Material | Use for |
| ---------- | ---------------------------------------------- |
| `solid` | content. The default. |
| `elevated` | surfaces that must be read before their context |
| `inset` | fields, wells, tracks — anything recessed |
| `frosted` | chrome that content passes under: bars, toolbars |
| `glass` | contextual layers where content behind must stay legible |
| Elevation | Relationship |
| --------- | ------------------------------- |
| 0 | flush — painted on the canvas |
| 1 | resting — cards, rows, panels |
| 2 | raised — anything that reacts |
| 3 | floating — popovers, menus |
| 4 | lifted — dialogs, sheets |
| 5 | overlay — topmost, used sparingly |
Applied as classes: `.nv-material-frosted`, `.nv-elevation-3`. A shadow that is
on neither scale is a shadow that means nothing.
## Continuity
Every interaction should read as a continuation of the state before it. When
something moves, grows, collapses or changes appearance, the change itself
should be perceptible — where it came from, where it went, and why. The goal is
not to animate everything. It is that nothing pops into a new state the user
could have watched it reach.
| Change | How it stays continuous |
| ------ | ----------------------- |
| Selection moves between items | The marker itself travels. It is a pseudo-element with a shared `view-transition-name`, so the browser interpolates its old box into its new one. |
| Switching tabs | Panels cross-fade inside one box and slide with the direction of travel — forward one way, back the other. |
| Content expanding | `interpolate-size` lets `block-size: auto` animate, so content grows from the row that revealed it. Nothing is measured. |
| Menus and dialogs | A menu is anchored to its trigger and scales out of its edge; a dialog expands away from the control that opened it. |
| Theme change | The whole surface cross-fades. Surfaces also transition their own colors, so it glides even with no script. |
| Hover, press, focus, select | All have a duration. Marks grow from their centre, the switch thumb travels its track. |
Most of this is plain CSS. The three cases CSS cannot do alone — a selection
moving *between two elements*, one panel replacing another, and the whole page
changing appearance — need a view transition, which needs one line of script:
```js
nv.transition(() => { // or document.startViewTransition directly
previous.removeAttribute("aria-current");
next.setAttribute("aria-current", "page");
});
```
`niveus.js` is optional and dependency-free. Without it every component still
works; changes simply arrive rather than travel. It also wires
`[data-nv-theme]`, `[data-nv-dialog]`, `[data-nv-tabs]` and `[data-nv-close]`
so the common cases need no code at all.
### One rule worth knowing
A `view-transition-name` must be unique among all rendered elements, or the
browser skips the transition entirely. Each selection group therefore needs its
own marker name:
```html
<nav class="nv-nav" style="--nv-selection-name: nv-sel-sidebar"> … </nav>
<nav class="nv-nav-tabs" style="--nv-selection-name: nv-sel-tabs"> … </nav>
```
Tab strips default to `nv-tabs-selection` and everything else to
`nv-selection`, which is enough for one of each per page.
### Motion under a transition
Transition layers always paint above the page, so a travelling marker cannot
stay behind the labels it passes the way it does at rest. Rather than let an
opaque marker wipe them, Niveus thins it while it is in flight: it stays
visible the whole way — it can still be followed — but reads as having lifted
off the surface to travel.
`prefers-reduced-motion` collapses every duration at the token level and skips
view transitions entirely. The change still happens; it just arrives.
## Color
The whole color system has one input:
```css
:root { --nv-brand: #d92e3f; }
```
From it the system derives the accent and its hover and press states, the text
that lands on it, the link color, the focus ring, the selection highlight, the
tint in the greys, and how far the status colors lean — for both appearances,
with nothing else to set. No component is involved: they all name roles, and
the roles re-derive.
Three things make a single input actually work rather than just recolor a
button:
**The label is derived, not chosen.** Every filled color computes its own
foreground by reading its lightness: below a threshold it resolves to
near-white, above it to near-black. A yellow brand gets dark labels and a navy
one light labels, and no one has to remember to update a second token.
**The color is moved until its label is readable.** A mid-tone is the one place
where neither white nor black reaches 4.5:1, so a brand that lands there is
darkened until white works — or, if already light, lightened until black does.
Hue and chroma are untouched; only lightness moves, and only as far as it must.
This is why a mid-tone green arrives slightly deeper than the swatch you set.
**The rest of the palette follows.** Status colors lean towards the brand by
`--nv-brand-harmony` (12%) — red still reads as danger, it just stops looking
borrowed from another system — and the greys carry `--nv-brand-tint` (4%) of
it, which is what stops an accent from looking stuck on. Set either to `0%` for
literal status colors and strictly neutral greys.
| Input | Default | What it does |
| ----- | ------- | ------------ |
| `--nv-brand` | `#4f6cff` | the one color everything derives from |
| `--nv-brand-tint` | `4%` | how much brand bleeds into the greys |
| `--nv-brand-harmony` | `12%` | how far status colors lean towards it |
| `--nv-contrast-pivot` | `0.7` | where "too mid-toned to carry a label" begins |
| `--nv-contrast-dark` / `-light` | `0.53` / `0.78` | how far either side a color is pushed |
### Branding a subtree
`data-nv-brand` re-derives the whole set for one region, so a section can carry
its own color without a second theme:
```html
<section data-nv-brand style="--nv-brand: #0d9488">
<button class="nv-button nv-button--primary">Upgrade</button>
</section>
```
The attribute is required, not decoration: `var()` inside a custom property is
substituted where that property is *declared*, so a `--nv-brand` set on a bare
element would never reach the roles. `[data-nv-brand]` is the selector that
re-runs the derivation.
### Overriding a role outright
Deriving is the default, not a requirement. Any role can be set directly, for
the whole page or for a subtree:
```css
.danger-zone { --nv-color-accent: var(--nv-color-danger); }
```
Everything inside follows — buttons, focus rings, links, switches — because
none of them ever named a color.
## Examples
`examples/` holds four complete interfaces. Open `examples/index.html`, or go
straight to one:
| | | |
| --- | --- | --- |
| **Lumen** | `examples/ai-landing/` | AI product landing page — hero, features, a scrolling row, pricing, FAQ |
| **Lumen Chat** | `examples/ai-chat/` | chat web app — sidebar, transcript, composer, settings dialog with tabs |
| **Northwire** | `examples/it-shop/` | hardware shop — filters, product grid, stock states, basket |
| **Northwire Ops** | `examples/dashboard/` | monitoring dashboard — stat tiles, a chart, a service table, incidents |
They look like four different products. None of them overrides a component,
forks a token, or writes a color. What differs between them is one declaration:
```css
:root { --nv-brand: #ea580c; } /* the shop */
```
Each links exactly two files from the system — `dist/niveus.css` and the
optional `niveus/niveus.js` — and then its own `app.css`, which contains grids,
widths and breakpoints and **not a single hex value**. `tools/check.py` enforces
that: a color anywhere in an example, other than `--nv-brand` itself, fails the
build. An example that quietly reaches for a hex value is no longer
demonstrating the system; it is demonstrating how to work around one.
Where an example needed something the system did not have, the answer was to
add it to the system: `.nv-badge` and `.nv-table` exist because these pages
asked for them.
## Philosophy
Niveus is not a folder of CSS. It is a set of decisions that have already been
made, so that building an interface stops being a series of small aesthetic
arguments.
**Niveus is not about making interfaces look physical. It is about making
digital surfaces feel understandable.** Depth, material and motion are here to
answer questions the user is already asking — what is this, is it above or
below, where did it come from, did my click register — and for nothing else. An
effect that answers no question is decoration, and decoration is what makes an
interface tiring.
### The principles, and what each one costs
| | |
| --- | --- |
| **Clarity** | Every element has a purpose. Decoration never competes with content. |
| **Soft physicality** | Depth comes from shadow, highlight and inset — not from ornament. |
| **Material** | Surfaces are made of something. Solid, elevated, inset, frosted, glass. |
| **Restraint** | No effect exists because it is possible. Translucency and shadow are spent, not sprinkled. |
| **Consistency** | The same control behaves the same way everywhere, including under a pointer. |
| **Adaptability** | Nothing is defined in terms of an appearance or a color. |
| **Continuity** | Nothing pops into a new state the user could have watched it reach. |
They constrain each other on purpose. Continuity says to animate the change;
restraint says most changes do not need it. Material says to use translucency;
restraint says only for layers that pass over content. When two pull in
different directions, restraint wins — a quiet interface with one thing moving
reads clearly, and a busy one does not.
### Where the line runs
Niveus decides **how the application looks and behaves**. The application
decides **what it does**. In practice:
- A color, a shadow, a radius, a duration, a control height → the system.
- A sidebar that happens to be 15rem wide, an aspect ratio, a grid template →
the application.
- A value that is *about* your content is yours. A value that restates the
design language is the system's, and hard-coding it is how a system quietly
stops being one.
### Adding to the system
Three questions, in order:
1. **Can it be expressed with what already exists?** Most new requirements are
an existing component with a modifier, or a pattern — a composition of
components that needs no new CSS at all. The patterns on the specimen page
introduce nothing.
2. **Is it a rule, or is it this once?** A component earns its place when it
can be stated as a reusable principle. "Selection emphasis travels" is a
rule. "This menu is 14px from its button" is not.
3. **Does it hold in both appearances, at every elevation, and under a
pointer?** If it only works on white, or only when nothing is behind it, it
is not finished.
A new token follows the same test. If a value appears three times and means the
same thing each time, it is a token. If it appears three times and means
something different each time, three different things were given the same
number by accident.
### Working on it
Every rule in this repo is checked rather than trusted:
- `tools/check.py` fails on a role defined for one appearance but not the
other, a token referenced but never defined, any hard-coded color in a
component *or an example*, and any class that exists in the CSS but is
missing from `REFERENCE.md`.
- Contrast is measured, not assumed — the derived label on every filled color
clears 4.5:1 for any brand.
- `tools/build.py --check` fails if `dist/` has drifted from the sources.
If a change cannot be made without breaking one of those, the change is
probably not the problem the system has.
## Components
| Class | Notes |
| -------------- | --------------------------------------------------------- |
| `.nv-button` | `--primary` `--subtle` `--ghost` `--danger` `--frosted` `--float`, `--sm`/`--lg`, `--icon`, `--pill`, `--block`, `.nv-button-group` |
| `.nv-input` | with `.nv-field`, `.nv-input-group`, `.nv-select`, `.nv-checkbox`, `.nv-radio`, `.nv-switch`, `.nv-choice` |
| `.nv-card` | `--elevated` `--floating` `--inset` `--frosted` `--interactive`, `.nv-card-grid` |
| `.nv-dialog` | native `<dialog>`; `--sheet` `--compact` `--wide` |
| `.nv-nav` | `--vertical` `--plain` `--inline`, plus `.nv-nav-tabs`, `.nv-tab-panel` and `.nv-breadcrumbs` |
| `.nv-menu` | anchored popover with `.nv-menu-trigger`, `__item`, `__separator`, `__label`, `__shortcut` |
| `.nv-disclosure` | native `<details>`; `.nv-disclosure-group`, plus `.nv-collapse` for other markup |
| `.nv-scroller` | snapping row inside `.nv-scroller-frame`, with edge fades and optional controls |
| `.nv-bed` | ground that is visibly not ordinary background; `--quiet` `--vivid` `--single` `--flush` |
| `.nv-badge` | state, not an action; `--accent` `--success` `--warning` `--danger`, `--solid` `--count` `--square` |
| `.nv-table` | inside `.nv-table-frame`; `__number` `__actions`, `--compact` `--interactive` |
| `.nv-surface` | materials and elevation as utilities |
Variants are defined by reassigning private `--_*` properties. Interaction —
the lift on hover, the sink on press, the focus ring — is written once per
component, so a new variant cannot drift from how the rest of the system
responds to a pointer.
## Accessibility
- One focus treatment system-wide: a contrast ring under an accent ring, so it
stays visible on any surface including accent-filled controls.
- Controls shorter than 44px still get a 44px pointer target.
- `prefers-reduced-motion` collapses duration and displacement to zero at the
token level — no component opts in individually.
- `prefers-reduced-transparency`, and browsers without `backdrop-filter`, fall
back from frosted and glass to their solid equivalents.
## Tools
```bash
python3 tools/build.py # flatten the @import chain into dist/niveus.css
python3 tools/build.py --check # fail if dist/ is stale (for CI)
python3 tools/check.py # enforce the system's own rules
```
`check.py` catches the failures that do not look broken: a role defined for one
appearance but not the other, a token referenced but never defined, and a
hard-coded color in a component.
## Browser support
Niveus uses `light-dark()`, cascade layers, `:has()`, `@starting-style` and
`allow-discrete` transitions — all baseline in current Chrome, Safari, Firefox
and Edge. `backdrop-filter` degrades to a solid surface where it is missing.
The continuity layer builds on newer platform features, each of which degrades
to the state change simply happening rather than travelling:
| Feature | Used for | Without it |
| ------- | -------- | ---------- |
| View transitions | travelling selection, tab panels, theme cross-fade | the change applies instantly |
| `interpolate-size` | disclosure height | content appears at full height (`.nv-collapse` still animates) |
| Anchor positioning | menus tied to their trigger | the menu centres itself |
| `popover` | menus, light dismiss | needs the application's own toggle |
---
> Niveus is not about making interfaces look physical. It is about making
> digital surfaces feel understandable.
---
Made by **Nexoniarz**.

393
docs/niveus/REFERENCE.md Normal file
View file

@ -0,0 +1,393 @@
# Niveus — reference
Every class, token, attribute and function the system exposes, with the name
the rest of the industry uses for it.
Two audiences:
- **People.** Find the thing you mean by its usual name, then use the class
beside it.
- **Models.** This file is the whole API. Hand it over on its own and an
assistant can build correct Niveus markup without seeing the CSS: it does not
need to know *how* `.nv-nav-tabs` works, only that a tab list is spelled that
way here. Nothing below is aspirational — `tools/check.py` fails if a class
exists in the CSS and is missing from this file.
Everything ships inside the `niveus` cascade layer, so unlayered application
styles win without `!important`.
```html
<link rel="stylesheet" href="dist/niveus.css">
<script src="niveus/niveus.js" defer></script> <!-- optional -->
<html data-theme="dark"> <!-- omit to follow the OS -->
<style>:root { --nv-brand: #d92e3f; }</style> <!-- the whole identity -->
```
---
## Button
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-button` | button, secondary button | Raised surface. The default; use it for anything that is not *the* action. |
| `.nv-button--primary` | filled / CTA button | The single affirmative action in a view. |
| `.nv-button--subtle` | tonal button | Accent-tinted fill, no elevation. Mid-emphasis. |
| `.nv-button--ghost` | text / quiet button | No material until hovered. Lowest emphasis. |
| `.nv-button--danger` | destructive button | Irreversible actions only. |
| `.nv-button--frosted` | glass button | For use over imagery or frosted chrome. |
| `.nv-button--float` | FAB, overlay control | Floats over content: grows on hover instead of lifting, and keeps its own positioning via `--nv-button-offset`. |
| `.nv-button--sm` `.nv-button--lg` | size variants | 28px and 44px; default is 36px. |
| `.nv-button--icon` | icon button | Square. Requires `aria-label`. |
| `.nv-button--pill` | pill button | Fully rounded. |
| `.nv-button--block` | full-width button | Fills its container. |
| `.nv-button__icon` | button icon slot | Sizes an inline SVG to the label. |
| `.nv-button-group` | segmented control, button group | Buttons sharing edges; only outer corners round. |
States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
(shows a spinner and keeps the width), `aria-pressed` for toggles.
## Form controls
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-field` | form field, form group | Label + control + hint, correctly spaced. |
| `.nv-field__label` | field label | Add `data-optional="(optional)"` to append a muted suffix. |
| `.nv-field__hint` | helper text | Guidance below the control. |
| `.nv-field__error` | validation message | Pair with `aria-invalid="true"` + `aria-describedby`. |
| `.nv-input` | text field, form control | `input`, `textarea` and `select`. Inset material. |
| `.nv-input--sm` `.nv-input--lg` | size variants | Match the button sizes. |
| `.nv-input--color` | color well / swatch input | `input[type=color]` as a square swatch. |
| `.nv-input-group` | input with adornment | Wrapper for a field with a prefix or suffix. |
| `.nv-input-group__affix` | prefix / suffix adornment | Sits inside the field's recess. First child = leading, last = trailing. |
| `.nv-select` | select, dropdown | Wrapper that draws the chevron around a native `select.nv-input`. |
| `.nv-checkbox` | checkbox | Native input, custom mark. Supports `:indeterminate`. |
| `.nv-radio` | radio button | Native input, custom mark. |
| `.nv-switch` | toggle switch | Inset track, elevated thumb that travels. |
| `.nv-choice` | control with inline label | `<label>` wrapping a control and its text. |
## Card
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-card` | card, panel | Resting container at elevation 1. |
| `.nv-card--elevated` | raised card | Elevation 2 — read before its surroundings. |
| `.nv-card--floating` | floating card | Elevation 3. |
| `.nv-card--inset` | well, sunken panel | Recessed into its parent. |
| `.nv-card--frosted` | glass card | Translucent, for use over imagery or a bed. |
| `.nv-card--interactive` | clickable card | Lifts on hover, sinks on press. |
| `.nv-card--flush` | edge-to-edge card | Removes part padding, for media or tables. |
| `.nv-card__header` | card header | |
| `.nv-card__title` | card title | |
| `.nv-card__subtitle` | card subtitle, supporting text | |
| `.nv-card__body` | card content | |
| `.nv-card__footer` | card actions | |
| `.nv-card__footer--plain` | undivided card actions | Drops the divider above the footer. |
| `.nv-card__media` | card media | 16:9 image slot. |
| `.nv-card__link` | stretched link | Expands one link's hit area over the whole card. |
| `.nv-card-grid` | responsive card grid | Auto-fitting, equal-height columns. |
## Dialog
Built on native `<dialog>`; open with `showModal()` or `nv.openDialog()`.
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-dialog` | modal dialog | Elevation 4 over a blurred scrim. Focus trap and Escape are the platform's. |
| `.nv-dialog--sheet` | bottom sheet | Enters from the block end, flush to the edges. |
| `.nv-dialog--compact` `.nv-dialog--wide` | width variants | 24rem / 44rem; default 30rem. |
| `.nv-dialog__header` | dialog header | |
| `.nv-dialog__title` | dialog title | Point `aria-labelledby` at it. |
| `.nv-dialog__description` | dialog supporting text | |
| `.nv-dialog__body` | dialog content | The only scrolling region. |
| `.nv-dialog__footer` | dialog actions | |
| `.nv-dialog__footer--divided` | grounded dialog actions | For a footer sitting over scrollable content. |
| `.nv-dialog__close` | close button | Optical alignment for a ghost icon button. |
## Menu
Built on the `popover` attribute and anchor positioning. Trigger and menu name
the same anchor through `--nv-anchor`.
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-menu` | dropdown menu, popover menu | Frosted surface anchored to its trigger. |
| `.nv-menu-trigger` | menu button | Sets `anchor-name` from `--nv-anchor`. |
| `.nv-menu__item` | menu item | `aria-checked="true"` draws a tick. |
| `.nv-menu__item--danger` | destructive menu item | |
| `.nv-menu__label` | menu section label / group heading | |
| `.nv-menu__separator` | menu divider | |
| `.nv-menu__shortcut` | keyboard shortcut hint, accelerator | Trailing muted text. |
## Navigation
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-nav` | app bar, navigation bar, header | Frosted, sticky chrome. |
| `.nv-nav--vertical` | sidebar, side navigation | Full-height, solid; drops the translucency. |
| `.nv-nav--plain` | solid bar | Opaque instead of frosted. |
| `.nv-nav--static` | non-sticky bar | |
| `.nv-nav--inline` | inline nav group | A navigation used inside content, not as chrome. |
| `.nv-nav__brand` | brand / logo lockup | |
| `.nv-nav__list` | nav list | |
| `.nv-nav__item` | nav item, nav link | Selected via `aria-current`, `aria-selected` or `--active`. |
| `.nv-nav__item--active` | active nav item | For when no ARIA state fits. |
| `.nv-nav__section` | nav section heading | |
| `.nv-nav__icon` | nav icon slot | |
| `.nv-nav__badge` | count badge, notification badge | |
| `.nv-nav__spacer` | flexible spacer | Pushes what follows to the end. |
| `.nv-nav-tabs` | tab list, segmented tabs | Inset track; the selected tab lifts out of it. |
| `.nv-tab-panel` | tab panel | Carries the shared content name so panels cross-fade. |
| `.nv-breadcrumbs` | breadcrumbs | |
## Disclosure
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-disclosure` | disclosure, accordion item | A `<details>` whose height animates open and closed. |
| `.nv-disclosure--plain` | bare disclosure | No surface of its own. |
| `.nv-disclosure__summary` | disclosure trigger | The `<summary>`. |
| `.nv-disclosure__chevron` | chevron, caret | Rotates rather than swapping glyphs. |
| `.nv-disclosure__body` | disclosure content | |
| `.nv-disclosure-group` | accordion | Stacked disclosures sharing hairlines. |
| `.nv-collapse` | collapsible region | Same relationship for markup that cannot be a `<details>`. Toggle `data-open`. |
## Scroller
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-scroller-frame` | carousel frame | Positions the controls; holds the track. |
| `.nv-scroller` | carousel, snap scroller, rail | Horizontal row with scroll snapping and fading edges. |
| `.nv-scroller--auto` | content-width items | Items size to content instead of the track. |
Set `--nv-scroller-item` for item width, `--nv-scroller-fade` for the edge fade.
## Badge
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-badge` | badge, chip, tag, status pill | Non-interactive statement of state. |
| `.nv-badge--accent` | accent badge | |
| `.nv-badge--success` `.nv-badge--warning` `.nv-badge--danger` | status badges | Tone follows the semantic role. |
| `.nv-badge--solid` | filled badge | For the one badge that must be seen first. |
| `.nv-badge--count` | count badge | Tabular, borderless, min two digits. |
| `.nv-badge--square` | square badge | |
| `.nv-badge__dot` | status dot | |
## Table
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-table-frame` | table wrapper | Scrolls sideways so the page does not have to. |
| `.nv-table` | data table | |
| `.nv-table--compact` | dense table | |
| `.nv-table--interactive` | clickable rows | |
| `.nv-table__number` | numeric cell | End-aligned, tabular figures. |
| `.nv-table__actions` | row actions cell | Shrinks to content. |
## Bed
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-bed` | showcase surface, patterned backdrop | Ground that is visibly not ordinary background: for regions the user should look *at*. Gives translucent materials something to be translucent against. |
| `.nv-bed--quiet` | subdued bed | No hatch, softer blooms. |
| `.nv-bed--vivid` | emphatic bed | |
| `.nv-bed--single` | single-hue bed | One color instead of three. |
| `.nv-bed--flush` | full-bleed bed | Square, no side borders. |
| `.nv-bed__label` | bed label, eyebrow | |
Set `--nv-bed-tint` to lead with a color other than the accent.
## Surface, material and elevation
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-surface` | surface, panel | Plain container on the solid material. |
| `.nv-material-solid` | opaque surface | Content lives here. The default. |
| `.nv-material-elevated` | raised surface | Separated from its context. |
| `.nv-material-inset` | recessed surface | Fields, wells, tracks. |
| `.nv-material-frosted` | frosted glass, blur backdrop | Chrome that content passes under. |
| `.nv-material-glass` | glass | Contextual layers where content behind must stay legible. |
| `.nv-elevation-0` `.nv-elevation-1` `.nv-elevation-2` | elevation / z-depth | flush · resting · raised |
| `.nv-elevation-3` `.nv-elevation-4` `.nv-elevation-5` | elevation / z-depth | floating · lifted · overlay |
| `.nv-elevation-inset-sm` `.nv-elevation-inset-md` `.nv-elevation-inset-lg` | inner shadow | Recessed surfaces. |
| `.nv-divider` | divider, separator, rule | |
| `.nv-divider--vertical` | column rule | Stretches to its flex row. |
Material and elevation are independent: combine freely.
## Type roles
| Class | Known as | Size |
| --- | --- | --- |
| `.nv-display` | display, hero text | 38px |
| `.nv-heading` | heading | 24px |
| `.nv-title` | title, subheading | 17px |
| `.nv-body` | body copy | 15px |
| `.nv-label` | label, UI text | 13px |
| `.nv-caption` | caption, secondary text | 12px |
| `.nv-overline` | overline, eyebrow, kicker | 12px uppercase |
| `.nv-code` | code, monospace | 13px |
| `.nv-text-primary` `.nv-text-secondary` `.nv-text-tertiary` | text color roles | Emphasis, not size. |
| `.nv-text-accent` `.nv-text-success` `.nv-text-warning` `.nv-text-danger` | semantic text colors | |
## Layout utilities
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-container` | page container, wrapper | Max width + gutters. |
| `.nv-stack` | stack, vertical rhythm | Column with a gap. |
| `.nv-stack--tight` `.nv-stack--loose` | stack density | |
| `.nv-cluster` | cluster, wrap row | Wrapping row, vertically centred. |
| `.nv-cluster--tight` | dense cluster | |
| `.nv-cluster--between` | spread cluster | Pushes children to both ends. |
| `.nv-truncate` | ellipsis, text truncation | One line with an ellipsis. |
| `.nv-prose` | prose, measure | Readable line length and leading. |
| `.nv-visually-hidden` | screen-reader-only, sr-only | Available to assistive tech, invisible on screen. |
| `.nv-continuous` | cross-fading region | Content that should cross-fade in place when it changes. |
| `.nv-carried` | (view-transition class) | Applied to nav items so they ride above a travelling marker. |
| `.nv-travelling` | (view-transition class) | Applied to a selection marker so it travels below the labels. |
---
## Tokens
Names are stable; values are not. Never state a color, shadow, radius, size or
duration directly — reference the token.
### Color roles
`--nv-color-` +
```
background background-subtle surface surface-elevated surface-inset
surface-hover surface-active scrim
text-primary text-secondary text-tertiary text-disabled text-inverted
border-subtle border border-strong
accent accent-hover accent-active accent-subtle accent-subtle-hover
accent-border accent-text on-accent
success success-hover success-subtle success-border success-text on-success
warning warning-hover warning-subtle warning-border warning-text on-warning
danger danger-hover danger-active danger-subtle danger-border
danger-text on-danger
shadow-ambient shadow-penumbra shadow-umbra shadow-inset
highlight highlight-strong elevation-tint-1 … -5
material-frosted material-glass material-border
focus-ring focus-ring-contrast selection
```
`on-*` is the text that goes on top of that color, and is derived — never set
it by hand.
### Color inputs
| Token | Default | Does |
| --- | --- | --- |
| `--nv-brand` | `#4f6cff` | The one color everything derives from. |
| `--nv-brand-tint` | `4%` | How much brand bleeds into the greys. |
| `--nv-brand-harmony` | `12%` | How far status colors lean towards the brand. |
| `--nv-contrast-pivot` | `0.7` | Lightness where a color can no longer carry a label. |
| `--nv-contrast-dark` / `-light` | `0.53` / `0.78` | How far either side a color is pushed. |
`--nv-palette-{neutral|accent|success|warning|danger}-{0…1000}` are raw values.
Components must never reference them.
### Scales
| Family | Steps |
| --- | --- |
| `--nv-space-` | `0 px 0-5 1 1-5 2 2-5 3 4 5 6 7 8 10 12 14 16 20 24 32` (suffix = px ÷ 4) |
| `--nv-radius-` | `none xs sm md lg xl 2xl full`, plus `nested-md` `nested-lg` |
| `--nv-font-size-` | `2xs xs sm md lg xl 2xl 3xl 4xl 5xl` |
| `--nv-font-weight-` | `regular medium semibold bold` |
| `--nv-line-height-` | `tight snug normal relaxed` |
| `--nv-letter-spacing-` | `tighter tight normal wide wider` |
| `--nv-font-` | `display heading title body label caption code` (composite shorthands) |
| `--nv-font-family-` | `sans mono` |
| `--nv-measure` | `-narrow` · `--nv-measure` · `-wide` |
| `--nv-control-height-` | `sm md lg` — also `control-padding-` and `control-gap-` |
| `--nv-elevation-` | `0 1 2 3 4 5`, `inset-sm` `inset-md` `inset-lg` |
| `--nv-highlight-` | `top` `top-strong` `edge` |
| `--nv-blur-` | `sm md lg xl` — also `backdrop-frosted` `backdrop-glass` `backdrop-scrim` |
| `--nv-material-` | `{solid,elevated,inset,frosted,glass}-{bg,border,shadow}` |
### Motion
| Family | Steps |
| --- | --- |
| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme` |
| `--nv-ease-` | `standard entrance exit spring emphasized linear` |
| `--nv-transition-` | `colors depth control surface expand` (composites) |
| displacement | `--nv-lift-hover` `--nv-press-depth` `--nv-travel-sm/md/lg` |
`prefers-reduced-motion` collapses every duration and displacement at the token
level. Components never opt in individually.
### Other
`--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
`--nv-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` ·
`--nv-scroller-fade` · `--nv-content-name`
---
## JavaScript
`niveus.js` is optional and dependency-free; it exposes `window.nv`. Without it
everything still renders — state changes simply arrive instead of travelling.
| Function | Signature | Does |
| --- | --- | --- |
| `nv.transition` | `(update, { direction, kind }) => Promise` | Runs `update` inside a view transition so the change can be followed. `direction` is `"forward"`/`"backward"`; `kind: "theme"` slows the cross-fade. Falls back to running `update` directly under reduced motion or without API support. |
| `nv.setTheme` | `("light" \| "dark" \| "system") => Promise` | Cross-fades the whole surface into the new appearance and fires `nv:theme` on `<html>`. |
| `nv.syncThemeControls` | `(theme) => void` | Sets `aria-pressed` on every `[data-nv-theme]`. |
| `nv.openDialog` | `(dialog \| id, trigger?) => dialog` | `showModal()`, plus a transform origin so the dialog expands out of its trigger. |
| `nv.selectTab` | `(tablist, tab) => Promise` | Moves selection and its panel, with direction. |
| `nv.initScroller` | `(frame) => void` | Wires one scroller's controls. |
| `nv.liftLabels` | `(scope) => void` | Names navigation items so a travelling marker passes behind them. |
| `nv.wire` | `(scope = document) => void` | Applies all of the below. Runs once on load; call again after injecting markup. |
| `nv.prefersReducedMotion` | `() => boolean` | |
### Data attributes
| Attribute | On | Does |
| --- | --- | --- |
| `data-theme` | `<html>` | `light` / `dark`. Absent follows the OS. |
| `data-nv-brand` | any element | Re-derives the whole color set for that subtree. |
| `data-nv-theme` | button | Sets the appearance when clicked. |
| `data-nv-dialog` | button | Opens the dialog with that id, from itself. |
| `data-nv-close` | button | Closes its dialog or popover. |
| `data-nv-tabs` | tablist | Wires `[role=tab]` children to their `aria-controls` panels, with arrow keys. |
| `data-nv-scroller` | `.nv-scroller-frame` | Wires its `[data-nv-scroll="prev"\|"next"]` controls. |
| `data-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. |
| `data-nv-direction` | `<html>` | Set during a transition by `nv.transition`. |
| `data-nv-transition` | `<html>` | `"theme"` during an appearance change. |
---
## Rules that bite
1. **`view-transition-name` must be unique per document.** A duplicate makes
the browser skip the transition entirely. Each selection group needs its own
`--nv-selection-name`; `.nv-nav-tabs` defaults to `nv-tabs-selection` and
everything else to `nv-selection`, which is enough for one of each per page.
2. **A menu and its trigger must name the same anchor** through `--nv-anchor`,
and the value must be unique per pair.
3. **Only one `.nv-tab-panel` may be visible at a time** — hide the rest with
`hidden`. They share one content name.
4. **Never reference `--nv-palette-*` from application code.** It is raw, and
does not adapt to the appearance.
5. **A floating control positioned with `translate`** needs
`.nv-button--float` and `--nv-button-offset`, or the hover lift overwrites
its position.
6. **Icon-only buttons need `aria-label`.** The system gives them a 44px target
but cannot give them a name.
---
Made by **Nexoniarz**.

View file

@ -0,0 +1,234 @@
/* ==========================================================================
Lumen Chat — app layout
--------------------------------------------------------------------------
Layout only. The shell is a two-column grid pinned to the viewport, with
exactly one scrolling region; everything visual comes from Niveus.
The avatars below are the one place this app extends the system rather than
consuming it — and even then they are built from tokens, not from values
invented here. That is the correct way to extend: new shapes, old language.
========================================================================== */
html, body {
block-size: 100%;
}
body {
overflow: hidden; /* the transcript scrolls, the page does not */
}
.app {
display: grid;
grid-template-columns: 17rem 1fr;
block-size: 100svh;
}
@media (max-width: 52rem) {
.app {
grid-template-columns: 1fr;
}
.app__sidebar {
display: none;
}
}
/* --- Sidebar ---------------------------------------------------------------- */
.app__sidebar {
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
min-block-size: 0;
padding: var(--nv-space-4) var(--nv-space-3);
background-color: var(--nv-color-background-subtle);
border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.app__threads {
flex: 1;
min-block-size: 0;
overflow-y: auto;
margin-inline: calc(var(--nv-space-1) * -1);
padding-inline: var(--nv-space-1);
}
.thread__title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread__meta {
display: block;
font: var(--nv-font-caption);
color: var(--nv-color-text-tertiary);
font-weight: var(--nv-font-weight-regular);
}
/* This sidebar is a plain aside rather than .nv-nav--vertical, so the list
keeps its default row direction until told otherwise. */
.app__threads .nv-nav__list {
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-0-5);
}
/* A nav item holding two lines needs to stop centring them. */
.app__threads .nv-nav__item {
align-items: flex-start;
flex-direction: column;
gap: 0;
min-inline-size: 0;
}
/* --- Main ------------------------------------------------------------------- */
.app__main {
display: grid;
grid-template-rows: auto 1fr auto;
min-block-size: 0;
min-inline-size: 0;
}
.app__transcript {
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--nv-space-6) var(--nv-layout-gutter);
}
.transcript {
max-inline-size: 48rem;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: var(--nv-space-6);
}
/* --- Messages ---------------------------------------------------------------- */
.message {
display: flex;
gap: var(--nv-space-3);
align-items: flex-start;
}
.message--from-user {
flex-direction: row-reverse;
}
.message__body {
max-inline-size: 42rem;
min-inline-size: 0;
}
.message--from-user .message__body {
padding: var(--nv-space-3) var(--nv-space-4);
background-color: var(--nv-color-accent-subtle);
border: var(--nv-border-width) solid var(--nv-color-accent-border);
border-radius: var(--nv-radius-lg);
border-end-end-radius: var(--nv-radius-xs);
color: var(--nv-color-text-primary);
}
.message__body > * + * {
margin-block-start: var(--nv-space-3);
}
.avatar {
inline-size: var(--nv-space-8);
block-size: var(--nv-space-8);
flex: none;
display: grid;
place-items: center;
border-radius: var(--nv-radius-full);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
}
.avatar--agent {
background-color: var(--nv-color-accent);
color: var(--nv-color-on-accent);
border-color: transparent;
}
.avatar--user {
background-color: var(--nv-color-surface-elevated);
color: var(--nv-color-text-secondary);
}
.code-block {
display: block;
padding: var(--nv-space-4);
border-radius: var(--nv-radius-md);
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: var(--nv-elevation-inset-sm);
overflow-x: auto;
white-space: pre;
}
/* The three dots while an answer is still arriving. */
.thinking {
display: inline-flex;
gap: var(--nv-space-1);
align-items: center;
padding-block: var(--nv-space-2);
}
.thinking span {
inline-size: var(--nv-space-1-5);
block-size: var(--nv-space-1-5);
border-radius: var(--nv-radius-full);
background-color: var(--nv-color-text-tertiary);
animation: thinking 1.2s var(--nv-ease-standard) infinite;
}
.thinking span:nth-child(2) { animation-delay: 0.15s; }
.thinking span:nth-child(3) { animation-delay: 0.3s; }
@keyframes thinking {
0%, 60%, 100% { opacity: 0.25; translate: 0 0; }
30% { opacity: 1; translate: 0 calc(var(--nv-travel-sm) * -0.5); }
}
@media (prefers-reduced-motion: reduce) {
.thinking span { animation: none; opacity: 0.5; }
}
/* --- Composer ----------------------------------------------------------------- */
.composer {
padding: var(--nv-space-4) var(--nv-layout-gutter) var(--nv-space-5);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
background-color: var(--nv-color-background);
}
.composer__inner {
max-inline-size: 48rem;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: var(--nv-space-2-5);
}
.composer__row {
display: flex;
align-items: flex-end;
gap: var(--nv-space-2);
}
.composer__row .nv-input {
min-block-size: var(--nv-control-height-lg);
max-block-size: 9rem;
}
.composer__hints {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--nv-space-4);
}

View file

@ -0,0 +1,485 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumen Chat — an AI chat app built with Niveus</title>
<link rel="stylesheet" href="../../dist/niveus.css">
<script src="../../niveus/niveus.js" defer></script>
<link rel="stylesheet" href="app.css">
<script>
try {
var saved = localStorage.getItem("lumen-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) {}
</script>
<style>
:root { --nv-brand: #6d5efc; }
</style>
</head>
<body>
<div class="app">
<!-- ================================================================== -->
<aside class="app__sidebar" style="--nv-selection-name: nv-chat-threads">
<a class="nv-nav__brand" href="#" style="padding-inline: var(--nv-space-2)">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v18M3 12h18M6 6l12 12M18 6L6 18" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round"/>
</svg>
Lumen
</a>
<button class="nv-button nv-button--block" id="new-thread">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
New conversation
</button>
<nav class="app__threads" aria-label="Conversations">
<span class="nv-nav__section">Today</span>
<ul class="nv-nav__list" id="threads" data-nv-travel>
<li>
<button class="nv-nav__item" data-thread="latency" aria-current="page">
<span class="thread__title">Checkout latency after #4412</span>
<span class="thread__meta">12 messages</span>
</button>
</li>
<li>
<button class="nv-nav__item" data-thread="migration">
<span class="thread__title">Postgres 15 migration plan</span>
<span class="thread__meta">4 messages</span>
</button>
</li>
</ul>
<span class="nv-nav__section">Earlier</span>
<ul class="nv-nav__list">
<li>
<button class="nv-nav__item" data-thread="audit">
<span class="thread__title">Dependency audit — Q3</span>
<span class="thread__meta">31 messages</span>
</button>
</li>
<li>
<button class="nv-nav__item" data-thread="onboarding">
<span class="thread__title">Why does billing own the cart?</span>
<span class="thread__meta">8 messages</span>
</button>
</li>
</ul>
</nav>
<hr class="nv-divider" style="margin-block: var(--nv-space-2)">
<button class="nv-nav__item nv-menu-trigger" popovertarget="account-menu"
style="--nv-anchor: --account">
<span class="avatar avatar--user" aria-hidden="true">RK</span>
<span class="thread__title">Robin Kade</span>
</button>
<div class="nv-menu" popover id="account-menu" style="--nv-anchor: --account">
<span class="nv-menu__label">Robin Kade</span>
<button class="nv-menu__item" data-nv-dialog="settings">
Settings<span class="nv-menu__shortcut">⌘,</span>
</button>
<button class="nv-menu__item">Keyboard shortcuts</button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Sign out</button>
</div>
</aside>
<!-- ================================================================== -->
<main class="app__main">
<nav class="nv-nav nv-nav--static" aria-label="Conversation">
<h1 class="nv-title nv-truncate" id="thread-heading">Checkout latency after #4412</h1>
<span class="nv-nav__spacer"></span>
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="model-menu"
style="--nv-anchor: --model">
<span id="model-label">Lumen 2</span>
<span class="nv-disclosure__chevron" aria-hidden="true"
style="margin-inline-start: var(--nv-space-1)"></span>
</button>
<div class="nv-menu" popover id="model-menu" style="--nv-anchor: --model">
<span class="nv-menu__label">Model</span>
<button class="nv-menu__item" aria-checked="true" data-model="Lumen 2">
Lumen 2<span class="nv-menu__shortcut">Balanced</span>
</button>
<button class="nv-menu__item" data-model="Lumen 2 Pro">
Lumen 2 Pro<span class="nv-menu__shortcut">Deeper</span>
</button>
<button class="nv-menu__item" data-model="Lumen Mini">
Lumen Mini<span class="nv-menu__shortcut">Fastest</span>
</button>
</div>
<button class="nv-button nv-button--sm nv-button--ghost">Share</button>
<button class="nv-button nv-button--sm nv-button--ghost nv-button--icon"
data-nv-dialog="settings" aria-label="Settings">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="2"/>
<path d="M12 3v2.2M12 18.8V21M21 12h-2.2M5.2 12H3M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6M18.4 18.4l-1.6-1.6M7.2 7.2L5.6 5.6"
stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</nav>
<div class="app__transcript" id="transcript-scroll">
<div class="transcript nv-continuous" id="transcript">
<!-- rendered from the thread data below -->
</div>
</div>
<div class="composer">
<form class="composer__inner" id="composer">
<div class="composer__row">
<button class="nv-button nv-button--lg nv-button--icon" type="button" aria-label="Attach">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M18 11l-6.5 6.5a4 4 0 01-5.6-5.6l7-7a2.8 2.8 0 013.9 4L9.6 16a1.6 1.6 0 01-2.3-2.3l6.2-6.2"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<label class="nv-visually-hidden" for="prompt">Message Lumen</label>
<textarea class="nv-input" id="prompt" rows="1"
placeholder="Ask about this codebase…"></textarea>
<button class="nv-button nv-button--lg nv-button--primary nv-button--icon"
type="submit" aria-label="Send">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M5 12h13M13 6l6 6-6 6" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
<div class="composer__hints">
<label class="nv-choice nv-caption">
<input class="nv-switch" type="checkbox" checked>
Search the repository
</label>
<label class="nv-choice nv-caption">
<input class="nv-switch" type="checkbox">
Propose changes
</label>
<span class="nv-nav__spacer"></span>
<span class="nv-caption">Enter to send · Shift + Enter for a new line</span>
</div>
</form>
</div>
</main>
</div>
<!-- ==================================================================== -->
<dialog class="nv-dialog nv-dialog--wide" id="settings" aria-labelledby="settings-title">
<div class="nv-dialog__header">
<div>
<h2 class="nv-dialog__title" id="settings-title">Settings</h2>
<p class="nv-dialog__description">Applies to this workspace.</p>
</div>
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
aria-label="Close" data-nv-close>
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="nv-dialog__body">
<div class="nv-nav-tabs" role="tablist" data-nv-tabs aria-label="Settings sections"
style="--nv-selection-name: nv-chat-settings-tabs">
<button class="nv-nav__item" role="tab" id="tab-general"
aria-controls="panel-general" aria-selected="true">General</button>
<button class="nv-nav__item" role="tab" id="tab-model"
aria-controls="panel-model" aria-selected="false" tabindex="-1">Model</button>
<button class="nv-nav__item" role="tab" id="tab-privacy"
aria-controls="panel-privacy" aria-selected="false" tabindex="-1">Privacy</button>
</div>
<div class="nv-tab-panel" id="panel-general" role="tabpanel" aria-labelledby="tab-general"
style="padding-block-start: var(--nv-space-5)">
<div class="nv-stack">
<div class="nv-field">
<label class="nv-field__label" for="ws-name">Workspace name</label>
<input class="nv-input" id="ws-name" value="Northwire Platform">
</div>
<div class="nv-field">
<span class="nv-field__label">Appearance</span>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">System</button>
</div>
<span class="nv-field__hint">The whole interface cross-fades between the two.</span>
</div>
<label class="nv-choice">
<input class="nv-switch" type="checkbox" checked>
Send on Enter
</label>
</div>
</div>
<div class="nv-tab-panel" id="panel-model" role="tabpanel" aria-labelledby="tab-model"
style="padding-block-start: var(--nv-space-5)" hidden>
<div class="nv-stack">
<div class="nv-field">
<label class="nv-field__label" for="default-model">Default model</label>
<div class="nv-select">
<select class="nv-input" id="default-model">
<option selected>Lumen 2 — balanced</option>
<option>Lumen 2 Pro — deeper reasoning</option>
<option>Lumen Mini — fastest</option>
</select>
</div>
<span class="nv-field__hint">Individual conversations can override this.</span>
</div>
<div class="nv-field">
<label class="nv-field__label" for="context">Context window</label>
<div class="nv-input-group">
<input class="nv-input" id="context" value="200000">
<span class="nv-input-group__affix">tokens</span>
</div>
</div>
<label class="nv-choice">
<input class="nv-checkbox" type="checkbox" checked>
Cite sources in every answer
</label>
</div>
</div>
<div class="nv-tab-panel" id="panel-privacy" role="tabpanel" aria-labelledby="tab-privacy"
style="padding-block-start: var(--nv-space-5)" hidden>
<div class="nv-stack">
<div class="nv-bed nv-bed--quiet nv-bed--single">
<p class="nv-body">
Repository contents are used to answer this workspace's questions
and nothing else.
</p>
</div>
<label class="nv-choice">
<input class="nv-switch" type="checkbox">
Keep transcripts after 30 days
</label>
<div class="nv-field">
<span class="nv-field__label">Danger zone</span>
<div class="nv-cluster">
<button class="nv-button nv-button--danger nv-button--sm">Delete all conversations</button>
</div>
</div>
</div>
</div>
</div>
<div class="nv-dialog__footer nv-dialog__footer--divided">
<button class="nv-button" data-nv-close>Cancel</button>
<button class="nv-button nv-button--primary" data-nv-close>Save changes</button>
</div>
</dialog>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("lumen-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
/* ------------------------------------------------------------------
Canned content. A real client would fetch this; what matters here is
that switching threads is a content change inside one surface, so it
goes through nv.transition() and cross-fades in place.
------------------------------------------------------------------ */
const threads = {
latency: {
title: "Checkout latency after #4412",
messages: [
{ from: "user", html: "Why did checkout latency double after Tuesday's deploy?" },
{ from: "agent", html:
`<p>The <code class="nv-code">pricing</code> service began resolving tax rules
per line item instead of per cart in <code class="nv-code">#4412</code>.
Carts with more than two items take the slow path.</p>
<div class="nv-cluster nv-cluster--tight">
<span class="nv-badge nv-badge--warning">p95 +118%</span>
<span class="nv-badge">3 files</span>
<span class="nv-badge">pricing/tax.ts</span>
</div>` },
{ from: "user", html: "Show me the hot path." },
{ from: "agent", html:
`<p>Here is the loop that moved:</p>
<code class="code-block nv-code">for (const line of cart.lines) {
// was: resolved once per cart
const rules = await taxRules.resolve(line.destination);
line.tax = rules.apply(line);
}</code>
<p><code class="nv-code">taxRules.resolve</code> is a network call. Hoisting
it above the loop restores the previous shape.</p>` },
],
},
migration: {
title: "Postgres 15 migration plan",
messages: [
{ from: "user", html: "What breaks if we go to Postgres 15 next sprint?" },
{ from: "agent", html:
`<p>Two things in this codebase, and one in the deploy pipeline.</p>
<div class="nv-cluster nv-cluster--tight">
<span class="nv-badge nv-badge--danger">1 blocking</span>
<span class="nv-badge nv-badge--warning">2 to review</span>
</div>
<p>The blocking one is the <code class="nv-code">reporting</code> service's use
of implicit integer casts in <code class="nv-code">?column?</code> aliases,
removed in 15.</p>` },
],
},
audit: {
title: "Dependency audit — Q3",
messages: [
{ from: "user", html: "Which advisories actually reach our code?" },
{ from: "agent", html:
`<p>Four of the thirty-one. The rest are in transitive packages whose
affected entry points you never call.</p>
<div class="nv-cluster nv-cluster--tight">
<span class="nv-badge nv-badge--danger">2 high</span>
<span class="nv-badge nv-badge--warning">2 moderate</span>
<span class="nv-badge nv-badge--success">27 unreachable</span>
</div>` },
],
},
onboarding: {
title: "Why does billing own the cart?",
messages: [
{ from: "user", html: "Why does billing own the cart model?" },
{ from: "agent", html:
`<p>It did not, until <code class="nv-code">#2180</code> moved it there so
promotions could be priced in one place. The commit message and the
review thread both say this was meant to be temporary.</p>` },
],
},
};
const transcript = document.getElementById("transcript");
const scroller = document.getElementById("transcript-scroll");
const heading = document.getElementById("thread-heading");
function messageHTML(message) {
const isUser = message.from === "user";
return `
<div class="message message--from-${message.from}">
<span class="avatar avatar--${isUser ? "user" : "agent"}" aria-hidden="true">${isUser ? "RK" : "L"}</span>
<div class="message__body">${message.html}</div>
</div>`;
}
function render(key) {
const thread = threads[key];
heading.textContent = thread.title;
transcript.innerHTML = thread.messages.map(messageHTML).join("");
}
render("latency");
/* Switching threads: the selection travels in the sidebar and the
transcript cross-fades, because both are one state change. */
const list = document.getElementById("threads");
document.querySelectorAll("[data-thread]").forEach((item) => {
item.addEventListener("click", () => {
if (item.hasAttribute("aria-current")) return;
nv.transition(() => {
document.querySelectorAll("[data-thread][aria-current]")
.forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
render(item.dataset.thread);
});
scroller.scrollTop = 0;
});
});
/* Choosing a model updates the menu's own checked state. */
document.querySelectorAll("[data-model]").forEach((option) => {
option.addEventListener("click", () => {
document.querySelectorAll("[data-model]").forEach((other) => {
other.setAttribute("aria-checked", String(other === option));
});
document.getElementById("model-label").textContent = option.dataset.model;
document.getElementById("model-menu").hidePopover();
});
});
/* Sending: append, show that something is happening, then answer. The
point is the states, not the reply. */
const form = document.getElementById("composer");
const prompt = document.getElementById("prompt");
function append(html) {
transcript.insertAdjacentHTML("beforeend", html);
scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
}
form.addEventListener("submit", (event) => {
event.preventDefault();
const text = prompt.value.trim();
if (!text) return;
append(messageHTML({ from: "user", html: text.replace(/[<>&]/g, (c) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c])) }));
prompt.value = "";
append(`<div class="message message--from-agent" id="pending">
<span class="avatar avatar--agent" aria-hidden="true">L</span>
<div class="message__body">
<span class="thinking" role="status" aria-label="Thinking">
<span></span><span></span><span></span>
</span>
</div>
</div>`);
setTimeout(() => {
document.getElementById("pending")?.remove();
append(messageHTML({ from: "agent", html:
`<p>This example has no model behind it — it exists to show the states
a chat interface moves through: the message landing, something
visibly happening, and the answer arriving in the same column.</p>` }));
}, 1400);
});
prompt.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
document.getElementById("new-thread").addEventListener("click", () => {
nv.transition(() => {
document.querySelectorAll("[data-thread][aria-current]")
.forEach((el) => el.removeAttribute("aria-current"));
heading.textContent = "New conversation";
transcript.innerHTML = `
<div class="nv-bed nv-bed--quiet" style="text-align: center">
<p class="nv-title">Ask about this codebase</p>
<p class="nv-caption" style="margin-block-start: var(--nv-space-2)">
Lumen has indexed 1,284 files across 3 repositories.
</p>
</div>`;
});
prompt.focus();
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,186 @@
/* ==========================================================================
Lumen — page layout
--------------------------------------------------------------------------
Everything in this file is *layout*: where things sit, how wide they get,
when they stack. Not one color, shadow, radius, type size or duration is
stated here — those all come from Niveus, which is the whole point of the
split. If you find yourself writing a hex value in a file like this, the
system is missing a token.
========================================================================== */
.shell {
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
.section {
padding-block: var(--nv-space-20);
}
.section__head {
max-inline-size: var(--nv-measure);
margin-block-end: var(--nv-space-8);
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
}
.section__head--center {
margin-inline: auto;
text-align: center;
align-items: center;
}
/* --- Hero ------------------------------------------------------------------ */
.hero {
display: grid;
gap: var(--nv-space-12);
padding-block: var(--nv-space-16) var(--nv-space-8);
align-items: center;
}
@media (min-width: 62rem) {
.hero {
grid-template-columns: 1fr 1fr;
gap: var(--nv-space-16);
}
}
.hero__copy {
display: flex;
flex-direction: column;
gap: var(--nv-space-5);
align-items: flex-start;
}
.hero__title {
font-size: clamp(var(--nv-font-size-4xl), 6vw, var(--nv-font-size-5xl));
}
/* --- Preview ---------------------------------------------------------------
A mock of the product, sitting on a bed so the frosted panel above it has
something to be frosted against. */
.preview {
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
max-inline-size: 30rem;
margin-inline: auto;
}
.bubble {
padding: var(--nv-space-3) var(--nv-space-4);
border-radius: var(--nv-radius-lg);
max-inline-size: 85%;
}
.bubble--from-user {
align-self: flex-end;
border-end-end-radius: var(--nv-radius-xs);
}
.bubble--from-agent {
align-self: flex-start;
border-end-start-radius: var(--nv-radius-xs);
}
/* --- Logos ------------------------------------------------------------------ */
.logos {
display: flex;
flex-wrap: wrap;
gap: var(--nv-space-8);
justify-content: center;
align-items: center;
padding-block: var(--nv-space-8);
opacity: 0.75;
}
/* --- Features ---------------------------------------------------------------- */
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
gap: var(--nv-space-5);
}
.feature__icon {
inline-size: var(--nv-space-10);
block-size: var(--nv-space-10);
display: grid;
place-items: center;
margin-block-end: var(--nv-space-4);
border-radius: var(--nv-radius-md);
background-color: var(--nv-color-accent-subtle);
color: var(--nv-color-accent-text);
}
.feature__icon svg {
inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
}
/* --- Pricing ------------------------------------------------------------------ */
.pricing {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
gap: var(--nv-space-5);
align-items: start;
}
.price {
display: flex;
align-items: baseline;
gap: var(--nv-space-2);
margin-block: var(--nv-space-4);
}
.price__amount {
font-size: var(--nv-font-size-4xl);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-tighter);
color: var(--nv-color-text-primary);
}
.plan-features {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--nv-space-2-5);
}
.plan-features li {
display: flex;
align-items: flex-start;
gap: var(--nv-space-2-5);
}
.plan-features svg {
inline-size: 1em;
block-size: 1em;
margin-block-start: 0.25em;
flex: none;
color: var(--nv-color-success-text);
}
/* --- Closing ------------------------------------------------------------------- */
.closing {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--nv-space-5);
padding-block: var(--nv-space-12);
}
.site-footer {
padding-block: var(--nv-space-8) var(--nv-space-12);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}

View file

@ -0,0 +1,468 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumen — an AI product landing page built with Niveus</title>
<!-- The system, and its optional continuity helpers. -->
<link rel="stylesheet" href="../../dist/niveus.css">
<script src="../../niveus/niveus.js" defer></script>
<!-- This page's own layout. No colors, no shadows, no type sizes. -->
<link rel="stylesheet" href="app.css">
<script>
/* Applied before first paint so the page never renders in the wrong
appearance and then corrects itself. */
try {
var saved = localStorage.getItem("lumen-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) { /* storage unavailable — follow the system */ }
</script>
<!-- The entire identity of this site is one declaration. -->
<style>
:root { --nv-brand: #6d5efc; }
</style>
</head>
<body>
<nav class="nv-nav" id="topnav" aria-label="Main" style="--nv-selection-name: nv-lumen-nav">
<a class="nv-nav__brand" href="#top">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3v18M3 12h18M6 6l12 12M18 6L6 18" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round"/>
</svg>
Lumen
</a>
<ul class="nv-nav__list" data-nv-travel>
<li><a class="nv-nav__item" href="#features" aria-current="page">Features</a></li>
<li><a class="nv-nav__item" href="#build">Build</a></li>
<li><a class="nv-nav__item" href="#pricing">Pricing</a></li>
<li><a class="nv-nav__item" href="#faq">FAQ</a></li>
</ul>
<span class="nv-nav__spacer"></span>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
<a class="nv-button nv-button--sm nv-button--primary" href="#pricing">Get started</a>
</nav>
<main id="top">
<!-- ================================================================== -->
<header class="shell hero">
<div class="hero__copy">
<span class="nv-badge nv-badge--accent">
<span class="nv-badge__dot"></span>
Lumen 2 is available
</span>
<h1 class="nv-display hero__title">The model that reads your whole codebase.</h1>
<p class="nv-prose">
Lumen answers questions about the systems you actually run — not a
snapshot of them. Point it at a repository and it keeps up as the code
changes.
</p>
<div class="nv-cluster">
<a class="nv-button nv-button--primary nv-button--lg" href="#pricing">Start free</a>
<button class="nv-button nv-button--lg" data-nv-dialog="demo">Watch a demo</button>
</div>
<p class="nv-caption">No card required · 14-day trial · SOC 2 Type II</p>
</div>
<div class="nv-bed nv-bed--single">
<span class="nv-bed__label">Live preview</span>
<div class="preview">
<div class="bubble bubble--from-user nv-material-elevated">
Why did checkout latency double after Tuesday's deploy?
</div>
<div class="nv-card nv-card--frosted bubble bubble--from-agent"
style="max-inline-size: 100%; padding: var(--nv-space-4)">
<p class="nv-body">
The <code class="nv-code">pricing</code> service started resolving
tax rules per line item instead of per cart in
<code class="nv-code">#4412</code>. Two of three carts hit the slow
path.
</p>
<div class="nv-cluster nv-cluster--tight" style="margin-block-start: var(--nv-space-3)">
<span class="nv-badge nv-badge--warning">p95 +118%</span>
<span class="nv-badge">3 files</span>
</div>
</div>
<div class="bubble bubble--from-user nv-material-elevated">
Open a PR that reverts just that change.
</div>
</div>
</div>
</header>
<!-- ================================================================== -->
<section class="shell">
<div class="logos">
<span class="nv-overline">Northwire</span>
<span class="nv-overline">Harbour</span>
<span class="nv-overline">Greenhouse Labs</span>
<span class="nv-overline">Quarry</span>
<span class="nv-overline">Signal Systems</span>
</div>
</section>
<!-- ================================================================== -->
<section class="shell section" id="features">
<div class="section__head">
<span class="nv-overline">Why teams switch</span>
<h2 class="nv-heading">Built for the code you already have.</h2>
<p class="nv-prose">
Three things matter when a model works on a real system: that it sees
the current state, that it explains itself, and that it cannot quietly
do something irreversible.
</p>
</div>
<div class="features">
<article class="nv-card">
<div class="nv-card__body">
<span class="feature__icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" stroke-width="2"
stroke-linecap="round"/>
</svg>
</span>
<h3 class="nv-title">Always current</h3>
<p style="margin-block-start: var(--nv-space-2)">
Indexes on every push, so answers describe the branch you are on
rather than the one it was trained on.
</p>
</div>
</article>
<article class="nv-card">
<div class="nv-card__body">
<span class="feature__icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="2"/>
<path d="M16 16l4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</span>
<h3 class="nv-title">Shows its work</h3>
<p style="margin-block-start: var(--nv-space-2)">
Every claim links to the lines it came from. If it cannot cite a
file, it says so instead of guessing.
</p>
</div>
</article>
<article class="nv-card">
<div class="nv-card__body">
<span class="feature__icon">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z" stroke="currentColor"
stroke-width="2" stroke-linejoin="round"/>
</svg>
</span>
<h3 class="nv-title">Asks before it acts</h3>
<p style="margin-block-start: var(--nv-space-2)">
Writes, merges and deploys are proposals until a human approves
them. Read access is the default.
</p>
</div>
</article>
</div>
</section>
<!-- ================================================================== -->
<section class="shell section" id="build">
<div class="section__head">
<span class="nv-overline">What teams build</span>
<h2 class="nv-heading">One model, several jobs.</h2>
</div>
<div class="nv-scroller-frame" data-nv-scroller>
<div class="nv-scroller">
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Incident triage</h3>
<p class="nv-card__subtitle">Platform teams</p>
</div></div>
<div class="nv-card__body">
Correlates a regression with the change that caused it, and drafts
the revert.
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Review assistant</h3>
<p class="nv-card__subtitle">Every repository</p>
</div></div>
<div class="nv-card__body">
Reads the diff in the context of the whole system, not just the
files that changed.
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Migration planning</h3>
<p class="nv-card__subtitle">Staff engineers</p>
</div></div>
<div class="nv-card__body">
Finds every call site, groups them by risk, and sequences the work.
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Onboarding</h3>
<p class="nv-card__subtitle">New joiners</p>
</div></div>
<div class="nv-card__body">
Answers "why is this here" with the commit, the review and the
issue behind it.
</div>
</article>
<article class="nv-card nv-card--interactive">
<div class="nv-card__header"><div>
<h3 class="nv-card__title">Dependency audits</h3>
<p class="nv-card__subtitle">Security</p>
</div></div>
<div class="nv-card__body">
Separates the advisories that reach your code from the ones that
do not.
</div>
</article>
</div>
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="prev" aria-label="Previous">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M14 6l-6 6 6 6" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="next" aria-label="Next">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M10 6l6 6-6 6" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</section>
<!-- ================================================================== -->
<section class="shell section" id="pricing">
<div class="section__head section__head--center">
<span class="nv-overline">Pricing</span>
<h2 class="nv-heading">Per seat, not per question.</h2>
<p class="nv-prose">
Usage limits make people ration the tool that is supposed to save them
time.
</p>
</div>
<div class="pricing">
<article class="nv-card">
<div class="nv-card__body">
<h3 class="nv-title">Starter</h3>
<p class="nv-caption">For a single repository.</p>
<p class="price">
<span class="price__amount">$0</span>
<span class="nv-caption">forever</span>
</p>
<ul class="plan-features nv-caption">
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> One repository</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Community support</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Read-only access</li>
</ul>
</div>
<div class="nv-card__footer">
<button class="nv-button nv-button--block">Start free</button>
</div>
</article>
<article class="nv-card nv-card--floating">
<div class="nv-card__body">
<div class="nv-cluster nv-cluster--between">
<h3 class="nv-title">Team</h3>
<span class="nv-badge nv-badge--accent nv-badge--solid">Most chosen</span>
</div>
<p class="nv-caption">For a codebase several people touch.</p>
<p class="price">
<span class="price__amount">$24</span>
<span class="nv-caption">per seat / month</span>
</p>
<ul class="plan-features nv-caption">
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Unlimited repositories</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Pull request proposals</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Shared context across the team</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> SSO and audit log</li>
</ul>
</div>
<div class="nv-card__footer">
<button class="nv-button nv-button--primary nv-button--block">Start 14-day trial</button>
</div>
</article>
<article class="nv-card">
<div class="nv-card__body">
<h3 class="nv-title">Enterprise</h3>
<p class="nv-caption">For code that cannot leave your network.</p>
<p class="price">
<span class="price__amount">Talk</span>
<span class="nv-caption">to us</span>
</p>
<ul class="plan-features nv-caption">
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Self-hosted or VPC</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Custom retention</li>
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Named support engineer</li>
</ul>
</div>
<div class="nv-card__footer">
<button class="nv-button nv-button--block">Contact sales</button>
</div>
</article>
</div>
</section>
<!-- ================================================================== -->
<section class="shell section" id="faq">
<div class="section__head">
<span class="nv-overline">Questions</span>
<h2 class="nv-heading">Before you ask.</h2>
</div>
<div class="nv-disclosure-group" style="max-inline-size: var(--nv-measure-wide)">
<details class="nv-disclosure" open>
<summary class="nv-disclosure__summary">
Do you train on our code?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
No. Repository contents are used to answer your team's questions and
nothing else. Enterprise plans can run entirely inside your own
network, where the question never leaves it either.
</div>
</details>
<details class="nv-disclosure">
<summary class="nv-disclosure__summary">
What happens when it is wrong?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
Every answer cites the files it drew on, so a wrong answer is
checkable rather than merely confident. Anything that writes is a
proposal until a person approves it.
</div>
</details>
<details class="nv-disclosure">
<summary class="nv-disclosure__summary">
How large a codebase can it handle?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
The largest repository we index in production is around eleven
million lines. Indexing is incremental, so size affects the first
run far more than the ones after it.
</div>
</details>
</div>
</section>
<!-- ================================================================== -->
<section class="shell">
<div class="nv-bed nv-bed--vivid closing">
<h2 class="nv-heading">Point it at a repository.</h2>
<p class="nv-prose">
The first answer usually arrives before the tour would have finished.
</p>
<div class="nv-cluster">
<a class="nv-button nv-button--primary nv-button--lg" href="#pricing">Start free</a>
<button class="nv-button nv-button--lg nv-button--frosted" data-nv-dialog="demo">
Watch a demo
</button>
</div>
</div>
</section>
<footer class="shell site-footer">
<div class="nv-cluster nv-cluster--between">
<span class="nv-caption">Lumen — an example interface built with Niveus.</span>
<span class="nv-caption">Made by Nexoniarz</span>
</div>
</footer>
</main>
<dialog class="nv-dialog" id="demo" aria-labelledby="demo-title">
<div class="nv-dialog__header">
<div>
<h2 class="nv-dialog__title" id="demo-title">A two-minute tour</h2>
<p class="nv-dialog__description">
This dialog expanded from the button you pressed, and the page behind
it is blurred rather than hidden.
</p>
</div>
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
aria-label="Close" data-nv-close>
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="nv-dialog__body">
<div class="nv-bed nv-bed--quiet" style="display: grid; place-items: center; min-block-size: 12rem">
<span class="nv-caption">Where the video would be.</span>
</div>
</div>
<div class="nv-dialog__footer">
<button class="nv-button" data-nv-close>Close</button>
<a class="nv-button nv-button--primary" href="#pricing" data-nv-close>Start free</a>
</div>
</dialog>
<script>
document.addEventListener("DOMContentLoaded", () => {
/* niveus.js wires the appearance buttons; the page decides what
"remember it" means. */
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("lumen-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
/* The nav's selection travels to whatever section you jump to. */
const nav = document.querySelector("[data-nv-travel]");
nav.addEventListener("click", (event) => {
const item = event.target.closest(".nv-nav__item");
if (!item || item.hasAttribute("aria-current")) return;
nv.transition(() => {
nav.querySelectorAll("[aria-current]").forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
});
});
/* The bar earns its shadow only once content has passed under it. */
const topnav = document.getElementById("topnav");
const onScroll = () => topnav.toggleAttribute("data-scrolled", scrollY > 8);
addEventListener("scroll", onScroll, { passive: true });
onScroll();
});
</script>
</body>
</html>

View file

@ -0,0 +1,153 @@
/* ==========================================================================
Northwire Ops — dashboard layout
--------------------------------------------------------------------------
Layout only. A data-dense view is where a design system is tested hardest:
there are more things on screen than anywhere else, so any inconsistency in
spacing or weight shows up immediately. Nothing here restates the language —
even the chart draws itself in semantic colors.
========================================================================== */
html, body { block-size: 100%; }
.app {
display: grid;
grid-template-columns: 15rem 1fr;
min-block-size: 100svh;
}
@media (max-width: 62rem) {
.app { grid-template-columns: 1fr; }
.app__sidebar { display: none; }
}
.app__main {
min-inline-size: 0;
display: flex;
flex-direction: column;
}
.app__content {
flex: 1;
padding: var(--nv-space-6) var(--nv-layout-gutter) var(--nv-space-12);
display: flex;
flex-direction: column;
gap: var(--nv-space-6);
max-inline-size: 80rem;
inline-size: 100%;
margin-inline: auto;
}
.page-head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--nv-space-4);
}
/* --- Stat tiles -------------------------------------------------------------- */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
gap: var(--nv-space-4);
}
.stat__value {
display: block;
margin-block: var(--nv-space-2) var(--nv-space-1);
font-size: var(--nv-font-size-3xl);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-tighter);
color: var(--nv-color-text-primary);
font-variant-numeric: tabular-nums;
}
/* --- Chart --------------------------------------------------------------------
Drawn in semantic colors, so it follows the brand and both appearances with
no second palette to maintain. */
.chart {
inline-size: 100%;
block-size: 14rem;
display: block;
overflow: visible;
}
.chart__grid {
stroke: var(--nv-color-border-subtle);
stroke-width: 1;
}
.chart__line {
fill: none;
stroke: var(--nv-color-accent);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
}
.chart__line--compare {
stroke: var(--nv-color-border-strong);
stroke-dasharray: 4 4;
}
.chart__area {
fill: url(#chart-fill);
}
.chart__labels {
display: flex;
justify-content: space-between;
margin-block-start: var(--nv-space-2);
}
/* --- Layout of the lower half --------------------------------------------------- */
.split {
display: grid;
gap: var(--nv-space-6);
align-items: start;
}
@media (min-width: 70rem) {
.split { grid-template-columns: 1.6fr 1fr; }
}
.activity {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.activity li {
display: flex;
gap: var(--nv-space-3);
padding-block: var(--nv-space-3);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.activity li:last-child { border-block-end: 0; }
.activity__mark {
inline-size: var(--nv-space-2);
block-size: var(--nv-space-2);
border-radius: var(--nv-radius-full);
margin-block-start: 0.45em;
flex: none;
background-color: var(--nv-color-border-strong);
}
.activity__mark--danger { background-color: var(--nv-color-danger); }
.activity__mark--success { background-color: var(--nv-color-success); }
.activity__mark--accent { background-color: var(--nv-color-accent); }
.activity__mark--warning { background-color: var(--nv-color-warning); }
.service-name {
display: flex;
align-items: center;
gap: var(--nv-space-2-5);
}

View file

@ -0,0 +1,578 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Northwire Ops — a dashboard built with Niveus</title>
<link rel="stylesheet" href="../../dist/niveus.css">
<script src="../../niveus/niveus.js" defer></script>
<link rel="stylesheet" href="app.css">
<script>
try {
var saved = localStorage.getItem("ops-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) {}
</script>
<style>
:root { --nv-brand: #0d9488; }
</style>
</head>
<body>
<div class="app">
<aside class="nv-nav nv-nav--vertical app__sidebar" aria-label="Sections"
style="--nv-selection-name: nv-ops-sidebar">
<a class="nv-nav__brand" href="#">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 18V9M10 18V5M16 18v-6M22 18H2" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round"/>
</svg>
Northwire Ops
</a>
<span class="nv-nav__section">Monitor</span>
<ul class="nv-nav__list" data-nv-travel="local">
<li><a class="nv-nav__item" href="#" aria-current="page">Overview</a></li>
<li><a class="nv-nav__item" href="#">Services <span class="nv-nav__badge">6</span></a></li>
<li><a class="nv-nav__item" href="#">Incidents <span class="nv-nav__badge">1</span></a></li>
<li><a class="nv-nav__item" href="#">Traces</a></li>
</ul>
<span class="nv-nav__section">Manage</span>
<ul class="nv-nav__list">
<li><a class="nv-nav__item" href="#">Deployments</a></li>
<li><a class="nv-nav__item" href="#">On-call</a></li>
<li><span class="nv-nav__item" aria-disabled="true">Cost (soon)</span></li>
</ul>
<span class="nv-nav__spacer"></span>
<div class="nv-bed nv-bed--quiet nv-bed--single" style="padding: var(--nv-space-4)">
<p class="nv-label">Retention</p>
<p class="nv-caption" style="margin-block: var(--nv-space-1) var(--nv-space-3)">
14 of 30 days used.
</p>
<button class="nv-button nv-button--sm nv-button--primary nv-button--block">Upgrade plan</button>
</div>
</aside>
<main class="app__main">
<nav class="nv-nav nv-nav--static" aria-label="Toolbar">
<ol class="nv-breadcrumbs">
<li><a href="#">Northwire</a></li>
<li><a href="#">Platform</a></li>
<li aria-current="page">Overview</li>
</ol>
<span class="nv-nav__spacer"></span>
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="range-menu"
style="--nv-anchor: --range">
<span id="range-label">Last 24 hours</span>
<span class="nv-disclosure__chevron" aria-hidden="true"
style="margin-inline-start: var(--nv-space-1)"></span>
</button>
<div class="nv-menu" popover id="range-menu" style="--nv-anchor: --range">
<button class="nv-menu__item" aria-checked="true" data-range="Last 24 hours">Last 24 hours</button>
<button class="nv-menu__item" data-range="Last 7 days">Last 7 days</button>
<button class="nv-menu__item" data-range="Last 30 days">Last 30 days</button>
<hr class="nv-menu__separator">
<button class="nv-menu__item" data-range="Custom range">Custom range…</button>
</div>
<button class="nv-button nv-button--sm nv-button--ghost">Export</button>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
</nav>
<div class="app__content">
<div class="page-head">
<div>
<h1 class="nv-heading">Platform overview</h1>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">
Six services · updated 40 seconds ago
</p>
</div>
<div class="nv-cluster">
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>1 incident open</span>
<button class="nv-button nv-button--sm nv-button--primary" data-nv-dialog="incident">
Review incident
</button>
</div>
</div>
<!-- Stat tiles ------------------------------------------------------ -->
<div class="stats">
<article class="nv-card"><div class="nv-card__body">
<span class="nv-overline">Requests</span>
<span class="stat__value">14.2k</span>
<span class="nv-badge nv-badge--success">+6.1% vs yesterday</span>
</div></article>
<article class="nv-card"><div class="nv-card__body">
<span class="nv-overline">Error rate</span>
<span class="stat__value">0.42%</span>
<span class="nv-badge nv-badge--danger">+0.29 pts</span>
</div></article>
<article class="nv-card"><div class="nv-card__body">
<span class="nv-overline">p95 latency</span>
<span class="stat__value">318 ms</span>
<span class="nv-badge nv-badge--warning">+118%</span>
</div></article>
<article class="nv-card"><div class="nv-card__body">
<span class="nv-overline">Uptime</span>
<span class="stat__value">99.71%</span>
<span class="nv-badge">30-day window</span>
</div></article>
</div>
<!-- Chart ------------------------------------------------------------ -->
<article class="nv-card">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">p95 latency</h2>
<p class="nv-card__subtitle">Solid: today · dashed: same day last week</p>
</div>
<span class="nv-badge nv-badge--warning">Regression at 14:00</span>
</div>
<div class="nv-card__body">
<svg class="chart" viewBox="0 0 600 200" preserveAspectRatio="none"
role="img" aria-label="p95 latency over 24 hours, rising sharply after 14:00">
<defs>
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--nv-color-accent)" stop-opacity="0.28"/>
<stop offset="100%" stop-color="var(--nv-color-accent)" stop-opacity="0"/>
</linearGradient>
</defs>
<g class="chart__grid">
<line x1="0" y1="40" x2="600" y2="40"/>
<line x1="0" y1="90" x2="600" y2="90"/>
<line x1="0" y1="140" x2="600" y2="140"/>
<line x1="0" y1="190" x2="600" y2="190"/>
</g>
<path class="chart__area"
d="M0,150 L60,146 L120,152 L180,143 L240,148 L300,140 L360,132 L420,60 L480,44 L540,52 L600,40 L600,190 L0,190 Z"/>
<polyline class="chart__line"
points="0,150 60,146 120,152 180,143 240,148 300,140 360,132 420,60 480,44 540,52 600,40"/>
<polyline class="chart__line chart__line--compare"
points="0,152 60,149 120,151 180,146 240,150 300,145 360,148 420,143 480,147 540,141 600,145"/>
</svg>
<div class="chart__labels nv-caption">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>now</span>
</div>
</div>
</article>
<!-- Services and activity -------------------------------------------- -->
<div class="split">
<article class="nv-card nv-card--flush">
<div class="nv-card__header" style="padding: var(--nv-space-5)">
<div>
<h2 class="nv-card__title">Services</h2>
<p class="nv-card__subtitle">Health across the platform</p>
</div>
<div class="nv-nav-tabs" role="tablist" data-nv-tabs aria-label="Service view"
style="--nv-selection-name: nv-ops-tabs">
<button class="nv-nav__item" role="tab" id="tab-all"
aria-controls="panel-all" aria-selected="true">All</button>
<button class="nv-nav__item" role="tab" id="tab-attention"
aria-controls="panel-attention" aria-selected="false" tabindex="-1">Needs attention</button>
</div>
</div>
<div class="nv-tab-panel" id="panel-all" role="tabpanel" aria-labelledby="tab-all">
<div class="nv-table-frame">
<table class="nv-table">
<thead>
<tr>
<th scope="col">Service</th>
<th scope="col" class="nv-table__number">Uptime</th>
<th scope="col" class="nv-table__number">p95</th>
<th scope="col" class="nv-table__number">req/min</th>
<th scope="col"><span class="nv-visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
Checkout API
</span>
</td>
<td class="nv-table__number">99.98%</td>
<td class="nv-table__number">142 ms</td>
<td class="nv-table__number">2.4k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-1" style="--nv-anchor: --svc1" aria-label="Options for Checkout API">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-1" style="--nv-anchor: --svc1">
<span class="nv-menu__label">Checkout API</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>Degraded</span>
Pricing
</span>
</td>
<td class="nv-table__number">99.71%</td>
<td class="nv-table__number">318 ms</td>
<td class="nv-table__number">1.9k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-2" style="--nv-anchor: --svc2" aria-label="Options for Pricing">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-2" style="--nv-anchor: --svc2">
<span class="nv-menu__label">Pricing</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
Identity
</span>
</td>
<td class="nv-table__number">99.99%</td>
<td class="nv-table__number">88 ms</td>
<td class="nv-table__number">6.1k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-3" style="--nv-anchor: --svc3" aria-label="Options for Identity">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-3" style="--nv-anchor: --svc3">
<span class="nv-menu__label">Identity</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
Search
</span>
</td>
<td class="nv-table__number">99.95%</td>
<td class="nv-table__number">204 ms</td>
<td class="nv-table__number">3.3k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-4" style="--nv-anchor: --svc4" aria-label="Options for Search">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-4" style="--nv-anchor: --svc4">
<span class="nv-menu__label">Search</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Failing</span>
Notifications
</span>
</td>
<td class="nv-table__number">97.20%</td>
<td class="nv-table__number">—</td>
<td class="nv-table__number">0</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-5" style="--nv-anchor: --svc5" aria-label="Options for Notifications">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-5" style="--nv-anchor: --svc5">
<span class="nv-menu__label">Notifications</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
Reporting
</span>
</td>
<td class="nv-table__number">99.90%</td>
<td class="nv-table__number">512 ms</td>
<td class="nv-table__number">410</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-6" style="--nv-anchor: --svc6" aria-label="Options for Reporting">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-6" style="--nv-anchor: --svc6">
<span class="nv-menu__label">Reporting</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="nv-tab-panel" id="panel-attention" role="tabpanel"
aria-labelledby="tab-attention" hidden>
<div class="nv-table-frame">
<table class="nv-table">
<thead>
<tr>
<th scope="col">Service</th>
<th scope="col" class="nv-table__number">Uptime</th>
<th scope="col" class="nv-table__number">p95</th>
<th scope="col" class="nv-table__number">req/min</th>
<th scope="col"><span class="nv-visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>Degraded</span>
Pricing
</span>
</td>
<td class="nv-table__number">99.71%</td>
<td class="nv-table__number">318 ms</td>
<td class="nv-table__number">1.9k</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-2" style="--nv-anchor: --svc2" aria-label="Options for Pricing">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-2" style="--nv-anchor: --svc2">
<span class="nv-menu__label">Pricing</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
<tr>
<td>
<span class="service-name">
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Failing</span>
Notifications
</span>
</td>
<td class="nv-table__number">97.20%</td>
<td class="nv-table__number">—</td>
<td class="nv-table__number">0</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
popovertarget="svc-menu-5" style="--nv-anchor: --svc5" aria-label="Options for Notifications">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
</svg>
</button>
<div class="nv-menu" popover id="svc-menu-5" style="--nv-anchor: --svc5">
<span class="nv-menu__label">Notifications</span>
<button class="nv-menu__item">Open dashboard</button>
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
<hr class="nv-menu__separator">
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</article>
<div class="nv-stack">
<article class="nv-card">
<div class="nv-card__header">
<div><h2 class="nv-card__title">Activity</h2></div>
</div>
<div class="nv-card__body" style="padding-block-start: 0">
<ul class="activity nv-caption">
<li>
<span class="activity__mark activity__mark--danger"></span>
<span><strong class="nv-text-primary">Notifications</strong> stopped responding
<span style="display: block">14:12 · alert #881</span></span>
</li>
<li>
<span class="activity__mark activity__mark--warning"></span>
<span><strong class="nv-text-primary">Pricing</strong> p95 crossed 300 ms
<span style="display: block">14:04 · alert #880</span></span>
</li>
<li>
<span class="activity__mark activity__mark--accent"></span>
<span><strong class="nv-text-primary">#4412</strong> deployed to production
<span style="display: block">13:58 · by robin</span></span>
</li>
<li>
<span class="activity__mark activity__mark--success"></span>
<span><strong class="nv-text-primary">Search</strong> recovered
<span style="display: block">09:31 · alert #879</span></span>
</li>
</ul>
</div>
</article>
<div class="nv-disclosure-group">
<details class="nv-disclosure" open>
<summary class="nv-disclosure__summary">
What changed at 14:00?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
One deploy landed in the window: <code class="nv-code">#4412</code>,
which moved tax resolution inside the cart loop.
</div>
</details>
<details class="nv-disclosure">
<summary class="nv-disclosure__summary">
Who is on call?
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
</summary>
<div class="nv-disclosure__body">
Robin Kade until 18:00, then Sam Oyelaran. Escalation goes to
the platform channel after 10 minutes.
</div>
</details>
</div>
</div>
</div>
<footer class="nv-cluster nv-cluster--between" style="padding-block-start: var(--nv-space-6)">
<span class="nv-caption">Northwire Ops — an example interface built with Niveus.</span>
<span class="nv-caption">Made by Nexoniarz</span>
</footer>
</div>
</main>
</div>
<dialog class="nv-dialog" id="incident" aria-labelledby="incident-title">
<div class="nv-dialog__header">
<div>
<h2 class="nv-dialog__title" id="incident-title">Incident #881</h2>
<p class="nv-dialog__description">Notifications stopped responding at 14:12.</p>
</div>
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
aria-label="Close" data-nv-close>
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="nv-dialog__body">
<div class="nv-stack nv-stack--tight">
<div class="nv-cluster nv-cluster--tight">
<span class="nv-badge nv-badge--danger nv-badge--solid">Sev 2</span>
<span class="nv-badge">27 minutes open</span>
<span class="nv-badge">Robin Kade</span>
</div>
<p class="nv-body">
The queue consumer exited after the pricing service began timing out,
and did not restart because the health check still reported ready.
</p>
<div class="nv-field">
<label class="nv-field__label" for="note">Add a note</label>
<textarea class="nv-input" id="note" rows="3"
placeholder="What did you find?"></textarea>
</div>
</div>
</div>
<div class="nv-dialog__footer nv-dialog__footer--divided">
<button class="nv-button" data-nv-close>Close</button>
<button class="nv-button nv-button--danger">Page the team</button>
<button class="nv-button nv-button--primary" data-nv-close>Acknowledge</button>
</div>
</dialog>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("ops-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
const sidebar = document.querySelector('[data-nv-travel="local"]');
sidebar.addEventListener("click", (event) => {
const item = event.target.closest(".nv-nav__item");
if (!item || item.hasAttribute("aria-current")) return;
event.preventDefault();
nv.transition(() => {
sidebar.querySelectorAll("[aria-current]").forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
});
});
document.querySelectorAll("[data-range]").forEach((option) => {
option.addEventListener("click", () => {
document.querySelectorAll("[data-range]").forEach((other) => {
other.setAttribute("aria-checked", String(other === option));
});
document.getElementById("range-label").textContent = option.dataset.range;
document.getElementById("range-menu").hidePopover();
});
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,251 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Niveus — examples</title>
<link rel="stylesheet" href="../dist/niveus.css">
<script src="../niveus/niveus.js" defer></script>
<script>
try {
var saved = localStorage.getItem("niveus-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) {}
</script>
<style>
/* Layout only — see each example's app.css for the same discipline. */
.shell {
max-inline-size: 64rem;
margin-inline: auto;
padding: var(--nv-space-10) var(--nv-layout-gutter) var(--nv-space-20);
display: flex;
flex-direction: column;
gap: var(--nv-space-10);
}
.intro {
max-inline-size: var(--nv-measure);
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
}
.examples {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(19rem, 100%), 1fr));
gap: var(--nv-space-5);
}
/* Each card previews its own brand by re-deriving the set for itself. */
.swatches {
display: flex;
gap: var(--nv-space-1-5);
margin-block-start: var(--nv-space-1);
}
.swatches span {
inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
border-radius: var(--nv-radius-xs);
border: var(--nv-border-width) solid var(--nv-color-border);
}
.site-footer {
padding-block-start: var(--nv-space-8);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
</style>
</head>
<body>
<nav class="nv-nav" aria-label="Main">
<a class="nv-nav__brand" href="../index.html">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="6" stroke="currentColor" stroke-width="1.6"/>
<path d="M8 15.5V8.5l8 7v-7" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Niveus
</a>
<ul class="nv-nav__list">
<li><a class="nv-nav__item" href="../index.html">Specimen</a></li>
<li><span class="nv-nav__item" aria-current="page">Examples</span></li>
<li><a class="nv-nav__item" href="../REFERENCE.md">Reference</a></li>
</ul>
<span class="nv-nav__spacer"></span>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
</nav>
<main class="shell">
<header class="intro">
<span class="nv-overline">Examples</span>
<h1 class="nv-display">Four products, one system.</h1>
<p class="nv-prose">
Each of these is a complete interface built from Niveus and nothing else.
They look like different products because they are — but none of them
overrides a component, forks a token, or writes a color. What differs is
one declaration of <code class="nv-code">--nv-brand</code>, and the
layout each one needs for its own content.
</p>
<p class="nv-prose">
Open any <code class="nv-code">app.css</code> beside them: it contains
grids, widths and breakpoints, and not a single hex value. That split —
the system owns the language, the application owns the layout — is the
thing these examples are really demonstrating.
</p>
</header>
<div class="examples">
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #6d5efc">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">
<a class="nv-card__link" href="ai-landing/index.html"
style="color: inherit; text-decoration: none">Lumen</a>
</h2>
<p class="nv-card__subtitle">AI product landing page</p>
</div>
<span class="nv-badge nv-badge--accent">Marketing</span>
</div>
<div class="nv-card__body">
Hero, feature cards, a scrolling row, pricing and an FAQ. Shows the
bed as a showcase surface and the disclosure group.
<div class="swatches" aria-hidden="true">
<span style="background: var(--nv-color-accent)"></span>
<span style="background: var(--nv-color-accent-subtle)"></span>
<span style="background: var(--nv-color-surface)"></span>
<span style="background: var(--nv-color-background)"></span>
</div>
</div>
<div class="nv-card__footer">
<span class="nv-caption nv-code">--nv-brand: #6d5efc</span>
</div>
</article>
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #6d5efc">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">
<a class="nv-card__link" href="ai-chat/index.html"
style="color: inherit; text-decoration: none">Lumen Chat</a>
</h2>
<p class="nv-card__subtitle">AI chat application</p>
</div>
<span class="nv-badge nv-badge--accent">Web app</span>
</div>
<div class="nv-card__body">
A full app shell: sidebar with travelling selection, a transcript that
cross-fades between threads, a composer, and settings in a dialog with
tabs.
<div class="swatches" aria-hidden="true">
<span style="background: var(--nv-color-accent)"></span>
<span style="background: var(--nv-color-accent-subtle)"></span>
<span style="background: var(--nv-color-surface)"></span>
<span style="background: var(--nv-color-background)"></span>
</div>
</div>
<div class="nv-card__footer">
<span class="nv-caption nv-code">--nv-brand: #6d5efc</span>
</div>
</article>
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #ea580c">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">
<a class="nv-card__link" href="it-shop/index.html"
style="color: inherit; text-decoration: none">Northwire</a>
</h2>
<p class="nv-card__subtitle">IT hardware shop</p>
</div>
<span class="nv-badge nv-badge--accent">Commerce</span>
</div>
<div class="nv-card__body">
Filters, a product grid, stock states in the status colors, and a
basket in a dialog with a table. Same components, warm brand.
<div class="swatches" aria-hidden="true">
<span style="background: var(--nv-color-accent)"></span>
<span style="background: var(--nv-color-accent-subtle)"></span>
<span style="background: var(--nv-color-surface)"></span>
<span style="background: var(--nv-color-background)"></span>
</div>
</div>
<div class="nv-card__footer">
<span class="nv-caption nv-code">--nv-brand: #ea580c</span>
</div>
</article>
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #0d9488">
<div class="nv-card__header">
<div>
<h2 class="nv-card__title">
<a class="nv-card__link" href="dashboard/index.html"
style="color: inherit; text-decoration: none">Northwire Ops</a>
</h2>
<p class="nv-card__subtitle">Monitoring dashboard</p>
</div>
<span class="nv-badge nv-badge--accent">Data app</span>
</div>
<div class="nv-card__body">
Stat tiles, a chart drawn in semantic colors, a service table with
per-row menus, and tabs that filter it. The densest test of the system.
<div class="swatches" aria-hidden="true">
<span style="background: var(--nv-color-accent)"></span>
<span style="background: var(--nv-color-accent-subtle)"></span>
<span style="background: var(--nv-color-surface)"></span>
<span style="background: var(--nv-color-background)"></span>
</div>
</div>
<div class="nv-card__footer">
<span class="nv-caption nv-code">--nv-brand: #0d9488</span>
</div>
</article>
</div>
<div class="nv-bed">
<span class="nv-bed__label">How to read these</span>
<div class="nv-stack" style="max-inline-size: var(--nv-measure)">
<p class="nv-body">
Every example links exactly two files from the system —
<code class="nv-code">dist/niveus.css</code> and the optional
<code class="nv-code">niveus/niveus.js</code> — then its own
<code class="nv-code">app.css</code> for layout. Nothing else is shared
between them.
</p>
<p class="nv-body">
Where one of them needed something the system did not have, the answer
was to add it to the system, not to the page: the badge and table
components exist because these examples asked for them.
</p>
</div>
</div>
<footer class="site-footer">
<div class="nv-cluster nv-cluster--between">
<span class="nv-caption">Niveus — define the system once, implement it everywhere.</span>
<span class="nv-caption">Made by Nexoniarz</span>
</div>
</footer>
</main>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("niveus-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
});
</script>
</body>
</html>

View file

@ -0,0 +1,145 @@
/* ==========================================================================
Northwire — shop layout
--------------------------------------------------------------------------
Layout only. Note what this shop did *not* need in order to look like a
different product from the AI site next door: a second stylesheet, an
override file, or a single color. It set --nv-brand and kept going.
========================================================================== */
.shell {
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
.section {
padding-block: var(--nv-space-10);
}
.section__head {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--nv-space-4);
margin-block-end: var(--nv-space-6);
}
/* --- Search sits inside the bar, so it must not grow forever --------------- */
.search {
flex: 1;
max-inline-size: 26rem;
min-inline-size: 8rem;
}
/* --- Catalogue -------------------------------------------------------------- */
.catalogue {
display: grid;
gap: var(--nv-space-8);
align-items: start;
}
@media (min-width: 60rem) {
.catalogue {
grid-template-columns: 15rem 1fr;
}
}
.filters {
display: flex;
flex-direction: column;
gap: var(--nv-space-6);
position: sticky;
inset-block-start: calc(var(--nv-control-height-lg) + var(--nv-space-6));
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--nv-space-2-5);
}
.products {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
gap: var(--nv-space-4);
}
/* --- Product ------------------------------------------------------------------
The illustration tile is a bed: it gives the line art a ground that is
visibly not the card, without inventing a colour for it. */
.product__art {
display: grid;
place-items: center;
aspect-ratio: 4 / 3;
border-radius: 0;
border-inline: 0;
border-block-start: 0;
padding: var(--nv-space-4);
}
.product__art svg {
inline-size: 60%;
max-inline-size: 8rem;
color: var(--nv-color-text-tertiary);
}
.product__price {
font-size: var(--nv-font-size-lg);
font-weight: var(--nv-font-weight-semibold);
color: var(--nv-color-text-primary);
font-variant-numeric: tabular-nums;
}
.product__was {
font: var(--nv-font-caption);
color: var(--nv-color-text-tertiary);
text-decoration: line-through;
}
/* --- Cart ---------------------------------------------------------------------- */
.cart-line__art {
inline-size: var(--nv-space-10);
block-size: var(--nv-space-10);
display: grid;
place-items: center;
border-radius: var(--nv-radius-sm);
background-color: var(--nv-color-surface-inset);
box-shadow: var(--nv-elevation-inset-sm);
color: var(--nv-color-text-tertiary);
}
.cart-line__art svg {
inline-size: 60%;
}
.cart-totals {
display: flex;
flex-direction: column;
gap: var(--nv-space-2);
padding-block-start: var(--nv-space-4);
}
.cart-totals__row {
display: flex;
justify-content: space-between;
gap: var(--nv-space-4);
font-variant-numeric: tabular-nums;
}
.cart-totals__row--due {
padding-block-start: var(--nv-space-3);
border-block-start: var(--nv-border-width) solid var(--nv-color-border);
font-size: var(--nv-font-size-lg);
font-weight: var(--nv-font-weight-semibold);
color: var(--nv-color-text-primary);
}
.site-footer {
padding-block: var(--nv-space-8) var(--nv-space-12);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}

View file

@ -0,0 +1,519 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Northwire — an IT shop built with Niveus</title>
<link rel="stylesheet" href="../../dist/niveus.css">
<script src="../../niveus/niveus.js" defer></script>
<link rel="stylesheet" href="app.css">
<script>
try {
var saved = localStorage.getItem("northwire-theme");
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
} catch (error) {}
</script>
<!-- A shop, not an AI product. One declaration is the whole difference. -->
<style>
:root { --nv-brand: #ea580c; }
</style>
</head>
<body>
<nav class="nv-nav" id="topnav" aria-label="Main" style="--nv-selection-name: nv-shop-nav">
<a class="nv-nav__brand" href="#top">
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16l-1.5 12h-13z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M9 7V5.5a3 3 0 016 0V7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
Northwire
</a>
<div class="nv-input-group search">
<span class="nv-input-group__affix">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" style="inline-size: 1em; block-size: 1em">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="2"/>
<path d="M16 16l4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</span>
<label class="nv-visually-hidden" for="search">Search products</label>
<input class="nv-input nv-input--sm" id="search" type="search" placeholder="Search 4,200 products…">
</div>
<span class="nv-nav__spacer"></span>
<div class="nv-button-group" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
<button class="nv-button nv-button--sm nv-button--primary" data-nv-dialog="basket">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16l-1.5 12h-13z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
Basket
<span class="nv-badge nv-badge--count nv-badge--solid" id="basket-count">3</span>
</button>
</nav>
<main id="top">
<section class="shell section">
<div class="nv-bed nv-bed--single">
<div class="nv-cluster nv-cluster--between">
<div style="max-inline-size: var(--nv-measure)">
<span class="nv-overline">Clearance</span>
<h1 class="nv-heading" style="margin-block: var(--nv-space-2)">
Ex-demo workstations, checked and warrantied.
</h1>
<p class="nv-prose">
Everything here has been bench-tested and comes with the same
two-year return window as new stock.
</p>
</div>
<button class="nv-button nv-button--lg nv-button--primary">See all 74 deals</button>
</div>
</div>
</section>
<section class="shell section">
<div class="section__head">
<div>
<span class="nv-overline">This week</span>
<h2 class="nv-heading">Reduced while stock lasts</h2>
</div>
</div>
<div class="nv-scroller-frame" data-nv-scroller>
<div class="nv-scroller" style="--nv-scroller-item: 15rem">
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Forge 14 Developer</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Ryzen 9 · 32 GB · 1 TB NVMe</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$1,849</p>
</div>
</article>
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4.5" width="18" height="12" rx="1.8" stroke="currentColor" stroke-width="1.6"/><path d="M9.5 19.5h5M12 16.5v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Clarity 27 4K</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">27″ · 3840×2160 · 144 Hz</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$629</p>
</div>
</article>
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Anvil TKL</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Tactile · hot-swap · USB-C</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$139</p>
</div>
</article>
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7.5" width="19" height="9" rx="1.6" stroke="currentColor" stroke-width="1.6"/><circle cx="8.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/><circle cx="15.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Halo X900 16 GB</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Triple fan · 2.6 GHz boost</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$1,099</p>
</div>
</article>
<article class="nv-card nv-card--interactive nv-card--flush">
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<h3 class="nv-label">Vault 4 TB NVMe</h3>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Gen4 · 7,400 MB/s read</p>
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$289</p>
</div>
</article>
</div>
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="prev" aria-label="Previous">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M14 6l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="next" aria-label="Next">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M10 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</div>
</section>
<section class="shell section" id="catalogue">
<div class="catalogue">
<aside class="filters" aria-label="Filters">
<div class="filter-group">
<span class="nv-overline">Category</span>
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox" checked> Workstations</label>
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox" checked> Displays</label>
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox"> Networking</label>
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox"> Peripherals</label>
</div>
<hr class="nv-divider" style="margin: 0">
<div class="filter-group">
<span class="nv-overline">Price</span>
<div class="nv-cluster nv-cluster--tight" style="flex-wrap: nowrap">
<div class="nv-input-group">
<span class="nv-input-group__affix">$</span>
<label class="nv-visually-hidden" for="min">Minimum price</label>
<input class="nv-input nv-input--sm" id="min" inputmode="numeric" value="0">
</div>
<span class="nv-caption">to</span>
<div class="nv-input-group">
<span class="nv-input-group__affix">$</span>
<label class="nv-visually-hidden" for="max">Maximum price</label>
<input class="nv-input nv-input--sm" id="max" inputmode="numeric" value="2000">
</div>
</div>
</div>
<hr class="nv-divider" style="margin: 0">
<div class="filter-group">
<span class="nv-overline">Availability</span>
<label class="nv-choice nv-caption"><input class="nv-switch" type="checkbox" checked> In stock only</label>
<label class="nv-choice nv-caption"><input class="nv-switch" type="checkbox"> Ex-demo</label>
</div>
<hr class="nv-divider" style="margin: 0">
<div class="filter-group">
<span class="nv-overline">Condition</span>
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition" checked> Any</label>
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition"> New</label>
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition"> Refurbished</label>
</div>
<button class="nv-button nv-button--ghost nv-button--sm">Clear filters</button>
</aside>
<div>
<div class="section__head">
<div>
<h2 class="nv-title">Workstations &amp; displays</h2>
<p class="nv-caption">8 of 412 products</p>
</div>
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="sort-menu"
style="--nv-anchor: --sort">
Sort: <span id="sort-label">Relevance</span>
<span class="nv-disclosure__chevron" aria-hidden="true"
style="margin-inline-start: var(--nv-space-1)"></span>
</button>
<div class="nv-menu" popover id="sort-menu" style="--nv-anchor: --sort">
<button class="nv-menu__item" aria-checked="true" data-sort="Relevance">Relevance</button>
<button class="nv-menu__item" data-sort="Price, low to high">Price, low to high</button>
<button class="nv-menu__item" data-sort="Price, high to low">Price, high to low</button>
<button class="nv-menu__item" data-sort="Newest">Newest</button>
</div>
</div>
<div class="products">
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Forge 14 Developer</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Ryzen 9 · 32 GB · 1 TB NVMe</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$1,849</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Forge 14 Developer">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4.5" width="18" height="12" rx="1.8" stroke="currentColor" stroke-width="1.6"/><path d="M9.5 19.5h5M12 16.5v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Clarity 27 4K</h3>
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>4 left</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">27″ · 3840×2160 · 144 Hz</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$629</span>
<span class="product__was">$749</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Clarity 27 4K">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Anvil TKL</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Tactile · hot-swap · USB-C</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$139</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Anvil TKL">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7.5" width="19" height="9" rx="1.6" stroke="currentColor" stroke-width="1.6"/><circle cx="8.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/><circle cx="15.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Halo X900 16 GB</h3>
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Backorder</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Triple fan · 2.6 GHz boost</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$1,099</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Halo X900 16 GB">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Vault 4 TB NVMe</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Gen4 · 7,400 MB/s read</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$289</span>
<span class="product__was">$329</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Vault 4 TB NVMe">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="13" width="18" height="6" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M7 16.5h.01M10 16.5h.01M8 9.5a5 5 0 018 0M11 6.2a8.5 8.5 0 012 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Mesh Six Pro</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Wi-Fi 6E · 3-pack · 2.5 GbE</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$399</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Mesh Six Pro">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 15v-3a7 7 0 0114 0v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect x="3" y="14" width="4" height="6" rx="1.6" stroke="currentColor" stroke-width="1.6"/><rect x="17" y="14" width="4" height="6" rx="1.6" stroke="currentColor" stroke-width="1.6"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Quiet Field 2</h3>
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>2 left</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">ANC · 40 h · dual link</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$219</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Quiet Field 2">Add to basket</button>
</div>
</article>
<article class="nv-card nv-card--flush nv-card--interactive">
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="9" width="18" height="7" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M7 12.5h2M11 12.5h2M15.5 12.5h2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
<div class="nv-card__body" style="padding: var(--nv-space-4)">
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
<h3 class="nv-label">Bridge 13-in-1</h3>
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
</div>
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Thunderbolt 4 · 96 W PD</p>
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
<span class="product__price">$179</span>
</div>
</div>
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
data-add="Bridge 13-in-1">Add to basket</button>
</div>
</article>
</div>
</div>
</div>
</section>
<footer class="shell site-footer">
<div class="nv-cluster nv-cluster--between">
<span class="nv-caption">Northwire — an example interface built with Niveus.</span>
<span class="nv-caption">Made by Nexoniarz</span>
</div>
</footer>
</main>
<dialog class="nv-dialog nv-dialog--wide" id="basket" aria-labelledby="basket-title">
<div class="nv-dialog__header">
<div>
<h2 class="nv-dialog__title" id="basket-title">Your basket</h2>
<p class="nv-dialog__description">Three items · delivered Thursday</p>
</div>
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
aria-label="Close" data-nv-close>
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="nv-dialog__body">
<div class="nv-table-frame">
<table class="nv-table">
<thead>
<tr>
<th scope="col">Item</th>
<th scope="col" class="nv-table__number">Qty</th>
<th scope="col" class="nv-table__number">Price</th>
<th scope="col"><span class="nv-visually-hidden">Remove</span></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<div class="nv-cluster" style="flex-wrap: nowrap">
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span>
Forge 14 Developer
<span class="nv-caption" style="display: block">Ryzen 9 · 32 GB</span>
</span>
</div>
</td>
<td class="nv-table__number">1</td>
<td class="nv-table__number">$1,849</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Forge 14 Developer">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</td>
</tr>
<tr>
<td>
<div class="nv-cluster" style="flex-wrap: nowrap">
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span>
Vault 4 TB NVMe
<span class="nv-caption" style="display: block">Gen4 NVMe</span>
</span>
</div>
</td>
<td class="nv-table__number">1</td>
<td class="nv-table__number">$289</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Vault 4 TB NVMe">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</td>
</tr>
<tr>
<td>
<div class="nv-cluster" style="flex-wrap: nowrap">
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
<span>
Anvil TKL
<span class="nv-caption" style="display: block">Tactile · hot-swap</span>
</span>
</div>
</td>
<td class="nv-table__number">1</td>
<td class="nv-table__number">$139</td>
<td class="nv-table__actions">
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Anvil TKL">
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="cart-totals">
<div class="cart-totals__row nv-caption"><span>Subtotal</span><span>$2,277.00</span></div>
<div class="cart-totals__row nv-caption"><span>Delivery</span><span>Free</span></div>
<div class="cart-totals__row nv-caption"><span>VAT (20%)</span><span>$455.40</span></div>
<div class="cart-totals__row cart-totals__row--due"><span>Due today</span><span>$2,732.40</span></div>
</div>
</div>
<div class="nv-dialog__footer nv-dialog__footer--divided">
<button class="nv-button" data-nv-close>Keep shopping</button>
<button class="nv-button nv-button--primary">Checkout</button>
</div>
</dialog>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.documentElement.addEventListener("nv:theme", (event) => {
try { localStorage.setItem("northwire-theme", event.detail.theme); } catch {}
});
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
/* Adding to the basket: the count changes, so it should be watchable. */
const count = document.getElementById("basket-count");
document.querySelectorAll("[data-add]").forEach((button) => {
button.addEventListener("click", () => {
nv.transition(() => {
count.textContent = String(Number(count.textContent) + 1);
});
button.textContent = "Added";
setTimeout(() => { button.textContent = "Add to basket"; }, 1200);
});
});
document.querySelectorAll("[data-sort]").forEach((option) => {
option.addEventListener("click", () => {
document.querySelectorAll("[data-sort]").forEach((other) => {
other.setAttribute("aria-checked", String(other === option));
});
document.getElementById("sort-label").textContent = option.dataset.sort;
document.getElementById("sort-menu").hidePopover();
});
});
const topnav = document.getElementById("topnav");
const onScroll = () => topnav.toggleAttribute("data-scrolled", scrollY > 8);
addEventListener("scroll", onScroll, { passive: true });
onScroll();
});
</script>
</body>
</html>

1350
docs/niveus/index.html Normal file

File diff suppressed because it is too large Load diff

197
docs/niveus/niveus/base.css Normal file
View file

@ -0,0 +1,197 @@
/* ==========================================================================
Niveus — Base
--------------------------------------------------------------------------
The minimum an application needs before any component renders: a predictable
box model, the canvas, the default type, and the system-wide focus and
selection treatment.
This layer styles elements, never classes with intent. Anything opinionated
enough to need a name belongs in components/.
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100svh;
background-color: var(--nv-color-background);
color: var(--nv-color-text-primary);
font: var(--nv-font-body);
letter-spacing: var(--nv-letter-spacing-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-synthesis-weight: none;
/* The canvas glides into a new appearance with everything standing on it,
rather than cutting while the surfaces above it fade. */
transition: var(--nv-transition-colors);
}
/* --- Rhythm ---------------------------------------------------------------
Block elements carry no outer margin of their own; spacing between them is
the layout's decision, set with gap or the spacing tokens. */
h1, h2, h3, h4, h5, h6,
p, figure, blockquote, dl, dd, pre {
margin: 0;
}
h1, h2, h3, h4, h5, h6 {
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-tight);
line-height: var(--nv-line-height-snug);
text-wrap: balance;
}
h1 { font-size: var(--nv-font-size-3xl); letter-spacing: var(--nv-letter-spacing-tighter); }
h2 { font-size: var(--nv-font-size-2xl); }
h3 { font-size: var(--nv-font-size-xl); }
h4 { font-size: var(--nv-font-size-lg); }
h5 { font-size: var(--nv-font-size-md); }
h6 { font-size: var(--nv-font-size-sm); }
p {
text-wrap: pretty;
}
small {
font-size: var(--nv-font-size-xs);
}
code, kbd, samp, pre {
font-family: var(--nv-font-family-mono);
font-size: var(--nv-font-size-sm);
}
/* --- Links ----------------------------------------------------------------- */
a {
color: var(--nv-color-accent-text);
text-decoration-color: color-mix(in srgb, currentColor 35%, transparent);
text-underline-offset: 0.18em;
transition: var(--nv-transition-colors);
}
a:hover {
text-decoration-color: currentColor;
}
/* --- Media ----------------------------------------------------------------- */
img, svg, video, canvas, audio, iframe, embed, object {
display: block;
max-width: 100%;
}
svg {
flex: none;
}
hr {
border: 0;
border-top: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin: 0;
}
/* --- Forms -----------------------------------------------------------------
Form controls do not inherit type by default; without this every field in
the system would silently fall back to the browser's own font. */
button, input, optgroup, select, textarea {
font: inherit;
color: inherit;
margin: 0;
}
textarea {
resize: vertical;
}
:disabled {
cursor: not-allowed;
}
/* --- Focus -----------------------------------------------------------------
One focus treatment for the whole system. Components that need the ring to
follow their own shape override box-shadow, not outline-color. */
:focus-visible {
outline: var(--nv-focus-ring-width) solid var(--nv-color-focus-ring);
outline-offset: 2px;
border-radius: var(--nv-radius-xs);
}
::selection {
background-color: var(--nv-color-selection);
color: var(--nv-color-text-primary);
}
/* --- Scrollbars ------------------------------------------------------------ */
* {
scrollbar-color: var(--nv-color-border-strong) transparent;
scrollbar-width: thin;
}
/* ==========================================================================
Type roles
--------------------------------------------------------------------------
Named roles from tokens/typography.css, available as classes so hierarchy
stays consistent even when the markup cannot use a heading element.
========================================================================== */
.nv-display {
font: var(--nv-font-display);
letter-spacing: var(--nv-letter-spacing-tighter);
text-wrap: balance;
}
.nv-heading {
font: var(--nv-font-heading);
letter-spacing: var(--nv-letter-spacing-tight);
text-wrap: balance;
}
.nv-title {
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
}
.nv-body {
font: var(--nv-font-body);
text-wrap: pretty;
}
.nv-label {
font: var(--nv-font-label);
}
.nv-caption {
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
}
.nv-overline {
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
.nv-code {
font: var(--nv-font-code);
}
/* Text color roles, for when the role is about emphasis rather than size. */
.nv-text-primary { color: var(--nv-color-text-primary); }
.nv-text-secondary { color: var(--nv-color-text-secondary); }
.nv-text-tertiary { color: var(--nv-color-text-tertiary); }
.nv-text-accent { color: var(--nv-color-accent-text); }
.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); }

View file

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

View file

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

View file

@ -0,0 +1,282 @@
/* ==========================================================================
Niveus — Button
--------------------------------------------------------------------------
.nv-button secondary by default — a raised surface
.nv-button--primary the single affirmative action in a view
.nv-button--ghost low-emphasis, no material until hovered
.nv-button--danger destructive, and only when it is
.nv-button--frosted for use over imagery or frosted chrome
.nv-button--sm | --lg 36px by default
.nv-button--icon square, for a lone icon (needs a label)
.nv-button--block fills its container
Every variant is defined purely by reassigning the private --_* properties
below. Interaction — the lift on hover, the sink on press, the focus ring —
lives once in the base rule, so a new variant can never drift from the way
the rest of the system responds to a pointer.
========================================================================== */
.nv-button {
/* Variant surface, overridden per modifier. */
--_bg: var(--nv-color-surface-elevated);
--_bg-hover: var(--nv-color-surface-hover);
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-primary);
--_border: var(--nv-color-border);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
--_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-3);
--_shadow-active: var(--nv-elevation-inset-sm);
/* Size, overridden per size modifier. */
--_height: var(--nv-control-height-md);
--_padding: var(--nv-control-padding-md);
--_gap: var(--nv-control-gap-md);
--_font-size: var(--nv-font-size-sm);
--_radius: var(--nv-radius-md);
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--_gap);
block-size: var(--_height);
padding-inline: var(--_padding);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--_radius);
background-color: var(--_bg);
color: var(--_fg);
box-shadow: var(--_shadow);
font-family: var(--nv-font-family-sans);
font-size: var(--_font-size);
font-weight: var(--nv-font-weight-medium);
line-height: var(--nv-line-height-snug);
letter-spacing: var(--nv-letter-spacing-normal);
white-space: nowrap;
text-decoration: none;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition: var(--nv-transition-control);
}
/* A control smaller than the minimum target still gets a full-size hit area:
the pseudo-element grows outward without changing the visual size. */
.nv-button::after {
content: "";
position: absolute;
inset-block-start: 50%;
inset-inline-start: 50%;
translate: -50% -50%;
inline-size: max(100%, var(--nv-target-min));
block-size: max(100%, var(--nv-target-min));
}
@media (hover: hover) {
.nv-button:hover:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-hover);
box-shadow: var(--_shadow-hover);
translate: 0 var(--nv-lift-hover);
}
}
.nv-button:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-active);
box-shadow: var(--_shadow-active);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
}
.nv-button:focus-visible {
outline: none;
box-shadow: var(--_shadow), var(--nv-focus-ring);
}
.nv-button:disabled,
.nv-button[aria-disabled="true"] {
background-color: var(--nv-color-surface-inset);
color: var(--nv-color-text-disabled);
border-color: var(--nv-color-border-subtle);
box-shadow: none;
translate: none;
cursor: not-allowed;
}
/* --- Variants --------------------------------------------------------------- */
.nv-button--primary {
--_bg: var(--nv-color-accent);
--_bg-hover: var(--nv-color-accent-hover);
--_bg-active: var(--nv-color-accent-active);
--_fg: var(--nv-color-on-accent);
--_border: transparent;
font-weight: var(--nv-font-weight-semibold);
}
.nv-button--danger {
--_bg: var(--nv-color-danger);
--_bg-hover: var(--nv-color-danger-hover);
--_bg-active: var(--nv-color-danger-active);
--_fg: var(--nv-color-on-danger);
--_border: transparent;
--nv-color-focus-ring: var(--nv-color-danger-border);
font-weight: var(--nv-font-weight-semibold);
}
.nv-button--ghost {
--_bg: transparent;
--_bg-hover: var(--nv-color-surface-hover);
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-secondary);
--_border: transparent;
--_shadow: none;
--_shadow-hover: none;
--_shadow-active: var(--nv-elevation-inset-sm);
}
@media (hover: hover) {
.nv-button--ghost:hover:not(:disabled, [aria-disabled="true"]) {
color: var(--nv-color-text-primary);
translate: none; /* nothing to lift — a ghost has no material to raise */
}
}
.nv-button--subtle {
--_bg: var(--nv-color-accent-subtle);
--_bg-hover: var(--nv-color-accent-subtle-hover);
--_bg-active: var(--nv-color-accent-subtle-hover);
--_fg: var(--nv-color-accent-text);
--_border: var(--nv-color-accent-border);
--_shadow: none;
--_shadow-hover: var(--nv-elevation-1);
}
.nv-button--frosted {
--_bg: var(--nv-material-glass-bg);
--_bg-hover: var(--nv-material-frosted-bg);
--_bg-active: var(--nv-material-frosted-bg);
--_border: var(--nv-material-glass-border);
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Sizes ------------------------------------------------------------------- */
.nv-button--sm {
--_height: var(--nv-control-height-sm);
--_padding: var(--nv-control-padding-sm);
--_gap: var(--nv-control-gap-sm);
--_font-size: var(--nv-font-size-xs);
--_radius: var(--nv-radius-sm);
}
.nv-button--lg {
--_height: var(--nv-control-height-lg);
--_padding: var(--nv-control-padding-lg);
--_gap: var(--nv-control-gap-lg);
--_font-size: var(--nv-font-size-md);
}
.nv-button--icon {
--_padding: 0;
inline-size: var(--_height);
}
.nv-button--pill {
--_radius: var(--nv-radius-full);
}
.nv-button--block {
display: flex;
inline-size: 100%;
}
/* --- Floating ------------------------------------------------------------------
A control that sits *over* content rather than in the flow: a carousel arrow,
a floating action. Two things change.
It is already off the surface, so there is nothing to lift it from — it grows
instead. And it is almost always positioned with `translate`, which the hover
lift would overwrite, throwing the button half its own height down the page
the moment a pointer touched it. --nv-button-offset carries that positioning
through every state. */
.nv-button--float {
--_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
--_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-4);
--_shadow-active: var(--nv-highlight-top), var(--nv-elevation-2);
translate: var(--nv-button-offset, 0 0);
}
@media (hover: hover) {
.nv-button--float:hover:not(:disabled, [aria-disabled="true"]) {
translate: var(--nv-button-offset, 0 0);
scale: 1.06;
}
}
.nv-button--float:active:not(:disabled, [aria-disabled="true"]) {
translate: var(--nv-button-offset, 0 0);
scale: 0.97;
}
/* --- Parts --------------------------------------------------------------------- */
.nv-button__icon {
inline-size: 1em;
block-size: 1em;
font-size: 1.15em;
opacity: 0.9;
}
/* Busy: the label stays in place to keep the button from resizing mid-action,
and the spinner replaces it. */
.nv-button[aria-busy="true"] {
color: transparent;
pointer-events: none;
}
.nv-button[aria-busy="true"]::before {
content: "";
position: absolute;
inline-size: 1em;
block-size: 1em;
border: 2px solid var(--_fg);
border-block-start-color: transparent;
border-radius: var(--nv-radius-full);
animation: nv-spin var(--nv-duration-slower) var(--nv-ease-linear) infinite;
}
@keyframes nv-spin {
to { rotate: 1turn; }
}
/* --- Group ---------------------------------------------------------------------
Segmented controls: buttons share an edge, so only the outer corners round
and the shared border is not drawn twice. */
.nv-button-group {
display: inline-flex;
align-items: center;
}
.nv-button-group > .nv-button:not(:first-child) {
border-start-start-radius: 0;
border-end-start-radius: 0;
margin-inline-start: calc(var(--nv-border-width) * -1);
}
.nv-button-group > .nv-button:not(:last-child) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.nv-button-group > .nv-button:hover,
.nv-button-group > .nv-button:focus-visible {
z-index: 1;
translate: none;
}

View file

@ -0,0 +1,171 @@
/* ==========================================================================
Niveus — Card
--------------------------------------------------------------------------
.nv-card resting surface, elevation 1
.nv-card--elevated separated from its context, elevation 2
.nv-card--inset recessed into its parent
.nv-card--frosted over imagery or scrolling content
.nv-card--interactive the whole card is one target
.nv-card__header / __body / __footer / __media
A card is a container, not a look. If a card needs a shadow that is not on
the elevation scale, the answer is a different elevation — not a different
shadow.
========================================================================== */
.nv-card {
--_bg: var(--nv-color-surface);
--_border: var(--nv-color-border-subtle);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-1);
--_padding: var(--nv-space-5);
position: relative;
display: flex;
flex-direction: column;
min-inline-size: 0;
background-color: var(--_bg);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--nv-radius-lg);
box-shadow: var(--_shadow);
/* Children are clipped to the corner radius so media and inset regions
cannot square off the card. */
overflow: hidden;
transition: var(--nv-transition-surface);
}
.nv-card--elevated {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
}
.nv-card--floating {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
}
.nv-card--inset {
--_bg: var(--nv-color-surface-inset);
--_shadow: var(--nv-elevation-inset-md);
--_border: var(--nv-color-border-subtle);
}
.nv-card--frosted {
--_bg: var(--nv-material-frosted-bg);
--_border: var(--nv-material-frosted-border);
--_shadow: var(--nv-material-frosted-shadow);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
.nv-card--flush {
--_padding: 0;
}
/* --- Interactive ------------------------------------------------------------
Rises on hover and sinks on press, exactly like a button — one physical
language for anything that responds to a pointer. */
.nv-card--interactive {
cursor: pointer;
text-align: start;
text-decoration: none;
}
@media (hover: hover) {
.nv-card--interactive:hover {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
translate: 0 calc(var(--nv-lift-hover) * 2);
}
}
.nv-card--interactive:active {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
}
.nv-card--interactive:focus-visible {
outline: none;
box-shadow: var(--_shadow), var(--nv-focus-ring);
}
/* Cards whose whole area is clickable via a link inside: the link's target is
stretched over the card, so the markup stays semantic and only the link text
is announced. */
.nv-card__link::after {
content: "";
position: absolute;
inset: 0;
}
/* --- Parts ------------------------------------------------------------------- */
.nv-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--nv-space-4);
padding: var(--_padding);
padding-block-end: var(--nv-space-3);
}
.nv-card__title {
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
margin: 0;
}
.nv-card__subtitle {
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
margin-block-start: var(--nv-space-1);
}
.nv-card__body {
flex: 1;
padding: var(--_padding);
color: var(--nv-color-text-secondary);
font: var(--nv-font-body);
}
/* A header already opened the block, so the body should not open it again. */
.nv-card__header + .nv-card__body {
padding-block-start: 0;
}
.nv-card__footer {
display: flex;
align-items: center;
gap: var(--nv-space-2);
padding: var(--_padding);
padding-block-start: var(--nv-space-3);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block-start: auto;
}
.nv-card__footer--plain {
border-block-start: 0;
padding-block-start: 0;
}
.nv-card__media {
display: block;
inline-size: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background-color: var(--nv-color-surface-inset);
}
/* --- Group ---------------------------------------------------------------------
Equal-height cards on one rhythm, collapsing to a single column without a
media query. */
.nv-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: var(--nv-space-4);
align-items: stretch;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,349 @@
/* ==========================================================================
Niveus — Navigation
--------------------------------------------------------------------------
.nv-nav horizontal bar, frosted, sticky by default
.nv-nav--vertical sidebar
.nv-nav--plain solid instead of frosted
.nv-nav__brand / __list / __item / __section / __spacer
.nv-nav-tabs in-page tab strip with a sliding indicator
Navigation is chrome: it sits above content rather than in it. That is why
the bar is frosted — the blur is not decoration, it is the signal that
content is passing underneath. A sidebar that scrolls with the page is not
chrome, so .nv-nav--vertical drops the translucency by default.
========================================================================== */
.nv-nav {
position: sticky;
inset-block-start: 0;
z-index: 10;
display: flex;
align-items: center;
gap: var(--nv-space-4);
padding: var(--nv-space-2) var(--nv-space-4);
min-block-size: var(--nv-control-height-lg);
background-color: var(--nv-material-frosted-bg);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
/* The travelling selection marker sits behind its item at z-index -1; the
bar has to be its own stacking context or the marker would fall behind
the bar's background and disappear. */
isolation: isolate;
box-shadow: var(--nv-highlight-top);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
transition: var(--nv-transition-surface);
}
/* The bar only earns a shadow once something is behind it. Set data-scrolled
from the application on scroll. */
.nv-nav[data-scrolled] {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
transition: box-shadow var(--nv-duration-normal) var(--nv-ease-standard);
}
.nv-nav--plain {
background-color: var(--nv-color-surface);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-nav--static {
position: static;
}
/* A navigation group used inside content rather than as page chrome: no bar,
no border, no blur — just the items and their travelling selection. */
.nv-nav--inline {
position: static;
display: inline-flex;
min-block-size: 0;
padding: var(--nv-space-1);
background-color: transparent;
border-block-end: 0;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-nav__brand {
display: flex;
align-items: center;
gap: var(--nv-space-2);
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
color: var(--nv-color-text-primary);
text-decoration: none;
padding-inline: var(--nv-space-1);
}
.nv-nav__list {
display: flex;
align-items: center;
gap: var(--nv-space-1);
margin: 0;
padding: 0;
list-style: none;
min-inline-size: 0;
}
.nv-nav__spacer {
flex: 1;
}
/* --- Item --------------------------------------------------------------------
Items are flat until they are touched. The active item is the only one with
material, which keeps the bar quiet while still answering "where am I". */
.nv-nav__item {
/* Rides above a travelling marker once niveus.js has given it a name. */
view-transition-class: nv-carried;
position: relative;
display: flex;
align-items: center;
gap: var(--nv-space-2);
padding: var(--nv-space-2) var(--nv-space-3);
min-block-size: var(--nv-control-height-md);
border: var(--nv-border-width) solid transparent;
border-radius: var(--nv-radius-md);
background-color: transparent;
color: var(--nv-color-text-secondary);
font: var(--nv-font-label);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: var(--nv-transition-control);
}
@media (hover: hover) {
.nv-nav__item:hover {
background-color: var(--nv-color-surface-hover);
color: var(--nv-color-text-primary);
}
}
.nv-nav__item:active {
background-color: var(--nv-color-surface-active);
box-shadow: var(--nv-elevation-inset-sm);
transition-duration: var(--nv-duration-instant);
}
.nv-nav__item:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
/* Selection is a material that *moves*. The emphasis lives in a pseudo-element
rather than on the item itself, and carries a view-transition-name: when the
selection changes inside a transition, the browser interpolates the marker's
old box into its new one, so the highlight slides from the previous item to
the next instead of blinking out and in somewhere else.
Only one item in a group may be selected at a time — that is what keeps the
name unique. A page with several selection groups must give each its own
--nv-selection-name, or the duplicate name aborts the transition. */
/* Selection changes color and gains material — but not weight. Bolding the
selected item re-measures its text, which reflows every item beside it on
each change, and makes the old and new labels cross-fade against each other
at different widths. The pill is the cue; the weight was costing a flicker
for nothing. */
.nv-nav__item[aria-current],
.nv-nav__item--active,
.nv-nav__item[aria-selected="true"] {
color: var(--nv-color-text-primary);
}
.nv-nav__item[aria-current]::before,
.nv-nav__item--active::before,
.nv-nav__item[aria-selected="true"]::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background-color: var(--nv-color-surface-elevated);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: inherit;
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
view-transition-name: var(--nv-selection-name);
view-transition-class: nv-travelling;
}
/* Hovering an already-selected item should not restate the emphasis. */
@media (hover: hover) {
.nv-nav__item[aria-current]:hover,
.nv-nav__item--active:hover,
.nv-nav__item[aria-selected="true"]:hover {
background-color: transparent;
}
}
.nv-nav__item[aria-disabled="true"] {
color: var(--nv-color-text-disabled);
pointer-events: none;
}
.nv-nav__icon {
inline-size: 1.15em;
block-size: 1.15em;
flex: none;
opacity: 0.85;
}
.nv-nav__badge {
margin-inline-start: auto;
padding-inline: var(--nv-space-1-5);
min-inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
display: inline-flex;
align-items: center;
justify-content: center;
background-color: var(--nv-color-accent-subtle);
color: var(--nv-color-accent-text);
border-radius: var(--nv-radius-full);
font: var(--nv-font-caption);
font-size: var(--nv-font-size-2xs);
font-weight: var(--nv-font-weight-semibold);
}
/* --- Vertical ------------------------------------------------------------------ */
.nv-nav--vertical {
position: sticky;
inset-block-start: 0;
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-1);
inline-size: 15rem;
block-size: 100svh;
padding: var(--nv-space-4) var(--nv-space-3);
background-color: var(--nv-color-background-subtle);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border-block-end: 0;
border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: none;
overflow-y: auto;
}
.nv-nav--vertical .nv-nav__list {
flex-direction: column;
align-items: stretch;
gap: var(--nv-space-0-5);
}
.nv-nav--vertical .nv-nav__item {
justify-content: flex-start;
}
.nv-nav--vertical .nv-nav__brand {
padding: var(--nv-space-2) var(--nv-space-3);
margin-block-end: var(--nv-space-3);
}
.nv-nav__section {
padding: var(--nv-space-4) var(--nv-space-3) var(--nv-space-1-5);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
.nv-nav__section:first-child {
padding-block-start: 0;
}
/* --- Tabs ------------------------------------------------------------------------
For switching views inside a page, not for moving between pages. The strip
itself is inset, so the selected tab reads as lifted out of the track. */
.nv-nav-tabs {
display: inline-flex;
align-items: center;
gap: var(--nv-space-0-5);
padding: var(--nv-space-1);
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-elevation-inset-sm);
overflow-x: auto;
scrollbar-width: none;
isolation: isolate;
}
.nv-nav-tabs::-webkit-scrollbar {
display: none;
}
.nv-nav-tabs .nv-nav__item {
border-radius: var(--nv-radius-sm);
min-block-size: var(--nv-control-height-sm);
padding-block: var(--nv-space-1);
}
/* Tabs are a selection group of their own, so they get their own marker name;
sharing the navigation bar's would mean two visible elements with the same
view-transition-name. */
.nv-nav-tabs {
--nv-selection-name: nv-tabs-selection;
}
.nv-nav-tabs .nv-nav__item[aria-selected="true"]::before {
border-radius: var(--nv-radius-sm);
}
/* --- Panels ----------------------------------------------------------------------
Switching tabs should feel like moving through one surface rather than
swapping the interface. The panel carries the shared content name, so the
outgoing and incoming panels cross-fade inside the same box — and slide
with the direction of travel when the application supplies one. */
.nv-tab-panel {
view-transition-name: var(--nv-content-name, nv-content);
}
/* --- Breadcrumbs -------------------------------------------------------------------- */
.nv-breadcrumbs {
display: flex;
align-items: center;
gap: var(--nv-space-2);
margin: 0;
padding: 0;
list-style: none;
font: var(--nv-font-caption);
color: var(--nv-color-text-tertiary);
}
.nv-breadcrumbs li + li::before {
content: "/";
margin-inline-end: var(--nv-space-2);
color: var(--nv-color-border-strong);
}
.nv-breadcrumbs a {
color: var(--nv-color-text-secondary);
text-decoration: none;
}
.nv-breadcrumbs a:hover {
color: var(--nv-color-text-primary);
}
.nv-breadcrumbs [aria-current] {
color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-medium);
}

View file

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

View file

@ -0,0 +1,116 @@
/* ==========================================================================
Niveus — Surface
--------------------------------------------------------------------------
The material vocabulary, expressed as classes. Every other component is
built from these; applications use them directly for anything the system
does not ship as a named component.
.nv-surface a container using the solid material
.nv-material-{type} apply a material to any element
.nv-elevation-{0..5} apply a depth level to any element
A material and an elevation are independent: .nv-material-solid
.nv-elevation-3 is a legitimate combination. What is not legitimate is a
raw box-shadow that belongs to neither scale.
========================================================================== */
.nv-surface {
background-color: var(--nv-color-surface);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: var(--nv-radius-lg);
transition: var(--nv-transition-surface);
}
/* --- Materials ------------------------------------------------------------- */
.nv-material-solid {
background-color: var(--nv-material-solid-bg);
border: var(--nv-border-width) solid var(--nv-material-solid-border);
box-shadow: var(--nv-material-solid-shadow);
backdrop-filter: none;
}
.nv-material-elevated {
background-color: var(--nv-material-elevated-bg);
border: var(--nv-border-width) solid var(--nv-material-elevated-border);
box-shadow: var(--nv-material-elevated-shadow);
backdrop-filter: none;
}
.nv-material-inset {
background-color: var(--nv-material-inset-bg);
border: var(--nv-border-width) solid var(--nv-material-inset-border);
box-shadow: var(--nv-material-inset-shadow);
backdrop-filter: none;
}
.nv-material-frosted {
background-color: var(--nv-material-frosted-bg);
border: var(--nv-border-width) solid var(--nv-material-frosted-border);
box-shadow: var(--nv-material-frosted-shadow);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
.nv-material-glass {
background-color: var(--nv-material-glass-bg);
border: var(--nv-border-width) solid var(--nv-material-glass-border);
box-shadow: var(--nv-material-glass-shadow);
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Elevation -------------------------------------------------------------
Elevation utilities carry three things at once, because a lifted surface is
never only a shadow: the shadow itself, the highlight along its top edge,
and — on a dark canvas, where a shadow has nothing left to darken — a tint
that lets more light reach the surface the higher it sits. The tint is
transparent in light appearance, so the two read as one scale. */
.nv-elevation-0 { box-shadow: var(--nv-elevation-0); }
.nv-elevation-1 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
background-image: linear-gradient(var(--nv-color-elevation-tint-1) 0 0);
}
.nv-elevation-2 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
background-image: linear-gradient(var(--nv-color-elevation-tint-2) 0 0);
}
.nv-elevation-3 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
background-image: linear-gradient(var(--nv-color-elevation-tint-3) 0 0);
}
.nv-elevation-4 {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-4);
background-image: linear-gradient(var(--nv-color-elevation-tint-4) 0 0);
}
.nv-elevation-5 {
box-shadow: var(--nv-highlight-top-strong), var(--nv-elevation-5);
background-image: linear-gradient(var(--nv-color-elevation-tint-5) 0 0);
}
.nv-elevation-inset-sm { box-shadow: var(--nv-elevation-inset-sm); }
.nv-elevation-inset-md { box-shadow: var(--nv-elevation-inset-md); }
.nv-elevation-inset-lg { box-shadow: var(--nv-elevation-inset-lg); }
/* --- Divider ---------------------------------------------------------------- */
.nv-divider {
border: 0;
border-top: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block: var(--nv-space-4);
}
.nv-divider--vertical {
border-top: 0;
border-left: var(--nv-border-width) solid var(--nv-color-border-subtle);
margin-block: 0;
margin-inline: var(--nv-space-4);
align-self: stretch;
}

View file

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

View file

@ -0,0 +1,209 @@
/* ==========================================================================
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 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.
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;
}
::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. */
::view-transition-old(nv-content) {
animation: nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both;
}
::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;
}
:root[data-nv-direction="forward"]::view-transition-old(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;
}
:root[data-nv-direction="forward"]::view-transition-new(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;
}
:root[data-nv-direction="backward"]::view-transition-old(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;
}
:root[data-nv-direction="backward"]::view-transition-new(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;
}
@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);
}
/* 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;
}
}

View file

@ -0,0 +1,59 @@
/* ==========================================================================
Niveus — a calm, adaptive interface language.
Made by Nexoniarz.
--------------------------------------------------------------------------
The single entry point. An application links this file and nothing else:
<link rel="stylesheet" href="niveus/niveus.css">
<script src="niveus/niveus.js"></script> — optional, see below
<html data-theme="dark"> — or omit data-theme to follow the system
niveus.js is not required for anything to render. It exists only so state
changes can be *continuous*: it wraps an update in a view transition so the
browser can animate between the two states. Without it, every component
still works — changes simply arrive rather than travel.
Everything ships inside the `niveus` cascade layer, so unlayered application
styles always win without needing !important or selector escalation. The
sub-layers are ordered below; that statement is what fixes their precedence,
independently of the order the files happen to load in.
========================================================================== */
@layer niveus.tokens, niveus.theme, niveus.base, niveus.continuity,
niveus.components, niveus.utilities;
/* --- Tokens: the values, appearance-agnostic ------------------------------- */
@import url("tokens/color.css") layer(niveus.tokens);
@import url("tokens/typography.css") layer(niveus.tokens);
@import url("tokens/spacing.css") layer(niveus.tokens);
@import url("tokens/radius.css") layer(niveus.tokens);
@import url("tokens/elevation.css") layer(niveus.tokens);
@import url("tokens/motion.css") layer(niveus.tokens);
@import url("tokens/materials.css") layer(niveus.tokens);
/* --- Themes: what those values become in each appearance -------------------- */
@import url("themes/light.css") layer(niveus.theme);
@import url("themes/dark.css") layer(niveus.theme);
/* --- Base: elements, before any component ----------------------------------- */
@import url("base.css") layer(niveus.base);
/* --- Continuity: how one state becomes the next ----------------------------- */
@import url("continuity.css") layer(niveus.continuity);
/* --- Components -------------------------------------------------------------- */
@import url("components/surface.css") layer(niveus.components);
@import url("components/button.css") layer(niveus.components);
@import url("components/input.css") layer(niveus.components);
@import url("components/card.css") layer(niveus.components);
@import url("components/dialog.css") layer(niveus.components);
@import url("components/navigation.css") layer(niveus.components);
@import url("components/menu.css") layer(niveus.components);
@import url("components/disclosure.css") layer(niveus.components);
@import url("components/scroller.css") layer(niveus.components);
@import url("components/bed.css") layer(niveus.components);
@import url("components/badge.css") layer(niveus.components);
@import url("components/table.css") layer(niveus.components);
/* --- Utilities ----------------------------------------------------------------- */
@import url("utilities.css") layer(niveus.utilities);

View file

@ -0,0 +1,322 @@
/* ==========================================================================
Niveus — continuity helpers
--------------------------------------------------------------------------
Optional. Nothing in Niveus needs this file to render; every component works
without it. What it adds is continuity for the changes CSS cannot animate on
its own — a selection moving to a different element, a panel replacing
another, the whole surface changing appearance. All three are the same
problem: two states that exist at different moments, with no single element
transitioning between them. A view transition is the browser's answer, and
this file is the small amount of wiring it needs.
Everything here is a thin wrapper. An application that already owns its
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.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
Markup wiring, applied automatically on load:
[data-nv-theme="dark"] button that sets the appearance
[data-nv-dialog="id"] button that opens a dialog from itself
[data-nv-tabs] tablist whose [role=tab] children switch
their aria-controls panels
[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
Wiring also names every navigation item, which is what lets a travelling
selection marker pass *behind* the labels rather than over them.
Reduced motion is honoured by skipping the view transition entirely: the
update still runs, it simply arrives instead of travelling.
========================================================================== */
(function (global) {
"use strict";
var root = document.documentElement;
function prefersReducedMotion() {
return global.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function canTransition() {
return typeof document.startViewTransition === "function" && !prefersReducedMotion();
}
/**
* 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.
*/
function transition(update, options) {
options = options || {};
if (!canTransition()) {
return Promise.resolve(update());
}
if (options.direction) root.dataset.nvDirection = options.direction;
if (options.kind) root.dataset.nvTransition = options.kind;
var view = document.startViewTransition(update);
function clear() {
delete root.dataset.nvDirection;
delete root.dataset.nvTransition;
}
view.finished.then(clear, clear);
return view.finished;
}
/** "light" | "dark" | "system" — system means: follow the environment. */
function setTheme(theme) {
var done = transition(function () {
if (theme === "system") {
root.removeAttribute("data-theme");
} else {
root.setAttribute("data-theme", theme);
}
syncThemeControls(theme);
}, { kind: "theme" });
root.dispatchEvent(new CustomEvent("nv:theme", { detail: { theme: theme } }));
return done;
}
function syncThemeControls(theme) {
var controls = document.querySelectorAll("[data-nv-theme]");
for (var i = 0; i < controls.length; i++) {
controls[i].setAttribute("aria-pressed", String(controls[i].dataset.nvTheme === theme));
}
}
/**
* Open a <dialog> so it appears to come from `trigger`: the entrance scales
* away from the trigger's centre instead of from the middle of the screen.
*/
function openDialog(dialog, trigger) {
if (typeof dialog === "string") dialog = document.getElementById(dialog);
if (!dialog) return null;
dialog.showModal();
if (trigger) {
// showModal() has already laid the dialog out, so both boxes are final.
var from = trigger.getBoundingClientRect();
var to = dialog.getBoundingClientRect();
// A trigger far outside the viewport — one the page has scrolled past —
// would otherwise put the origin hundreds of pixels away and turn a
// 3% scale into a lunge across the screen. One dialog-length in any
// direction is enough to say where it came from.
var clamp = function (value, size) {
return Math.max(-size, Math.min(value, size * 2));
};
dialog.style.setProperty(
"--nv-origin",
clamp(from.left + from.width / 2 - to.left, to.width).toFixed(1) + "px " +
clamp(from.top + from.height / 2 - to.top, to.height).toFixed(1) + "px"
);
} else {
dialog.style.removeProperty("--nv-origin");
}
return dialog;
}
function tabsOf(tablist) {
return Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]'));
}
/** Move the selection, and the panel, in the direction the user is going. */
function selectTab(tablist, tab) {
var tabs = tabsOf(tablist);
var next = tabs.indexOf(tab);
var current = -1;
for (var i = 0; i < tabs.length; i++) {
if (tabs[i].getAttribute("aria-selected") === "true") current = i;
}
if (next < 0 || next === current) return Promise.resolve();
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"));
if (panel) panel.hidden = !selected;
});
}, { direction: next > current ? "forward" : "backward" });
tab.focus();
return done;
}
function initTabs(tablist) {
tablist.addEventListener("click", function (event) {
var tab = event.target.closest('[role="tab"]');
if (tab && tablist.contains(tab)) {
event.preventDefault();
selectTab(tablist, tab);
}
});
// The ARIA tabs pattern: arrows move selection, Home and End jump to the
// ends, and only the selected tab is in the tab order.
tablist.addEventListener("keydown", function (event) {
var tabs = tabsOf(tablist);
var index = tabs.indexOf(document.activeElement);
if (index < 0) return;
var vertical = tablist.getAttribute("aria-orientation") === "vertical";
var back = vertical ? "ArrowUp" : "ArrowLeft";
var forward = vertical ? "ArrowDown" : "ArrowRight";
var target = null;
if (event.key === back) target = tabs[(index - 1 + tabs.length) % tabs.length];
else if (event.key === forward) target = tabs[(index + 1) % tabs.length];
else if (event.key === "Home") target = tabs[0];
else if (event.key === "End") target = tabs[tabs.length - 1];
if (target) {
event.preventDefault();
selectTab(tablist, target);
}
});
}
/**
* Wire a scroller's prev/next controls. Scrolling itself is the browser's;
* this only gives pointers without a horizontal axis somewhere to click, and
* hides a control once there is nothing left in that direction.
*/
function initScroller(frame) {
var track = frame.querySelector(".nv-scroller");
if (!track) return;
var controls = frame.querySelectorAll("[data-nv-scroll]");
function step() {
var item = track.firstElementChild;
if (!item) return track.clientWidth * 0.8;
var gap = parseFloat(getComputedStyle(track).columnGap) || 0;
return item.getBoundingClientRect().width + gap;
}
function update() {
// A sub-pixel slack, or a fractional scrollWidth leaves a control
// enabled at an end it cannot move past.
var atStart = track.scrollLeft <= 1;
var atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
controls.forEach(function (control) {
control.disabled = control.dataset.nvScroll === "prev" ? atStart : atEnd;
});
// The same two facts decide which edge is allowed to fade.
frame.toggleAttribute("data-nv-at-start", atStart);
frame.toggleAttribute("data-nv-at-end", atEnd);
}
controls.forEach(function (control) {
control.addEventListener("click", function () {
var direction = control.dataset.nvScroll === "next" ? 1 : -1;
track.scrollBy({ left: direction * step(), behavior: "smooth" });
});
});
track.addEventListener("scroll", update, { passive: true });
if (typeof ResizeObserver === "function") new ResizeObserver(update).observe(track);
update();
}
/**
* 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.
*
* 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.
*/
var carried = 0;
function liftLabels(scope) {
var groups = scope.querySelectorAll(".nv-nav, .nv-nav-tabs, [data-nv-travel]");
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;
}
}
}
function wire(scope) {
scope = scope || document;
scope.querySelectorAll("[data-nv-theme]").forEach(function (control) {
control.addEventListener("click", function () {
setTheme(control.dataset.nvTheme);
});
});
scope.querySelectorAll("[data-nv-dialog]").forEach(function (trigger) {
trigger.addEventListener("click", function () {
openDialog(trigger.dataset.nvDialog, trigger);
});
});
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
liftLabels(scope);
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
control.addEventListener("click", function () {
var owner = control.closest("dialog, [popover]");
if (!owner) return;
if (owner.tagName === "DIALOG") owner.close();
else owner.hidePopover();
});
});
// A dialog closes when the scrim is clicked, but not when a drag merely
// ends there.
scope.querySelectorAll("dialog.nv-dialog").forEach(function (dialog) {
dialog.addEventListener("mousedown", function (event) {
if (event.target === dialog) dialog.close();
});
});
}
global.nv = {
transition: transition,
setTheme: setTheme,
syncThemeControls: syncThemeControls,
openDialog: openDialog,
selectTab: selectTab,
initScroller: initScroller,
liftLabels: liftLabels,
wire: wire,
prefersReducedMotion: prefersReducedMotion,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { wire(); });
} else {
wire();
}
})(window);

View file

@ -0,0 +1,120 @@
/* ==========================================================================
Niveus — Dark appearance
--------------------------------------------------------------------------
Values for the dark side of every semantic role, resolved through
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.
========================================================================== */
:root[data-theme="dark"] {
color-scheme: dark;
}
: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);
/* --- 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-inverted: var(--nv-palette-neutral-975);
/* --- Borders ------------------------------------------------------------ */
--nv-dark-border-subtle: rgb(255 255 255 / 0.06);
--nv-dark-border: rgb(255 255 255 / 0.1);
--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);
--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-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);
--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-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-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);
/* --- 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);
--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-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-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);
--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-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-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);
/* 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-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);
/* --- 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);
}

View file

@ -0,0 +1,158 @@
/* ==========================================================================
Niveus — Light appearance
--------------------------------------------------------------------------
Values for the light side of every semantic role. Nothing here is consumed
by components directly; tokens/color.css resolves these through light-dark().
Almost nothing below is a literal color. Each role is *derived* from
--nv-brand, so setting one value re-colours the entire system: the accent
and its states, what text goes on top of it, how the status colors lean, and
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.
========================================================================== */
:root {
/* Follow the environment unless the application states an appearance. */
color-scheme: light dark;
}
:root[data-theme="light"] {
color-scheme: light;
}
/* [data-nv-brand] re-derives the whole set for a subtree, so one region can
carry a different brand without a second theme. */
:root,
[data-nv-brand] {
/* --- Canvas and surfaces -----------------------------------------------
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);
/* --- Text ---------------------------------------------------------------
Primary text stays neutral — it carries the reading contrast. The quieter
two lean towards the brand, where a tint reads as warmth rather than as a
loss of legibility. */
--nv-light-text-primary: var(--nv-palette-neutral-900);
--nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%);
--nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%);
--nv-light-text-disabled: var(--nv-palette-neutral-400);
--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);
/* --- Accent --------------------------------------------------------------
Every filled color is pushed away from the middle of the lightness range
before it is used. A mid-tone is the one place where neither white nor
black text reaches 4.5:1, so a brand that lands there is darkened until
white works — or, if it is already light, lightened until black does. The
brand's hue and chroma are untouched; only its lightness moves, and only
as far as it has to. */
--nv-light-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-light-accent-hover: oklch(from var(--nv-light-accent) calc(l - 0.045) c h);
--nv-light-accent-active: oklch(from var(--nv-light-accent) calc(l - 0.09) c h);
--nv-light-accent-subtle: oklch(from var(--nv-light-accent) 0.965 calc(c * 0.16) h);
--nv-light-accent-subtle-hover: oklch(from var(--nv-light-accent) 0.93 calc(c * 0.22) h);
--nv-light-accent-border: oklch(from var(--nv-light-accent) l c h / 0.32);
--nv-light-accent-text: oklch(from var(--nv-light-accent) clamp(0.3, calc(l - 0.12), 0.5) c h);
/* Text that lands on the color itself. The clamp is a step function: below
the lightness threshold it resolves to 1 and the text goes near-white,
above it to 0 and the text goes near-black — so a yellow brand gets dark
labels and a navy one gets light labels, with no second token to set. */
--nv-light-on-accent: oklch(from var(--nv-light-accent)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Success ------------------------------------------------------------- */
--nv-light-success: oklch(from
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-light-success-hover: oklch(from var(--nv-light-success) calc(l - 0.05) c h);
--nv-light-success-subtle: oklch(from var(--nv-light-success) 0.965 calc(c * 0.16) h);
--nv-light-success-border: oklch(from var(--nv-light-success) l c h / 0.3);
--nv-light-success-text: oklch(from var(--nv-light-success) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-success: oklch(from var(--nv-light-success)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Warning -------------------------------------------------------------- */
/* Amber starts above the pivot on purpose. Pushed the other way it lands as
brown, which reads as neither a warning nor a color anyone chose. */
--nv-light-warning: oklch(from
color-mix(in oklab, var(--nv-palette-warning-400), 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-light-warning-hover: oklch(from var(--nv-light-warning) calc(l - 0.05) c h);
--nv-light-warning-subtle: oklch(from var(--nv-light-warning) 0.965 calc(c * 0.16) h);
--nv-light-warning-border: oklch(from var(--nv-light-warning) l c h / 0.32);
--nv-light-warning-text: oklch(from var(--nv-light-warning) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-warning: oklch(from var(--nv-light-warning)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Danger ---------------------------------------------------------------- */
--nv-light-danger: oklch(from
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-light-danger-hover: oklch(from var(--nv-light-danger) calc(l - 0.05) c h);
--nv-light-danger-active: oklch(from var(--nv-light-danger) calc(l - 0.1) c h);
--nv-light-danger-subtle: oklch(from var(--nv-light-danger) 0.965 calc(c * 0.16) h);
--nv-light-danger-border: oklch(from var(--nv-light-danger) l c h / 0.3);
--nv-light-danger-text: oklch(from var(--nv-light-danger) clamp(0.3, calc(l - 0.1), 0.48) c h);
--nv-light-on-danger: oklch(from var(--nv-light-danger)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- 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-highlight-strong: rgb(255 255 255 / 1);
/* 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;
--nv-light-elevation-tint-2: transparent;
--nv-light-elevation-tint-3: transparent;
--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);
/* --- Interaction ----------------------------------------------------------- */
--nv-light-focus-ring: oklch(from var(--nv-light-accent) l c h / 0.4);
--nv-light-focus-ring-contrast: rgb(255 255 255 / 0.9);
--nv-light-selection: oklch(from var(--nv-light-accent) l c h / 0.2);
}

View file

@ -0,0 +1,218 @@
/* ==========================================================================
Niveus — Color
--------------------------------------------------------------------------
Two levels of tokens live here:
1. Palette Raw, appearance-agnostic values. Never referenced by
components. Prefixed --nv-palette-*.
2. Semantic The roles components actually consume (background, surface,
text-primary, accent, danger, …). Each role resolves with
light-dark(), pulling its value from the matching appearance
file: themes/light.css (--nv-light-*) or themes/dark.css
(--nv-dark-*).
The appearance is chosen by `color-scheme`, which themes/*.css bind to the
[data-theme] attribute. A component therefore never knows — and must never
ask — which appearance is active.
========================================================================== */
/* ==========================================================================
Brand
--------------------------------------------------------------------------
The whole color system has one input. Set --nv-brand and the accent, its
hover and active states, the text that lands on it, the focus ring, the
selection highlight, the tint in the greys and the lean of the status colors
are all re-derived from it — in both appearances, with no second value to
keep in sync.
:root { --nv-brand: #d92e3f; }
Set it on any element with [data-nv-brand] to re-brand a subtree.
========================================================================== */
:root {
--nv-brand: #4f6cff;
/* How much of the brand bleeds into the greys. Enough that the neutrals
belong to the same family as the accent; not enough to read as colored.
0% gives strictly neutral greys. */
--nv-brand-tint: 4%;
/* How far the status colors lean towards the brand. Red still reads as
danger and green still reads as success — they just stop looking borrowed
from another system. 0% keeps them literal. */
--nv-brand-harmony: 12%;
/* Where "too mid-toned to carry a label" begins, and how far either side a
color is pushed to escape it. Raising the pivot sends more brands dark
(white labels); lowering it sends more of them light (dark labels). */
--nv-contrast-pivot: 0.7;
--nv-contrast-dark: 0.53;
--nv-contrast-light: 0.78;
}
:root {
/* --- Neutral ---------------------------------------------------------- */
--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;
/* --- Accent (indigo) --------------------------------------------------- */
--nv-palette-accent-50: #eef2ff;
--nv-palette-accent-100: #dfe6ff;
--nv-palette-accent-200: #c4d0ff;
--nv-palette-accent-300: #9fb3ff;
--nv-palette-accent-400: #7691ff;
--nv-palette-accent-500: #4f6cff;
--nv-palette-accent-600: #3f5cf0;
--nv-palette-accent-700: #3450d6;
--nv-palette-accent-800: #2a3fa8;
--nv-palette-accent-900: #22347f;
/* --- Success (green) --------------------------------------------------- */
--nv-palette-success-50: #e8f9ef;
--nv-palette-success-100: #ccf0dc;
--nv-palette-success-200: #9ee2bc;
--nv-palette-success-300: #62cd96;
--nv-palette-success-400: #33b478;
--nv-palette-success-500: #189a60;
--nv-palette-success-600: #0f7d4d;
--nv-palette-success-700: #0c6440;
--nv-palette-success-800: #0a5034;
--nv-palette-success-900: #08412b;
/* --- Warning (amber) --------------------------------------------------- */
--nv-palette-warning-50: #fff6e6;
--nv-palette-warning-100: #ffe9c2;
--nv-palette-warning-200: #ffd68d;
--nv-palette-warning-300: #fbbe52;
--nv-palette-warning-400: #eda520;
--nv-palette-warning-500: #cf8809;
--nv-palette-warning-600: #a96d05;
--nv-palette-warning-700: #855505;
--nv-palette-warning-800: #6a4406;
--nv-palette-warning-900: #563806;
/* --- Danger (red) ------------------------------------------------------ */
--nv-palette-danger-50: #ffeff1;
--nv-palette-danger-100: #ffdbde;
--nv-palette-danger-200: #ffb9bf;
--nv-palette-danger-300: #ff8b96;
--nv-palette-danger-400: #f95b6c;
--nv-palette-danger-500: #e2334a;
--nv-palette-danger-600: #c01f36;
--nv-palette-danger-700: #9c172b;
--nv-palette-danger-800: #7e1524;
--nv-palette-danger-900: #67141f;
}
/* ==========================================================================
Semantic roles
========================================================================== */
/* The subtree selector has to be repeated here, not just on the theme files:
var() inside a custom property is substituted where that property is
*declared*, so a --nv-color-* declared only on :root would bake in the root
brand and inherit the result unchanged. Re-declaring on [data-nv-brand]
re-runs the substitution against the subtree's own brand. */
:root,
[data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */
--nv-color-background: light-dark(var(--nv-light-background), var(--nv-dark-background));
--nv-color-background-subtle: light-dark(var(--nv-light-background-subtle), var(--nv-dark-background-subtle));
--nv-color-surface: light-dark(var(--nv-light-surface), var(--nv-dark-surface));
--nv-color-surface-elevated: light-dark(var(--nv-light-surface-elevated), var(--nv-dark-surface-elevated));
--nv-color-surface-inset: light-dark(var(--nv-light-surface-inset), var(--nv-dark-surface-inset));
--nv-color-surface-hover: light-dark(var(--nv-light-surface-hover), var(--nv-dark-surface-hover));
--nv-color-surface-active: light-dark(var(--nv-light-surface-active), var(--nv-dark-surface-active));
--nv-color-scrim: light-dark(var(--nv-light-scrim), var(--nv-dark-scrim));
/* --- Text --------------------------------------------------------------- */
--nv-color-text-primary: light-dark(var(--nv-light-text-primary), var(--nv-dark-text-primary));
--nv-color-text-secondary: light-dark(var(--nv-light-text-secondary), var(--nv-dark-text-secondary));
--nv-color-text-tertiary: light-dark(var(--nv-light-text-tertiary), var(--nv-dark-text-tertiary));
--nv-color-text-disabled: light-dark(var(--nv-light-text-disabled), var(--nv-dark-text-disabled));
--nv-color-text-inverted: light-dark(var(--nv-light-text-inverted), var(--nv-dark-text-inverted));
/* --- Borders ------------------------------------------------------------ */
--nv-color-border-subtle: light-dark(var(--nv-light-border-subtle), var(--nv-dark-border-subtle));
--nv-color-border: light-dark(var(--nv-light-border), var(--nv-dark-border));
--nv-color-border-strong: light-dark(var(--nv-light-border-strong), var(--nv-dark-border-strong));
/* --- Accent ------------------------------------------------------------- */
--nv-color-accent: light-dark(var(--nv-light-accent), var(--nv-dark-accent));
--nv-color-accent-hover: light-dark(var(--nv-light-accent-hover), var(--nv-dark-accent-hover));
--nv-color-accent-active: light-dark(var(--nv-light-accent-active), var(--nv-dark-accent-active));
--nv-color-accent-subtle: light-dark(var(--nv-light-accent-subtle), var(--nv-dark-accent-subtle));
--nv-color-accent-subtle-hover: light-dark(var(--nv-light-accent-subtle-hover), var(--nv-dark-accent-subtle-hover));
--nv-color-accent-border: light-dark(var(--nv-light-accent-border), var(--nv-dark-accent-border));
--nv-color-accent-text: light-dark(var(--nv-light-accent-text), var(--nv-dark-accent-text));
--nv-color-on-accent: light-dark(var(--nv-light-on-accent), var(--nv-dark-on-accent));
/* --- Success ------------------------------------------------------------ */
--nv-color-success: light-dark(var(--nv-light-success), var(--nv-dark-success));
--nv-color-success-hover: light-dark(var(--nv-light-success-hover), var(--nv-dark-success-hover));
--nv-color-success-subtle: light-dark(var(--nv-light-success-subtle), var(--nv-dark-success-subtle));
--nv-color-success-border: light-dark(var(--nv-light-success-border), var(--nv-dark-success-border));
--nv-color-success-text: light-dark(var(--nv-light-success-text), var(--nv-dark-success-text));
--nv-color-on-success: light-dark(var(--nv-light-on-success), var(--nv-dark-on-success));
/* --- Warning ------------------------------------------------------------ */
--nv-color-warning: light-dark(var(--nv-light-warning), var(--nv-dark-warning));
--nv-color-warning-hover: light-dark(var(--nv-light-warning-hover), var(--nv-dark-warning-hover));
--nv-color-warning-subtle: light-dark(var(--nv-light-warning-subtle), var(--nv-dark-warning-subtle));
--nv-color-warning-border: light-dark(var(--nv-light-warning-border), var(--nv-dark-warning-border));
--nv-color-warning-text: light-dark(var(--nv-light-warning-text), var(--nv-dark-warning-text));
--nv-color-on-warning: light-dark(var(--nv-light-on-warning), var(--nv-dark-on-warning));
/* --- Danger ------------------------------------------------------------- */
--nv-color-danger: light-dark(var(--nv-light-danger), var(--nv-dark-danger));
--nv-color-danger-hover: light-dark(var(--nv-light-danger-hover), var(--nv-dark-danger-hover));
--nv-color-danger-active: light-dark(var(--nv-light-danger-active), var(--nv-dark-danger-active));
--nv-color-danger-subtle: light-dark(var(--nv-light-danger-subtle), var(--nv-dark-danger-subtle));
--nv-color-danger-border: light-dark(var(--nv-light-danger-border), var(--nv-dark-danger-border));
--nv-color-danger-text: light-dark(var(--nv-light-danger-text), var(--nv-dark-danger-text));
--nv-color-on-danger: light-dark(var(--nv-light-on-danger), var(--nv-dark-on-danger));
/* --- Depth and material ------------------------------------------------- */
--nv-color-shadow-ambient: light-dark(var(--nv-light-shadow-ambient), var(--nv-dark-shadow-ambient));
--nv-color-shadow-penumbra: light-dark(var(--nv-light-shadow-penumbra), var(--nv-dark-shadow-penumbra));
--nv-color-shadow-umbra: light-dark(var(--nv-light-shadow-umbra), var(--nv-dark-shadow-umbra));
--nv-color-shadow-inset: light-dark(var(--nv-light-shadow-inset), var(--nv-dark-shadow-inset));
--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));
/* 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. */
--nv-color-elevation-tint-1: light-dark(var(--nv-light-elevation-tint-1), var(--nv-dark-elevation-tint-1));
--nv-color-elevation-tint-2: light-dark(var(--nv-light-elevation-tint-2), var(--nv-dark-elevation-tint-2));
--nv-color-elevation-tint-3: light-dark(var(--nv-light-elevation-tint-3), var(--nv-dark-elevation-tint-3));
--nv-color-elevation-tint-4: light-dark(var(--nv-light-elevation-tint-4), var(--nv-dark-elevation-tint-4));
--nv-color-elevation-tint-5: light-dark(var(--nv-light-elevation-tint-5), var(--nv-dark-elevation-tint-5));
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
--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));
/* --- 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));
--nv-color-selection: light-dark(var(--nv-light-selection), var(--nv-dark-selection));
}

View file

@ -0,0 +1,74 @@
/* ==========================================================================
Niveus — Elevation
--------------------------------------------------------------------------
Depth is a scale, not a free choice. Each level describes one relationship
between a surface and what sits behind it:
0 flush painted on the canvas, no separation
1 resting cards, list rows, static panels
2 raised buttons, controls, anything that reacts to a pointer
3 floating popovers, menus, dropdowns, tooltips
4 lifted dialogs, sheets, detached panels
5 overlay the topmost layer, used sparingly
Every level is built from the same three shadow colors so depth stays
consistent between appearances: an ambient contact shadow that keeps the
edge crisp, a soft penumbra for the spread, and an umbra that only appears
once a surface is genuinely far from the canvas.
========================================================================== */
:root {
--nv-elevation-0: none;
--nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient),
0 1px 3px -1px var(--nv-color-shadow-penumbra);
--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);
--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);
--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);
--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);
/* --- 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);
--nv-elevation-inset-lg:
inset 0 2px 3px var(--nv-color-shadow-inset),
inset 0 6px 12px -3px var(--nv-color-shadow-inset);
/* --- Highlight -----------------------------------------------------------
The thin catch of light along the top edge of a raised surface. This is
what makes Niveus read as material rather than as a rectangle with a
shadow. Always paired with an elevation, never used alone. */
--nv-highlight-top: inset 0 1px 0 var(--nv-color-highlight);
--nv-highlight-top-strong: inset 0 1px 0 var(--nv-color-highlight-strong);
--nv-highlight-edge:
inset 0 1px 0 var(--nv-color-highlight),
inset 0 0 0 1px var(--nv-color-border-subtle);
/* --- Focus ---------------------------------------------------------------
A contrast ring sits under the accent ring so focus stays visible on both
light and dark surfaces, and on accent-filled controls. */
--nv-focus-ring-width: 3px;
--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);
}

View file

@ -0,0 +1,77 @@
/* ==========================================================================
Niveus — Materials
--------------------------------------------------------------------------
A material is a surface treatment: what it is made of, not where it sits.
Elevation answers "how far from the canvas"; material answers "what does
light do when it hits this".
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
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.
========================================================================== */
:root {
/* --- Blur ---------------------------------------------------------------- */
--nv-blur-sm: 8px;
--nv-blur-md: 16px;
--nv-blur-lg: 28px;
--nv-blur-xl: 44px;
/* Backdrops lose saturation when blurred; a touch of saturate() puts the
color back so frosted surfaces do not read as gray films. */
--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%);
/* --- Material recipes -----------------------------------------------------
Each recipe is the full set of properties that defines the material.
Applied by the .nv-material-* classes in components/surface.css. */
--nv-material-solid-bg: var(--nv-color-surface);
--nv-material-solid-border: var(--nv-color-border);
--nv-material-solid-shadow: var(--nv-elevation-0);
--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-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-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-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);
}
/* Where translucency cannot be honoured — reduced transparency, or a browser
without backdrop-filter — materials fall back to their solid equivalents
rather than becoming unreadable washes. */
@media (prefers-reduced-transparency: reduce) {
:root {
--nv-material-frosted-bg: var(--nv-color-surface-elevated);
--nv-material-glass-bg: var(--nv-color-surface-elevated);
--nv-backdrop-frosted: none;
--nv-backdrop-glass: none;
--nv-backdrop-scrim: none;
}
}
@supports not (backdrop-filter: blur(1px)) {
:root {
--nv-material-frosted-bg: var(--nv-color-surface-elevated);
--nv-material-glass-bg: var(--nv-color-surface-elevated);
}
}

View file

@ -0,0 +1,107 @@
/* ==========================================================================
Niveus — Motion
--------------------------------------------------------------------------
Motion explains state and spatial relationship. It is short enough to feel
immediate and eased enough to feel physical.
Rule of thumb: the smaller the element, the faster it moves. A button's
hover is 120ms; a dialog's entrance is 240ms.
========================================================================== */
:root {
/* --- Duration ------------------------------------------------------------ */
--nv-duration-instant: 0ms;
--nv-duration-fast: 120ms; /* hover, press, color change */
--nv-duration-quick: 160ms; /* small elements entering */
--nv-duration-normal: 220ms; /* popovers, menus, expansion */
--nv-duration-slow: 320ms; /* dialogs, sheets, large surfaces */
--nv-duration-slower: 480ms; /* full-screen transitions */
/* --- Easing ---------------------------------------------------------------
`standard` covers almost everything. Entrance decelerates into place, exit
accelerates away, and `spring` is reserved for confirmations — never for
anything a user triggers repeatedly. */
--nv-ease-standard: cubic-bezier(0.2, 0.05, 0, 1);
--nv-ease-entrance: cubic-bezier(0.05, 0.7, 0.1, 1);
--nv-ease-exit: cubic-bezier(0.3, 0, 0.8, 0.15);
--nv-ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1);
--nv-ease-linear: linear;
/* --- Continuity -------------------------------------------------------------
Durations for changes the user is meant to *follow*, not merely notice.
They are longer than an interaction response because the eye has further
to travel: emphasis crossing a navigation bar, a panel growing open, a
whole surface changing appearance. */
--nv-duration-travel: 260ms; /* emphasis moving from one element to another */
--nv-duration-expand: 280ms; /* content growing out of, or back into, place */
--nv-duration-theme: 400ms; /* the entire surface changing appearance */
/* Leaves quickly, lands softly. The right curve for something travelling a
distance the user is meant to follow with their eye. */
--nv-ease-emphasized: cubic-bezier(0.32, 0.04, 0.06, 1);
/* --- Composites ------------------------------------------------------------
`translate` and `scale` are listed rather than `transform`, because the
components animate the individual properties — a transition on `transform`
does not cover them, and the movement snaps. */
--nv-transition-colors:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-depth:
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-control:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard),
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
/* Containers: the same as depth, plus the colors, so a surface glides into
its new appearance when the theme changes instead of snapping. */
--nv-transition-surface:
color var(--nv-duration-fast) var(--nv-ease-standard),
background-color var(--nv-duration-fast) var(--nv-ease-standard),
border-color var(--nv-duration-fast) var(--nv-ease-standard),
box-shadow var(--nv-duration-fast) var(--nv-ease-standard),
translate var(--nv-duration-fast) var(--nv-ease-standard),
scale var(--nv-duration-fast) var(--nv-ease-standard);
--nv-transition-expand:
block-size var(--nv-duration-expand) var(--nv-ease-standard),
grid-template-rows var(--nv-duration-expand) var(--nv-ease-standard),
opacity var(--nv-duration-expand) var(--nv-ease-standard);
/* --- Displacement ----------------------------------------------------------
How far things move. Presses sink by 1px; surfaces enter from 8px away. */
--nv-lift-hover: -1px;
--nv-press-depth: 1px;
--nv-travel-sm: 4px;
--nv-travel-md: 8px;
--nv-travel-lg: 16px;
}
/* Reduced motion is a system-wide contract, not a per-component opt-in: cut
duration to near-zero and neutralise displacement, but keep transitions
alive so state changes still resolve rather than snapping mid-flight. */
@media (prefers-reduced-motion: reduce) {
:root {
--nv-duration-fast: 1ms;
--nv-duration-quick: 1ms;
--nv-duration-normal: 1ms;
--nv-duration-slow: 1ms;
--nv-duration-slower: 1ms;
--nv-duration-travel: 1ms;
--nv-duration-expand: 1ms;
--nv-duration-theme: 1ms;
--nv-ease-spring: var(--nv-ease-standard);
--nv-lift-hover: 0px;
--nv-press-depth: 0px;
--nv-travel-sm: 0px;
--nv-travel-md: 0px;
--nv-travel-lg: 0px;
}
}

View file

@ -0,0 +1,23 @@
/* ==========================================================================
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.
========================================================================== */
: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-full: 999rem; /* — pills, avatars, switches */
/* Nested radius: an inner surface inset by N should be N smaller than its
parent, or the corners read as off-center. */
--nv-radius-nested-md: calc(var(--nv-radius-lg) - var(--nv-space-2));
--nv-radius-nested-lg: calc(var(--nv-radius-xl) - var(--nv-space-3));
}

View file

@ -0,0 +1,54 @@
/* ==========================================================================
Niveus — Spacing and size
--------------------------------------------------------------------------
A 4px rhythm. The numeric suffix is the value in quarter-steps, so
--nv-space-4 is 16px and --nv-space-6 is 24px. Anything not on the scale is
application layout, not design-system spacing.
========================================================================== */
:root {
--nv-space-0: 0;
--nv-space-px: 1px;
--nv-space-0-5: 0.125rem; /* 2px */
--nv-space-1: 0.25rem; /* 4px */
--nv-space-1-5: 0.375rem; /* 6px */
--nv-space-2: 0.5rem; /* 8px */
--nv-space-2-5: 0.625rem; /* 10px */
--nv-space-3: 0.75rem; /* 12px */
--nv-space-4: 1rem; /* 16px */
--nv-space-5: 1.25rem; /* 20px */
--nv-space-6: 1.5rem; /* 24px */
--nv-space-7: 1.75rem; /* 28px */
--nv-space-8: 2rem; /* 32px */
--nv-space-10: 2.5rem; /* 40px */
--nv-space-12: 3rem; /* 48px */
--nv-space-14: 3.5rem; /* 56px */
--nv-space-16: 4rem; /* 64px */
--nv-space-20: 5rem; /* 80px */
--nv-space-24: 6rem; /* 96px */
--nv-space-32: 8rem; /* 128px */
/* --- Control sizing -------------------------------------------------------
Shared by every interactive control so a button, an input and a select
line up on the same row without per-component tuning. */
--nv-control-height-sm: 1.75rem; /* 28px */
--nv-control-height-md: 2.25rem; /* 36px */
--nv-control-height-lg: 2.75rem; /* 44px */
--nv-control-padding-sm: var(--nv-space-2-5);
--nv-control-padding-md: var(--nv-space-3);
--nv-control-padding-lg: var(--nv-space-4);
--nv-control-gap-sm: var(--nv-space-1-5);
--nv-control-gap-md: var(--nv-space-2);
--nv-control-gap-lg: var(--nv-space-2-5);
/* Minimum pointer target, independent of visual control height. */
--nv-target-min: 2.75rem; /* 44px */
/* --- Layout ---------------------------------------------------------------- */
--nv-layout-gutter: var(--nv-space-6);
--nv-layout-max-width: 72rem;
--nv-border-width: 1px;
--nv-border-width-strong: 2px;
}

View file

@ -0,0 +1,73 @@
/* ==========================================================================
Niveus — Typography
--------------------------------------------------------------------------
One type scale, used everywhere. Sizes are in rem so they respect the user's
browser font size; line heights are unitless so they inherit correctly.
========================================================================== */
:root {
/* --- Families ----------------------------------------------------------- */
--nv-font-family-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",
"Consolas", monospace;
/* --- Size scale ---------------------------------------------------------- */
--nv-font-size-2xs: 0.6875rem; /* 11px — dense metadata, never body text */
--nv-font-size-xs: 0.75rem; /* 12px */
--nv-font-size-sm: 0.8125rem; /* 13px */
--nv-font-size-md: 0.9375rem; /* 15px — default body size */
--nv-font-size-lg: 1.0625rem; /* 17px */
--nv-font-size-xl: 1.25rem; /* 20px */
--nv-font-size-2xl: 1.5rem; /* 24px */
--nv-font-size-3xl: 1.875rem; /* 30px */
--nv-font-size-4xl: 2.375rem; /* 38px */
--nv-font-size-5xl: 3rem; /* 48px */
/* --- Weight -------------------------------------------------------------- */
--nv-font-weight-regular: 400;
--nv-font-weight-medium: 500;
--nv-font-weight-semibold: 600;
--nv-font-weight-bold: 700;
/* --- Line height --------------------------------------------------------- */
--nv-line-height-tight: 1.18; /* display and large headings */
--nv-line-height-snug: 1.32; /* headings, single-line controls */
--nv-line-height-normal: 1.5; /* body copy */
--nv-line-height-relaxed: 1.68; /* long-form reading */
/* --- Letter spacing -------------------------------------------------------
Large text needs negative tracking to stay optically even; small caps-ish
labels need positive tracking to stay readable. */
--nv-letter-spacing-tighter: -0.022em;
--nv-letter-spacing-tight: -0.012em;
--nv-letter-spacing-normal: 0em;
--nv-letter-spacing-wide: 0.02em;
--nv-letter-spacing-wider: 0.06em;
/* --- Composite roles ------------------------------------------------------
Applied as classes in base.css (.nv-display, .nv-heading, …). Kept as
tokens so applications can compose their own elements from the same
values instead of guessing. */
--nv-font-display: var(--nv-font-weight-semibold) var(--nv-font-size-4xl) /
var(--nv-line-height-tight) var(--nv-font-family-sans);
--nv-font-heading: var(--nv-font-weight-semibold) var(--nv-font-size-2xl) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-title: var(--nv-font-weight-semibold) var(--nv-font-size-lg) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-body: var(--nv-font-weight-regular) var(--nv-font-size-md) /
var(--nv-line-height-normal) var(--nv-font-family-sans);
--nv-font-label: var(--nv-font-weight-medium) var(--nv-font-size-sm) /
var(--nv-line-height-snug) var(--nv-font-family-sans);
--nv-font-caption: var(--nv-font-weight-regular) var(--nv-font-size-xs) /
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);
/* --- Measure --------------------------------------------------------------- */
--nv-measure-narrow: 44ch;
--nv-measure: 68ch;
--nv-measure-wide: 82ch;
}

View file

@ -0,0 +1,64 @@
/* ==========================================================================
Niveus — Utilities
--------------------------------------------------------------------------
A deliberately short list. Utilities exist for the handful of patterns that
appear in every application and have exactly one correct implementation —
not as a general-purpose styling language. Anything that needs a second
utility to look right is a component.
========================================================================== */
/* Content for assistive technology only. Not display:none, which removes it
from the accessibility tree as well. */
.nv-visually-hidden:not(:focus):not(:active) {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.nv-container {
inline-size: 100%;
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
/* Vertical rhythm between siblings, on the spacing scale. */
.nv-stack {
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
}
.nv-stack--tight { gap: var(--nv-space-2); }
.nv-stack--loose { gap: var(--nv-space-6); }
/* A row that wraps instead of overflowing. */
.nv-cluster {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--nv-space-3);
}
.nv-cluster--tight { gap: var(--nv-space-2); }
.nv-cluster--between { justify-content: space-between; }
.nv-truncate {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Long-form text at a readable measure. */
.nv-prose {
max-inline-size: var(--nv-measure);
line-height: var(--nv-line-height-relaxed);
color: var(--nv-color-text-secondary);
}

113
docs/niveus/tools/build.py Normal file
View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Bundle the Niveus sources into a single stylesheet.
`niveus/niveus.css` is the authored entry point and works as-is over HTTP: the
browser resolves the @import chain itself. That costs a request per file and,
more importantly, blocks rendering until the whole chain has loaded — so for
anything shipped, this script flattens the chain into `dist/niveus.css`.
Each `@import url("…") layer(name)` becomes a `@layer name { … }` block holding
the file's contents, which is exactly equivalent to what the browser does with
the import. Nothing is minified and nothing is reordered; the output is meant
to stay readable and to diff cleanly against the sources.
python3 tools/build.py [--check]
--check verifies dist/ is current without writing, for use in CI.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ENTRY = ROOT / "niveus" / "niveus.css"
OUTPUT = ROOT / "dist" / "niveus.css"
IMPORT = re.compile(
r"""@import\s+url\(\s*["']([^"']+)["']\s*\)\s*(?:layer\(\s*([^)]+?)\s*\))?\s*;""",
re.VERBOSE,
)
def indent(text: str, prefix: str = " ") -> str:
return "\n".join(prefix + line if line.strip() else line for line in text.split("\n"))
def bundle(path: Path, seen: set[Path] | None = None) -> str:
"""Inline every @import in `path`, depth first."""
seen = seen if seen is not None else set()
resolved = path.resolve()
if resolved in seen:
raise SystemExit(f"circular @import involving {resolved.relative_to(ROOT)}")
seen.add(resolved)
source = path.read_text(encoding="utf-8")
out: list[str] = []
cursor = 0
for match in IMPORT.finditer(source):
out.append(source[cursor : match.start()])
cursor = match.end()
target = (path.parent / match.group(1)).resolve()
if not target.is_file():
raise SystemExit(f"{path.relative_to(ROOT)}: cannot resolve {match.group(1)}")
# The import's own comment header is kept; it explains the section.
contents = bundle(target, seen).strip("\n")
layer = match.group(2)
if layer:
out.append(f"@layer {layer} {{\n{indent(contents)}\n}}")
else:
out.append(contents)
out.append(source[cursor:])
seen.discard(resolved)
return "".join(out)
def build() -> str:
header = (
"/* Niveus — generated bundle. Do not edit.\n"
" Source: niveus/niveus.css and the files it imports.\n"
" Rebuild: python3 tools/build.py\n"
"*/\n\n"
)
body = bundle(ENTRY)
# Collapse the runs of blank lines left behind by the inlined imports.
body = re.sub(r"\n{3,}", "\n\n", body).strip() + "\n"
return header + body
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="fail if dist/niveus.css differs from the sources",
)
args = parser.parse_args()
result = build()
if args.check:
current = OUTPUT.read_text(encoding="utf-8") if OUTPUT.is_file() else None
if current != result:
print("dist/niveus.css is out of date — run: python3 tools/build.py")
return 1
print("dist/niveus.css is up to date")
return 0
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(result, encoding="utf-8")
size = len(result.encode("utf-8"))
print(f"wrote {OUTPUT.relative_to(ROOT)} ({size:,} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())

255
docs/niveus/tools/check.py Normal file
View file

@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Check that the Niveus sources hold to their own rules.
Four things go wrong in a design system long before anything looks broken, and
all four are mechanical:
1. A semantic token is defined for one appearance but not the other, so a
role silently resolves to nothing in light or in dark.
2. A component references a token that does not exist — a typo that degrades
to "no style" instead of to an error.
3. A visual value is hard-coded in a component — or in an example, which
claims to be built from the system and nothing else — instead of coming
from a token, which is how a system stops being a single source of truth.
4. dist/ drifts from the sources.
This script catches the first three; `build.py --check` covers the fourth.
python3 tools/check.py
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "niveus"
DEFINITION = re.compile(r"(--[a-zA-Z0-9_-]+)\s*:")
REFERENCE = re.compile(r"var\(\s*(--[a-zA-Z0-9_-]+)")
COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
# A raw color or a raw shadow geometry in a component means the design language
# was restated locally instead of referenced.
HARD_CODED = [
(re.compile(r"#[0-9a-fA-F]{3,8}\b"), "hard-coded color"),
(re.compile(r"\b(?:rgb|rgba|hsl|hsla|oklch|lab)\(", re.IGNORECASE), "hard-coded color"),
]
# Properties the *application* supplies per instance, not the system. A menu
# and its trigger have to name the same anchor, and only the markup knows which
# pair belongs together.
CONSUMER_DEFINED = {"--nv-anchor"}
TOKEN_FILES = sorted((SRC / "tokens").glob("*.css")) + sorted((SRC / "themes").glob("*.css"))
CONSUMER_FILES = (
sorted((SRC / "components").glob("*.css"))
+ [SRC / "base.css", SRC / "utilities.css"]
)
def strip_comments(text: str) -> str:
return COMMENT.sub("", text)
def collect(paths: list[Path]) -> tuple[set[str], dict[str, list[tuple[Path, int, str]]]]:
"""Return every custom property defined across `paths`, and every reference."""
defined: set[str] = set()
references: dict[str, list[tuple[Path, int, str]]] = {}
for path in paths:
source = strip_comments(path.read_text(encoding="utf-8"))
for line_no, line in enumerate(source.split("\n"), start=1):
for name in DEFINITION.findall(line):
defined.add(name)
for name in REFERENCE.findall(line):
references.setdefault(name, []).append((path, line_no, line.strip()))
return defined, references
def rel(path: Path) -> str:
return str(path.relative_to(ROOT))
def check_theme_parity(failures: list[str]) -> None:
light = SRC / "themes" / "light.css"
dark = SRC / "themes" / "dark.css"
light_roles = {
name[len("--nv-light-") :]
for name in DEFINITION.findall(strip_comments(light.read_text(encoding="utf-8")))
if name.startswith("--nv-light-")
}
dark_roles = {
name[len("--nv-dark-") :]
for name in DEFINITION.findall(strip_comments(dark.read_text(encoding="utf-8")))
if name.startswith("--nv-dark-")
}
for role in sorted(light_roles - dark_roles):
failures.append(f"themes/dark.css: missing --nv-dark-{role} (light defines it)")
for role in sorted(dark_roles - light_roles):
failures.append(f"themes/light.css: missing --nv-light-{role} (dark defines it)")
if not failures:
print(f" theme parity: {len(light_roles)} roles defined in both appearances")
def check_references(failures: list[str]) -> None:
all_files = TOKEN_FILES + CONSUMER_FILES
defined, references = collect(all_files)
unresolved = []
for name, sites in sorted(references.items()):
if name.startswith("--_"):
continue # component-private, defined and used in the same rule
if name in CONSUMER_DEFINED:
continue
if name in defined:
continue
# var() with a fallback is an intentional escape hatch.
if all("," in line.split(f"var({name}")[1][:40] for _, _, line in sites):
continue
unresolved.append((name, sites[0]))
for name, (path, line_no, _) in unresolved:
failures.append(f"{rel(path)}:{line_no}: {name} is referenced but never defined")
if not unresolved:
print(f" references: {len(references)} tokens referenced, all resolve")
def check_hard_coded(failures: list[str]) -> None:
found = 0
for path in CONSUMER_FILES:
source = strip_comments(path.read_text(encoding="utf-8"))
for line_no, line in enumerate(source.split("\n"), start=1):
for pattern, label in HARD_CODED:
if pattern.search(line):
failures.append(f"{rel(path)}:{line_no}: {label} — {line.strip()}")
found += 1
if not found:
print(f" no hard-coded colors in {len(CONSUMER_FILES)} component and base files")
REFERENCE_DOC = ROOT / "REFERENCE.md"
EXAMPLES = ROOT / "examples"
# The one literal color an example is allowed: the input to the system.
BRAND_INPUT = re.compile(r"--nv-brand\s*:")
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE)
INLINE_STYLE = re.compile(r"""\sstyle=["']([^"']*)["']""")
def check_examples(failures: list[str]) -> None:
"""The examples claim to be built from the system and nothing else.
That claim is only worth making if it is enforced: an example that quietly
reaches for a hex value is no longer demonstrating the system, it is
demonstrating how to work around one. The single exception is --nv-brand,
which is the input the whole color system derives from — a literal there is
the point.
"""
if not EXAMPLES.is_dir():
return
scanned = 0
for path in sorted(EXAMPLES.rglob("*")):
if path.suffix not in {".css", ".html"}:
continue
scanned += 1
raw = path.read_text(encoding="utf-8")
if path.suffix == ".css":
regions = [(1, strip_comments(raw))]
else:
# Only the parts of an HTML file that can carry style.
regions = []
for match in STYLE_BLOCK.finditer(raw):
line = raw[: match.start()].count("\n") + 1
regions.append((line, strip_comments(match.group(1))))
for match in INLINE_STYLE.finditer(raw):
line = raw[: match.start()].count("\n") + 1
regions.append((line, match.group(1)))
for base_line, region in regions:
for offset, line in enumerate(region.split("\n")):
if BRAND_INPUT.search(line):
continue
for pattern, label in HARD_CODED:
if pattern.search(line):
failures.append(
f"{rel(path)}:{base_line + offset}: {label} in an example "
f"— {line.strip()[:70]}"
)
if path.name == "index.html" and "dist/niveus.css" not in raw:
failures.append(f"{rel(path)}: does not link the system stylesheet")
if not failures:
print(f" examples: {scanned} files, no color stated outside --nv-brand")
def check_reference(failures: list[str]) -> None:
"""Every class the CSS defines must appear in REFERENCE.md, spelled in full.
The reference claims to be the entire API — which is the only reason it is
safe to hand to someone, or to a model, instead of the source. A class that
exists but is undocumented breaks that claim silently, and a documented
class that no longer exists is worse: it reads as real.
"""
if not REFERENCE_DOC.is_file():
return
documented = REFERENCE_DOC.read_text(encoding="utf-8")
defined: set[str] = set()
sources = (
sorted((SRC / "components").glob("*.css"))
+ [SRC / "utilities.css", SRC / "base.css", SRC / "continuity.css"]
)
for path in sources:
source = strip_comments(path.read_text(encoding="utf-8"))
defined.update(re.findall(r"\.(nv-[a-z0-9_-]+)", source))
missing = sorted(name for name in defined if f".{name}" not in documented)
for name in missing:
failures.append(f"REFERENCE.md: .{name} is defined in the CSS but not documented")
# And the reverse: nothing documented that no longer exists.
mentioned = set(re.findall(r"`\.(nv-[a-z0-9_-]+)`", documented))
for name in sorted(mentioned - defined):
failures.append(f"REFERENCE.md: .{name} is documented but not defined anywhere")
if not missing:
print(f" reference: {len(defined)} classes, all documented")
def main() -> int:
failures: list[str] = []
print("Niveus checks")
check_theme_parity(failures)
check_references(failures)
check_hard_coded(failures)
check_examples(failures)
check_reference(failures)
if failures:
print(f"\n{len(failures)} problem(s):")
for failure in failures:
print(f" {failure}")
return 1
print("\nAll checks passed.")
return 0
if __name__ == "__main__":
sys.exit(main())