docs/Website: Niveus at upstream f340d56; fonts are binary

The vendored copy of Niveus, the site's design system, brought up to
the current upstream (github.com/Nexoniarz/Niveus, 2026-10-18). The
tree under docs/Website/niveus is upstream's byte for byte.

.gitattributes says `* text eol=lf` and listed the binary types it
knew, and woff2 was not one of them: Niveus's InstrumentSans-Italic
had a CR LF pair in its compressed data, and committing it turned that
into LF and the font into garbage. woff, woff2, otf, ico, gif, webp,
pdf and wasm are binary now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
This commit is contained in:
Nexoniarz 2026-10-23 15:10:40 +02:00
commit a8215149a3
56 changed files with 8139 additions and 279 deletions

11
.gitattributes vendored
View file

@ -16,3 +16,14 @@
*.tgz binary
*.zip binary
*.wad binary
# Fonts and the rest of what the website ships. `* text` above applies to
# anything not listed, and a compressed font with a 0x0D 0x0A in it had that
# pair "fixed" to 0x0A on commit -- a woff2 that no longer decoded.
*.woff binary
*.woff2 binary
*.otf binary
*.ico binary
*.gif binary
*.webp binary
*.pdf binary
*.wasm binary

1
docs/Website/niveus/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__/

201
docs/Website/niveus/LICENSE Normal file
View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the
purposes of this License, Derivative Works shall not include works
that remain separable from, or merely link (or bind by name) to the
interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the
original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing
the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,16 +1,28 @@
# Niveus
A calm, adaptive interface language built around clarity, depth, material and
consistency.
A tactile, adaptive interface language. Controls are bevelled keys with light
falling across them, fields are wells cut into the surface, windows are solid,
and glass is kept for the chrome that floats over everything else. Warm
graphite in dark, soft paper in light, one brand color driving both.
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">
<link rel="stylesheet" href="dist/niveus.css"> <!-- with fonts/ beside dist/ -->
```
Niveus ships its own faces in `fonts/`: Instrument Sans for the interface and
JetBrains Mono for code and engraved labels, both under the SIL Open Font
License. Copy `fonts/` alongside `dist/` (or `niveus/`) and they load by
themselves.
The stylesheet uses the variable `.woff2` files. `fonts/static/` holds the
same faces as plain TrueType files at the four weights Niveus uses (400, 500,
600, 700, plus Instrument Sans italics), for native toolkits and font
configurations that pick a weight by name rather than by variable axis.
```html
<html data-theme="dark"> <!-- omit data-theme to follow the system -->
```
@ -137,6 +149,27 @@ answers how far that surface is from the canvas. They are independent.
Applied as classes: `.nv-material-frosted`, `.nv-elevation-3`. A shadow that is
on neither scale is a shadow that means nothing.
### Light
Elevation is the shade a surface casts. **Glow** is the light it gives off —
and it is reserved for things that are *on*: a live status, the plan you are
on, a finished step, the selected item in a showcase. Glow never replaces an
elevation; a glowing surface still rests on something.
| Where | How |
| ----- | --- |
| A card that belongs to something with a color | `.nv-card--lit` — a lit top edge in `--nv-card-tint` |
| The one card that is on | `.nv-card--glow` |
| A showcase card | `.nv-card--spotlight` — light follows the pointer |
| A feature icon or product mark | `.nv-tile`, `--glow` to light it |
| A person | `.nv-avatar--glow` |
| A display line | `.nv-text-lit` — type cut from the same lit material |
Glows come in the same steps as everything else (`--nv-glow-sm/md/lg`), and a
tinted surface glows in its own tint, mixed down to `--nv-glow-strength`. Two
glowing things side by side cancel each other out: if everything is lit,
nothing is.
## Continuity
Every interaction should read as a continuation of the state before it. When
@ -170,6 +203,11 @@ 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.
Long pages can arrive in the order they were written: `.nv-reveal` rises into
place once as it scrolls into view, and `.nv-reveal-group` brings its children
in one after another, `--nv-stagger` apart. Only the script marks anything as
waiting, so without it — and under reduced motion — nothing is ever hidden.
### One rule worth knowing
A `view-transition-name` must be unique among all rendered elements, or the
@ -379,7 +417,7 @@ probably not the problem the system has.
| -------------- | --------------------------------------------------------- |
| `.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-card` | `--elevated` `--floating` `--inset` `--frosted` `--interactive`, light: `--lit` `--glow` `--spotlight`, `.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` |
@ -388,6 +426,12 @@ probably not the problem the system has.
| `.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-tile` | a small raised key holding one glyph; `--solid` `--glow`, `--sm` `--lg` `--xl` |
| `.nv-avatar` | round raised disc; `--ring` `--glow`, sizes, `__status`, `.nv-avatar-group` |
| `.nv-stat` | readings set into one recessed well with `.nv-stat-group`; `__label` `__value` `__delta` |
| `.nv-steps` | markers set into a rail; `data-state="done"` lights the way, `--vertical` |
| `.nv-deck` | one surface in front of others of its kind, with `__layer` |
| `.nv-skeleton` | a waiting well with a slow sweep of light; `--text` `--circle` |
| `.nv-surface` | materials and elevation as utilities |
Variants are defined by reassigning private `--_*` properties. Interaction —
@ -413,6 +457,14 @@ python3 tools/build.py --check # fail if dist/ is stale (for CI)
python3 tools/check.py # enforce the system's own rules
```
`tools/niveus_css.py` is not a command: it is an evaluator for the subset of
CSS the token layer is written in (`oklch(from …)`, `color-mix()`,
`light-dark()`, `calc()`), with CSS Color 4 gamut mapping, plus `read_rules()`
for the component layer (which role a button's background is in each state).
Every port that cannot read CSS — Android, Qt — imports it from here to
generate its tokens and control recipes, so no platform restates a value by
hand.
`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.
@ -435,9 +487,16 @@ to the state change simply happening rather than travelling:
---
> Niveus is not about making interfaces look physical. It is about making
> digital surfaces feel understandable.
> Niveus borrows from physical things only what makes a surface easier to
> understand: a key looks pressable, a well looks like a place to put
> something, and glass means something is floating.
---
## License
Niveus is licensed under the [Apache License 2.0](LICENSE). The fonts in
`fonts/` keep their own license, the SIL Open Font License 1.1
(`fonts/OFL-*.txt`), which allows bundling them with anything.
Made by **Nexoniarz**.

View file

@ -17,7 +17,7 @@ Everything ships inside the `niveus` cascade layer, so unlayered application
styles win without `!important`.
```html
<link rel="stylesheet" href="dist/niveus.css">
<link rel="stylesheet" href="dist/niveus.css"> <!-- keep fonts/ beside dist/ -->
<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 -->
@ -76,6 +76,9 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
| `.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--lit` | accent-edge card, colored top border | A strip of light along the top edge in the card's tint, spilling a little down the face. For a card that belongs to something with a color. |
| `.nv-card--glow` | glowing card, highlighted card | Gives off its tint. For the one card that is *on* — the current plan, the live item. |
| `.nv-card--spotlight` | spotlight card, cursor glow | A soft light follows the pointer across the face (needs `niveus.js`). Showcases only. |
| `.nv-card__header` | card header | |
| `.nv-card__title` | card title | |
| `.nv-card__subtitle` | card subtitle, supporting text | |
@ -86,6 +89,19 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
| `.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. |
`--lit`, `--glow` and `--spotlight` combine with every elevation and with each
other. They take `--nv-card-tint` (the accent unless set on the card).
## Deck
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-deck` | card stack, stacked cards | One surface in front of others of its kind. Every child shares one grid cell. |
| `.nv-deck__layer` | stack layer | A decorative card behind the front one, stepping back and dimming with depth. Up to two; mark them `aria-hidden="true"`. |
Hidden `.nv-tab-panel`s can live in a deck together: the front surface is
whichever one is showing.
## Dialog
Built on native `<dialog>`; open with `showModal()` or `nv.openDialog()`.
@ -136,7 +152,7 @@ the same anchor through `--nv-anchor`.
| `.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-tab-panel` | tab panel | Hands over to the next panel inside the same box when `nv.selectTab` switches tabs. |
| `.nv-breadcrumbs` | breadcrumbs | |
## Disclosure
@ -173,6 +189,61 @@ Set `--nv-scroller-item` for item width, `--nv-scroller-fade` for the edge fade.
| `.nv-badge--square` | square badge | |
| `.nv-badge__dot` | status dot | |
## Tile
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-tile` | icon tile, icon badge, feature icon | A small raised key holding one glyph: tinted face, glyph in its tint. Not interactive. |
| `.nv-tile--solid` | filled icon tile | Saturated face with a light glyph. |
| `.nv-tile--glow` | glowing icon tile | Lit from inside. |
| `.nv-tile--sm` `.nv-tile--lg` `.nv-tile--xl` | tile sizes | 32 / 56 / 72px; default 44px. |
`--nv-tile-tint` picks the color. An `<img>` inside fills the key, an `<svg>`
takes half of it.
## Avatar
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-avatar` | avatar, profile picture | Round raised disc holding an `<img>` or initials. |
| `.nv-avatar--ring` | avatar ring, story ring | A ring in `--nv-avatar-tint`, set off by a gap of surface. |
| `.nv-avatar--glow` | glowing avatar | The ring, giving off its tint. |
| `.nv-avatar--sm` `.nv-avatar--lg` `.nv-avatar--xl` | avatar sizes | 28 / 64 / 96px; default 40px. |
| `.nv-avatar__status` | presence dot | Grey at rest. |
| `.nv-avatar__status--online` | online indicator | Lit green. |
| `.nv-avatar-group` | avatar stack, facepile | Overlapping row. |
## Stat
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-stat-group` | stats row, KPI strip, readout | One recessed well holding several readings, split by hairlines. Wraps on its own. Use a `<dl>`. |
| `.nv-stat` | stat, metric, KPI | One reading: label, value, optional delta, in that order. |
| `.nv-stat__label` | metric label | `<dt>` inside a group. |
| `.nv-stat__value` | metric value, big number | `<dd>`; tabular numerals. |
| `.nv-stat__delta` | change, trend | |
| `.nv-stat__delta--up` `.nv-stat__delta--down` | positive / negative trend | Success and danger text. |
## Steps
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-steps` | stepper, timeline, process, progress steps | Markers set into a rail cut into the surface. Horizontal, vertical below 40rem. Use an `<ol>`. |
| `.nv-steps--vertical` | vertical stepper, timeline | Always vertical. |
| `.nv-steps__item` | step | `data-state="done"` lights it and its stretch of rail; `aria-current="step"` lights the marker only. |
| `.nv-steps__marker` | step number, step knob | |
| `.nv-steps__body` | step content | |
| `.nv-steps__title` | step title | |
| `.nv-steps__text` | step description | |
## Skeleton
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-skeleton` | skeleton, loading placeholder, shimmer | A shallow well where content will be, with a slow band of light crossing it. Still under reduced motion. |
| `.nv-skeleton--text` | text line placeholder | The last of several lines ends short. |
| `.nv-skeleton--circle` | avatar placeholder | |
## Table
| Class | Known as | Does |
@ -197,6 +268,25 @@ Set `--nv-scroller-item` for item width, `--nv-scroller-fade` for the edge fade.
Set `--nv-bed-tint` to lead with a color other than the accent.
## Window
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-window` | window, app frame | A framed, solid window. Content never sits on glass. |
| `.nv-window--inactive` | unfocused window | Greys the lights and the title, drops one elevation. Hovering the lights restores their color. |
| `.nv-window__titlebar` | titlebar, header bar | Raised strip: controls · title · actions, with the title on the window's center line. |
| `.nv-window__controls` | traffic lights, window buttons | Group at the start of the bar. Its glyphs show while the pointer is over the group. |
| `.nv-window__control` | window button | One light. Needs `aria-label`, plus one of the three below. |
| `.nv-window__control--close` | close button | Red, ×. Always first. |
| `.nv-window__control--minimize` | minimize button | Yellow, −. Always second. |
| `.nv-window__control--zoom` | zoom, maximize button | Green, +. Always third. |
| `.nv-window__title` | window title | Truncates. |
| `.nv-window__actions` | titlebar actions | Trailing slot; put `.nv-button--ghost.nv-button--sm` here. |
| `.nv-window__body` | window content | Fills the rest and scrolls. |
The three lights keep fixed colors on purpose: they are found by position and
color before they are read, so they never take the brand.
## Surface, material and elevation
| Class | Known as | Does |
@ -205,7 +295,7 @@ Set `--nv-bed-tint` to lead with a color other than the accent.
| `.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-frosted` | frosted glass, blur backdrop | Chrome that floats over content: bars, docks, launchers, notifications, menus. Moderate blur, bevelled rim. |
| `.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 |
@ -215,6 +305,14 @@ Set `--nv-bed-tint` to lead with a color other than the accent.
Material and elevation are independent: combine freely.
Niveus is solid, with glass used sparingly: anything that holds content
(windows, cards, dialogs) is solid, and only chrome floating over it is
frosted. Raised things are bevelled — lit along the top edge, shaded along the
bottom, with a contact outline — and carry a sheen across their face. When
composing your own raised element, use `box-shadow: var(--nv-bevel),
var(--nv-outline), var(--nv-elevation-2)` with `background-image:
var(--nv-sheen-raised)` over its role color.
## Type roles
| Class | Known as | Size |
@ -225,10 +323,19 @@ Material and elevation are independent: combine freely.
| `.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-overline` | overline, eyebrow, engraved label | 11px mono, uppercase, wide tracking |
| `.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 | |
| `.nv-text-lit` | gradient text, metallic text | Lit from above, settling into the accent. One display line per page. |
| `.nv-text-lit--accent` | accent gradient text | The accent alone, lit from above — for a phrase inside a display line. |
## Reveal
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-reveal` | scroll reveal, animate on scroll | Rises and fades in once, as it scrolls into view. Needs `niveus.js`; without it, or under reduced motion, nothing is hidden. |
| `.nv-reveal-group` | staggered reveal | Its children arrive one after another, `--nv-stagger` apart. |
## Layout utilities
@ -243,7 +350,8 @@ Material and elevation are independent: combine freely.
| `.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-continuous` | content region | Content that hands over in place (out, then in) when it changes. |
| `.nv-content` | view-transition class | Not an HTML class: the `view-transition-class` that gives a named element the hand-over animation. Set `view-transition-class: nv-content` on your own named element to get it. |
| `.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. |
@ -274,9 +382,14 @@ 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
highlight highlight-strong lowlight outline elevation-tint-1 … -5
sheen-top sheen-bottom sheen-strong-top sheen-strong-bottom
knob knob-shade
material-frosted material-glass material-border
window-close window-minimize window-zoom window-inactive
window-control-ring window-glyph
focus-ring focus-ring-contrast selection
glow
```
`on-*` is the text that goes on top of that color, and is derived — never set
@ -292,8 +405,9 @@ it by hand.
| `--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.
`--nv-palette-{neutral|accent|success|warning|danger}-{0…1000}` and
`--nv-palette-window-{close|minimize|zoom}` are raw values. Components must
never reference them.
### Scales
@ -304,24 +418,28 @@ Components must never reference them.
| `--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-letter-spacing-` | `tighter tight normal wide wider engraved` |
| `--nv-font-` | `display heading title body label caption code engraved` (composite shorthands) |
| `--nv-font-family-` | `sans` (Instrument Sans) `mono` (JetBrains Mono), both shipped in `fonts/` |
| `--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` |
| bevel | `--nv-bevel` (lit top + shaded bottom) · `--nv-lowlight-bottom` · `--nv-outline` (contact line) · `--nv-ledge` (lit lower lip of a recess) |
| `--nv-sheen-` | `raised filled surface knob` — gradients laid over a role color as `background-image` |
| `--nv-blur-` | `sm md lg xl` — also `backdrop-frosted` `backdrop-glass` `backdrop-scrim` |
| `--nv-material-` | `{solid,elevated,inset,frosted,glass}-{bg,border,shadow}` |
| `--nv-glow-` | `sm md lg` (accent halos) · `blur-sm/md/lg` `spread-sm/md/lg` (geometry for a tinted glow) · `strength` (how much tint a glow carries) |
### Motion
| Family | Steps |
| --- | --- |
| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme` |
| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme shimmer` |
| `--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` |
| sequence | `--nv-stagger` — the gap between siblings arriving one after another |
`prefers-reduced-motion` collapses every duration and displacement at the token
level. Components never opt in individually.
@ -332,7 +450,9 @@ level. Components never opt in individually.
`--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`
`--nv-scroller-fade` · `--nv-content-name` · `--nv-card-tint` ·
`--nv-tile-tint` · `--nv-avatar-tint` · `--nv-pointer-x` / `--nv-pointer-y`
(set by `niveus.js`) · `--nv-reveal-index` (set by `niveus.js`)
---
@ -343,14 +463,14 @@ 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.transition` | `(update, { direction, kind, scope }) => Promise` | Runs `update` inside a view transition so the change can be followed. `direction` is `"forward"`/`"backward"`; `kind: "theme"` slows the cross-fade; `scope` is the selection group (or list of groups) whose marker should travel — pass the group whose `aria-current`/`aria-selected` the update moves. Without `scope` nothing is lifted and the change cross-fades in place. 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.liftLabels` | `(scope) => Element[]` | Names the navigation items in `scope` so a travelling marker passes behind them, and returns them. `nv.transition` does this for its `scope` and removes the names afterwards. |
| `nv.wire` | `(scope = document) => void` | Applies all of the below, and starts watching `.nv-reveal` / `.nv-reveal-group`. Runs once on load; call again after injecting markup. |
| `nv.prefersReducedMotion` | `() => boolean` | |
### Data attributes
@ -367,19 +487,22 @@ everything still renders — state changes simply arrive instead of travelling.
| `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. |
| `data-nv-reveal` | revealed element | `pending` while waiting to scroll into view, `shown` while arriving; removed once it has. Set by `niveus.js`. |
| `data-nv-travelling` | selection group | Set by `nv.transition` on each `scope` for the length of the change; names its marker. |
---
## 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.
the browser skip the transition entirely. Markers are only named while their
group is in `scope`, so this only bites when one transition moves two groups
at once: give each its own `--nv-selection-name`. `.nv-nav-tabs` defaults
to `nv-tabs-selection` and everything else to `nv-selection`.
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.
3. **Hide inactive `.nv-tab-panel`s with `hidden`.** `nv.selectTab` relies
on it to know which panel is leaving.
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

4950
docs/Website/niveus/dist/niveus.css vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -404,7 +404,7 @@
.forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
render(item.dataset.thread);
});
}, { scope: list });
scroller.scrollTop = 0;
});
});

View file

@ -454,7 +454,7 @@
nv.transition(() => {
nav.querySelectorAll("[aria-current]").forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
});
}, { scope: nav });
});
/* The bar earns its shadow only once content has passed under it. */

View file

@ -560,7 +560,7 @@
nv.transition(() => {
sidebar.querySelectorAll("[aria-current]").forEach((el) => el.removeAttribute("aria-current"));
item.setAttribute("aria-current", "page");
});
}, { scope: sidebar });
});
document.querySelectorAll("[data-range]").forEach((option) => {

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,93 @@
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -240,12 +240,14 @@
<header class="hero">
<p class="nv-overline">Design system</p>
<h1 class="nv-display">Surfaces that feel understandable.</h1>
<h1 class="nv-display">Surfaces you could press.</h1>
<p class="nv-prose">
Niveus is a calm, adaptive interface language built on clarity, depth,
material and consistency. The same components carry their hierarchy from
light to dark, because nothing below is defined in terms of an
appearance — only in terms of a role.
Niveus is a tactile, adaptive interface language. Controls are bevelled
keys with light falling across them, fields are wells cut into the
surface, and glass is kept for the chrome that floats over everything
else. The same components carry their hierarchy from light to dark,
because nothing below is defined in terms of an appearance — only in
terms of a role.
</p>
<div class="demo-row">
<button class="nv-button nv-button--primary nv-button--lg" data-nv-dialog="welcome">
@ -513,7 +515,7 @@
</div>
<div class="type-row">
<span class="nv-overline">Overline</span>
<span class="type-row__meta">--nv-font-caption · uppercase</span>
<span class="type-row__meta">--nv-font-engraved · mono, uppercase</span>
</div>
</div>
</section>
@ -762,6 +764,58 @@
</div>
</div>
<div class="panel">
<h3 class="nv-title" style="margin-block-end: var(--nv-space-4)">Window</h3>
<div class="nv-bed nv-bed--quiet" style="display: grid; gap: var(--nv-space-6); grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr)); align-items: start">
<div class="nv-window">
<div class="nv-window__titlebar">
<div class="nv-window__controls">
<button class="nv-window__control nv-window__control--close" aria-label="Close"></button>
<button class="nv-window__control nv-window__control--minimize" aria-label="Minimize"></button>
<button class="nv-window__control nv-window__control--zoom" aria-label="Zoom"></button>
</div>
<span class="nv-window__title">Appearance</span>
<div class="nv-window__actions">
<button class="nv-button nv-button--ghost nv-button--sm nv-button--icon" aria-label="Search">
<svg class="nv-button__icon" 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="m20 20-4.2-4.2" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<div class="nv-window__body nv-stack" style="padding: var(--nv-space-5)">
<p class="nv-overline">Style</p>
<div class="nv-nav-tabs" role="tablist" aria-label="Appearance">
<button class="nv-nav__item" role="tab" aria-selected="false">Light</button>
<button class="nv-nav__item" role="tab" aria-selected="true">Dark</button>
<button class="nv-nav__item" role="tab" aria-selected="false">System</button>
</div>
<label class="nv-choice"><input class="nv-switch" type="checkbox" checked> Translucent chrome</label>
<label class="nv-choice"><input class="nv-switch" type="checkbox"> Reduce motion</label>
<div class="nv-field">
<label class="nv-field__label" for="win-wallpaper">Wallpaper</label>
<input class="nv-input" id="win-wallpaper" value="~/Pictures/dusk-ridge.jpg">
</div>
<div class="nv-cluster" style="justify-content: flex-end">
<button class="nv-button">Reset</button>
<button class="nv-button nv-button--primary">Apply</button>
</div>
</div>
</div>
<div class="nv-window nv-window--inactive">
<div class="nv-window__titlebar">
<div class="nv-window__controls">
<button class="nv-window__control nv-window__control--close" aria-label="Close"></button>
<button class="nv-window__control nv-window__control--minimize" aria-label="Minimize"></button>
<button class="nv-window__control nv-window__control--zoom" aria-label="Zoom"></button>
</div>
<span class="nv-window__title">Inactive — hover the lights</span>
</div>
<div class="nv-window__body" style="padding: var(--nv-space-5)">
<p class="nv-caption">A window without focus greys its lights and steps back one elevation. The lights take their color back the moment the pointer reaches them.</p>
</div>
</div>
</div>
</div>
<div class="panel">
<h3 class="nv-title" style="margin-block-end: var(--nv-space-4)">Dialog</h3>
<div class="demo-row">
@ -1315,7 +1369,7 @@
previous.removeAttribute("aria-current");
});
item.setAttribute("aria-current", "page");
});
}, { scope: group });
});
}

View file

@ -24,6 +24,14 @@ body {
margin: 0;
min-height: 100svh;
background-color: var(--nv-color-background);
/* The canvas is lit, not painted: a wash of the accent falls in from the top
corner and a fainter one pools at the opposite foot. It is what gives
translucent surfaces something to be translucent against, and keeps a
page of solid panels from reading as one flat field. */
background-image:
radial-gradient(120% 80% at 0% 0%, color-mix(in oklab, var(--nv-color-accent) light-dark(10%, 16%), transparent), transparent 60%),
radial-gradient(90% 70% at 100% 100%, color-mix(in oklab, var(--nv-color-accent) light-dark(6%, 10%), transparent), transparent 60%);
background-attachment: fixed;
color: var(--nv-color-text-primary);
font: var(--nv-font-body);
letter-spacing: var(--nv-letter-spacing-normal);
@ -175,10 +183,11 @@ textarea {
color: var(--nv-color-text-secondary);
}
/* Engraved: small mono capitals that mark a region the way markings mark an
instrument panel. Not for anything that has to be read as a sentence. */
.nv-overline {
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
font: var(--nv-font-engraved);
letter-spacing: var(--nv-letter-spacing-engraved);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
@ -195,3 +204,32 @@ textarea {
.nv-text-success { color: var(--nv-color-success-text); }
.nv-text-warning { color: var(--nv-color-warning-text); }
.nv-text-danger { color: var(--nv-color-danger-text); }
/* Lit text: type cut from the same material as the surfaces — bright along
the top, warming towards the accent below, the way light falls across a
raised face. Each line is lit on its own. For one display line on a page,
not for running text. */
.nv-text-lit {
background-image: linear-gradient(
180deg,
var(--nv-color-text-primary) 30%,
color-mix(in oklab, var(--nv-color-accent-text) 30%, var(--nv-color-text-primary))
);
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: var(--nv-color-accent-text);
}
/* The accent alone, lit from above: for an accented phrase inside a display
line, where flat accent text would read as a link. */
.nv-text-lit--accent {
background-image: linear-gradient(
180deg,
color-mix(in oklab, var(--nv-color-accent-text) 55%, var(--nv-color-text-primary)),
var(--nv-color-accent-text) 60%,
var(--nv-color-accent)
);
}

View file

@ -0,0 +1,113 @@
/* ==========================================================================
Niveus — Avatar
--------------------------------------------------------------------------
<span class="nv-avatar"><img src="…" alt=""></span>
<span class="nv-avatar">NX</span>
A person, as a round raised disc: an image when there is one, initials when
there is not. The disc is lit like a knob — highlight above, shade below —
so a face reads as something set into the interface, not pasted onto it.
.nv-avatar--ring a ring in the tint, set off by a gap of surface
.nv-avatar--glow the ring gives off its tint
.nv-avatar--sm / --lg / --xl
.nv-avatar__status presence dot; --online lights it
.nv-avatar-group overlapping row of avatars
--nv-avatar-tint picks the ring color; the accent unless set.
========================================================================== */
.nv-avatar {
--nv-avatar-tint: var(--nv-color-accent);
--_size: 2.5rem;
--_ring: 0 0 transparent;
--_glow: 0 0 transparent;
position: relative;
display: inline-grid;
place-items: center;
flex: none;
inline-size: var(--_size);
block-size: var(--_size);
background-color: var(--nv-color-surface-elevated);
background-image: var(--nv-sheen-raised);
color: var(--nv-color-text-secondary);
font: var(--nv-font-label);
font-weight: var(--nv-font-weight-semibold);
border-radius: var(--nv-radius-full);
box-shadow: var(--_ring), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow);
user-select: none;
}
.nv-avatar > img {
inline-size: 100%;
block-size: 100%;
border-radius: inherit;
object-fit: cover;
}
/* The gap is drawn in the surface color, so the ring reads as a separate band
sitting around the disc rather than as a thick border painted onto it. */
.nv-avatar--ring,
.nv-avatar--glow {
--_ring:
0 0 0 2px var(--nv-color-surface),
0 0 0 4px var(--nv-avatar-tint);
}
.nv-avatar--glow {
--_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm)
color-mix(in oklab, var(--nv-avatar-tint) var(--nv-glow-strength), transparent);
}
.nv-avatar--sm {
--_size: 1.75rem;
font: var(--nv-font-caption);
}
.nv-avatar--lg {
--_size: 4rem;
font: var(--nv-font-title);
}
.nv-avatar--xl {
--_size: 6rem;
font: var(--nv-font-heading);
}
/* --- Status ------------------------------------------------------------------ */
.nv-avatar__status {
position: absolute;
inset-block-end: 4%;
inset-inline-end: 4%;
inline-size: max(0.625rem, 24%);
block-size: max(0.625rem, 24%);
border-radius: var(--nv-radius-full);
background-color: var(--nv-color-text-tertiary);
box-shadow: 0 0 0 2px var(--nv-color-surface);
}
.nv-avatar__status--online {
background-color: var(--nv-color-success);
box-shadow:
0 0 0 2px var(--nv-color-surface),
0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm)
color-mix(in oklab, var(--nv-color-success) var(--nv-glow-strength), transparent);
}
/* --- Group ----------------------------------------------------------------------- */
.nv-avatar-group {
display: inline-flex;
align-items: center;
}
.nv-avatar-group > .nv-avatar {
box-shadow: 0 0 0 2px var(--nv-color-surface), var(--nv-bevel), var(--nv-elevation-1);
}
.nv-avatar-group > .nv-avatar + .nv-avatar {
margin-inline-start: calc(var(--_size) * -0.3);
}

View file

@ -76,6 +76,8 @@
--_bg: var(--_solid-bg);
--_fg: var(--_solid-fg);
--_border: transparent;
background-image: var(--nv-sheen-filled);
box-shadow: var(--nv-highlight-top);
}
.nv-badge--square {

View file

@ -10,6 +10,9 @@
.nv-button--icon square, for a lone icon (needs a label)
.nv-button--block fills its container
A button is a key: a bevelled face with light falling across it, a contact
line where it meets the surface, and a press that sinks it into an inset.
Every variant is defined purely by reassigning the private --_* properties
below. Interaction — the lift on hover, the sink on press, the focus ring —
lives once in the base rule, so a new variant can never drift from the way
@ -23,9 +26,10 @@
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-primary);
--_border: var(--nv-color-border);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
--_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-3);
--_shadow-active: var(--nv-elevation-inset-sm);
--_sheen: var(--nv-sheen-raised);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
--_shadow-active: var(--nv-elevation-inset-sm), var(--nv-outline);
/* Size, overridden per size modifier. */
--_height: var(--nv-control-height-md);
@ -46,6 +50,7 @@
border-radius: var(--_radius);
background-color: var(--_bg);
background-image: var(--_sheen);
color: var(--_fg);
box-shadow: var(--_shadow);
@ -83,8 +88,11 @@
}
}
/* Pressed, the face is pushed below the light: the sheen goes and the inset
takes over. */
.nv-button:active:not(:disabled, [aria-disabled="true"]) {
background-color: var(--_bg-active);
background-image: none;
box-shadow: var(--_shadow-active);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
@ -98,6 +106,7 @@
.nv-button:disabled,
.nv-button[aria-disabled="true"] {
background-color: var(--nv-color-surface-inset);
background-image: none;
color: var(--nv-color-text-disabled);
border-color: var(--nv-color-border-subtle);
box-shadow: none;
@ -113,6 +122,13 @@
--_bg-active: var(--nv-color-accent-active);
--_fg: var(--nv-color-on-accent);
--_border: transparent;
--_sheen: var(--nv-sheen-filled);
/* A filled key gives off a little of its own color: the glow is what makes
it read as lit rather than printed. */
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2),
0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3),
0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent);
font-weight: var(--nv-font-weight-semibold);
}
@ -122,6 +138,11 @@
--_bg-active: var(--nv-color-danger-active);
--_fg: var(--nv-color-on-danger);
--_border: transparent;
--_sheen: var(--nv-sheen-filled);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2),
0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3),
0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent);
--nv-color-focus-ring: var(--nv-color-danger-border);
font-weight: var(--nv-font-weight-semibold);
}
@ -132,11 +153,20 @@
--_bg-active: var(--nv-color-surface-active);
--_fg: var(--nv-color-text-secondary);
--_border: transparent;
--_sheen: none;
--_shadow: none;
--_shadow-hover: none;
--_shadow-active: var(--nv-elevation-inset-sm);
}
/* A ghost has no material to lose, so disabling it only dims the label —
it does not suddenly grow a well its enabled siblings never had. */
.nv-button--ghost:disabled,
.nv-button--ghost[aria-disabled="true"] {
background-color: transparent;
border-color: transparent;
}
@media (hover: hover) {
.nv-button--ghost:hover:not(:disabled, [aria-disabled="true"]) {
color: var(--nv-color-text-primary);
@ -150,6 +180,7 @@
--_bg-active: var(--nv-color-accent-subtle-hover);
--_fg: var(--nv-color-accent-text);
--_border: var(--nv-color-accent-border);
--_sheen: none;
--_shadow: none;
--_shadow-hover: var(--nv-elevation-1);
}
@ -159,6 +190,7 @@
--_bg-hover: var(--nv-material-frosted-bg);
--_bg-active: var(--nv-material-frosted-bg);
--_border: var(--nv-material-glass-border);
--_sheen: none;
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
@ -205,9 +237,9 @@
through every state. */
.nv-button--float {
--_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
--_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-4);
--_shadow-active: var(--nv-highlight-top), var(--nv-elevation-2);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
--_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4);
--_shadow-active: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
translate: var(--nv-button-offset, 0 0);
}

View file

@ -6,6 +6,9 @@
.nv-card--inset recessed into its parent
.nv-card--frosted over imagery or scrolling content
.nv-card--interactive the whole card is one target
.nv-card--lit lit along its top edge in its tint
.nv-card--glow gives off its tint — the one card that is on
.nv-card--spotlight catches a light that follows the pointer
.nv-card__header / __body / __footer / __media
A card is a container, not a look. If a card needs a shadow that is not on
@ -14,10 +17,20 @@
========================================================================== */
.nv-card {
--_bg: var(--nv-color-surface);
/* Content surfaces are mostly solid: enough of the canvas comes through to
tie a card to the light around it, never enough to compete with what it
holds. The blur keeps anything busy behind it (a bed, an image) from
reading through as texture. */
--_bg: color-mix(in oklab, var(--nv-color-surface) 90%, transparent);
--_border: var(--nv-color-border-subtle);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-1);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
--_sheen: var(--nv-sheen-surface);
--_padding: var(--nv-space-5);
/* Light layers the variants switch on. Kept separate from --_shadow so a
lit or glowing card can still be elevated, floating or interactive. */
--_lit: 0 0 transparent;
--_glow: 0 0 transparent;
--_spot: linear-gradient(transparent, transparent);
position: relative;
display: flex;
@ -25,10 +38,13 @@
min-inline-size: 0;
background-color: var(--_bg);
background-image: var(--_spot), var(--_sheen);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--nv-radius-lg);
box-shadow: var(--_shadow);
box-shadow: var(--_lit), var(--_shadow), var(--_glow);
backdrop-filter: var(--nv-backdrop-glass);
-webkit-backdrop-filter: var(--nv-backdrop-glass);
/* Children are clipped to the corner radius so media and inset regions
cannot square off the card. */
@ -37,18 +53,19 @@
}
.nv-card--elevated {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-2);
--_bg: color-mix(in oklab, var(--nv-color-surface-elevated) 90%, transparent);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
}
.nv-card--floating {
--_bg: var(--nv-color-surface-elevated);
--_shadow: var(--nv-highlight-top), var(--nv-elevation-3);
--_bg: color-mix(in oklab, var(--nv-color-surface-elevated) 88%, transparent);
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
}
.nv-card--inset {
--_bg: var(--nv-color-surface-inset);
--_shadow: var(--nv-elevation-inset-md);
--_bg: color-mix(in oklab, var(--nv-color-surface-inset) 85%, transparent);
--_shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
--_sheen: none;
--_border: var(--nv-color-border-subtle);
}
@ -56,6 +73,7 @@
--_bg: var(--nv-material-frosted-bg);
--_border: var(--nv-material-frosted-border);
--_shadow: var(--nv-material-frosted-shadow);
--_sheen: none;
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
@ -76,20 +94,75 @@
@media (hover: hover) {
.nv-card--interactive:hover {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), var(--_glow);
translate: 0 calc(var(--nv-lift-hover) * 2);
}
}
.nv-card--interactive:active {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow);
translate: 0 var(--nv-press-depth);
transition-duration: var(--nv-duration-instant);
}
.nv-card--interactive:focus-visible {
outline: none;
box-shadow: var(--_shadow), var(--nv-focus-ring);
box-shadow: var(--_lit), var(--_shadow), var(--_glow), var(--nv-focus-ring);
}
/* --- Light ------------------------------------------------------------------
Three ways a card can carry light, each stated once and combinable with
every elevation above. They all take the card's tint — the accent unless
--nv-card-tint names another color, the way --nv-bed-tint does for a bed.
Lit: a strip of light along the top edge, spilling a little way down
the face. For a card that belongs to something with a color: a
project, a status, a category.
Glow: the card gives off its tint. For the one card that is on — the
current plan, the live item. Two glowing cards side by side
cancel each other out.
Spotlight: a soft light follows the pointer across the face (niveus.js
tracks it). A showcase effect; a list of work items does not
need one. */
.nv-card--lit,
.nv-card--glow,
.nv-card--spotlight {
--nv-card-tint: var(--nv-color-accent);
}
.nv-card--lit {
--_lit:
inset 0 2px 0 color-mix(in oklab, var(--nv-card-tint) 85%, transparent),
inset 0 18px 28px -22px color-mix(in oklab, var(--nv-card-tint) 70%, transparent);
--_sheen:
radial-gradient(
120% 70% at 0% 0%,
color-mix(in oklab, var(--nv-card-tint) 12%, transparent),
transparent 70%
),
var(--nv-sheen-surface);
}
.nv-card--glow {
--_border: color-mix(in oklab, var(--nv-card-tint) 40%, var(--nv-color-border-subtle));
--_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md)
color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent);
}
@media (hover: hover) {
.nv-card--glow.nv-card--interactive:hover {
--_glow: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-md)
color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent);
}
.nv-card--spotlight:hover {
--_spot: radial-gradient(
22rem circle at var(--nv-pointer-x, 50%) var(--nv-pointer-y, 0%),
color-mix(in oklab, var(--nv-card-tint) 14%, var(--nv-color-sheen-top)),
transparent 70%
);
}
}
/* Cards whose whole area is clickable via a link inside: the link's target is

View file

@ -0,0 +1,55 @@
/* ==========================================================================
Niveus — Deck
--------------------------------------------------------------------------
<div class="nv-deck">
<article class="nv-card">…</article>
<div class="nv-deck__layer" aria-hidden="true"></div>
<div class="nv-deck__layer" aria-hidden="true"></div>
</div>
One surface in front of others of its kind: the current item of a set that
has more behind it. The layers are cards seen edge-on, stepping back and
dimming with distance, so the set has depth without anything having to
move. Use it where "there are more of these" is part of the message — a
showcase of products, a stack of notifications, a carousel at rest.
Everything in the deck shares one grid cell. The front surface is any
element; the layers are decorative and hidden from assistive technology.
Panels that swap (an .nv-tab-panel set) can live in the deck together:
the hidden ones take no space.
========================================================================== */
.nv-deck {
--_step: var(--nv-space-3);
display: grid;
position: relative;
isolation: isolate;
/* Room above the front surface for the layers that peek out behind it. */
padding-block-start: calc(var(--_step) * 2);
}
.nv-deck > * {
grid-area: 1 / 1;
min-inline-size: 0;
}
.nv-deck__layer {
--_depth: 1;
z-index: -1;
pointer-events: none;
background-color: color-mix(in oklab, var(--nv-color-surface) 90%, transparent);
background-image: var(--nv-sheen-surface);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-lg);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
translate: 0 calc(var(--_step) * var(--_depth) * -1);
scale: calc(1 - var(--_depth) * 0.05) 1;
opacity: calc(1 - var(--_depth) * 0.3);
}
.nv-deck__layer + .nv-deck__layer {
--_depth: 2;
z-index: -2;
}

View file

@ -23,11 +23,17 @@
max-block-size: min(42rem, calc(100svh - var(--nv-space-12)));
padding: 0;
background-color: var(--nv-color-surface-elevated);
/* The dialog sits over a dimmed, blurred page, so it can afford to let a
little of it through: it reads as a pane lifted off the page rather than
a card pasted over it. */
background-color: color-mix(in oklab, var(--nv-color-surface-elevated) 86%, transparent);
background-image: var(--nv-sheen-surface);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-xl);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-4);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4);
overflow: hidden;

View file

@ -20,9 +20,10 @@
--_padding: var(--nv-space-4);
background-color: var(--nv-color-surface);
background-image: var(--nv-sheen-surface);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-lg);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
transition: var(--nv-transition-surface);
}

View file

@ -8,9 +8,9 @@
.nv-checkbox / .nv-radio custom marks on the native controls
.nv-switch binary control with an inset track
Inputs are the system's inset material: they read as recessed into the
surface that holds them, which is what distinguishes a place to type from a
thing to press. On focus the recess stays — only the ring and border change,
Inputs are the system's inset material: they read as wells cut into the
surface that holds them — shaded inside, lit along the lower lip — which is
what distinguishes a place to type from a thing to press. On focus the recess stays — only the ring and border change,
so the field never appears to jump forward.
========================================================================== */
@ -59,12 +59,14 @@
min-inline-size: 0;
block-size: var(--_height);
padding-inline: var(--_padding);
background-color: var(--nv-color-surface-inset);
/* A well in dark, where recess reads as shade; paper in light, where a grey
well reads as a field that has been switched off. The inset shadow and
ledge carry the recess either way. */
background-color: light-dark(var(--nv-color-surface), var(--nv-color-surface-inset));
color: var(--nv-color-text-primary);
border: var(--nv-border-width) solid var(--_border);
border-radius: var(--_radius);
box-shadow: var(--nv-elevation-inset-sm);
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
font-family: var(--nv-font-family-sans);
font-size: var(--_font-size);
@ -87,7 +89,7 @@
.nv-input:focus {
outline: none;
--_border: var(--nv-color-accent);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-focus-ring);
box-shadow: var(--nv-elevation-inset-md), var(--nv-focus-ring);
}
.nv-input[aria-invalid="true"] {
@ -134,7 +136,13 @@ textarea.nv-input {
padding: 0;
}
.nv-input--color::-webkit-color-swatch,
.nv-input--color::-webkit-color-swatch {
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: calc(var(--nv-radius-md) - var(--nv-space-1));
}
/* Separate rules on purpose: a selector list that names another engine's
pseudo-element is invalid as a whole, and each browser would drop both. */
.nv-input--color::-moz-color-swatch {
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: calc(var(--nv-radius-md) - var(--nv-space-1));
@ -227,13 +235,16 @@ textarea.nv-input {
.nv-radio {
position: relative;
flex: none;
/* The marks are drawn in em; pinning the size keeps them the same whatever
the label around them is set in. */
font-size: var(--nv-font-size-md);
inline-size: var(--nv-space-5);
block-size: var(--nv-space-5);
margin: 0;
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border-strong);
border-radius: var(--nv-radius-xs);
box-shadow: var(--nv-elevation-inset-sm);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
cursor: pointer;
appearance: none;
transition: var(--nv-transition-control);
@ -247,8 +258,9 @@ textarea.nv-input {
.nv-radio:checked,
.nv-checkbox:indeterminate {
background-color: var(--nv-color-accent);
background-image: var(--nv-sheen-filled);
border-color: transparent;
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
}
/* The marks are always present, at zero size and zero opacity, so checking a
@ -323,8 +335,10 @@ textarea.nv-input {
}
/* --- Switch --------------------------------------------------------------------------
The track is inset and the thumb is elevated: the physical relationship does
the explaining, so the control reads as on or off without a label. */
The track is a slot cut into the surface and the thumb is a knob riding in
it: the physical relationship does the explaining, so the control reads as
on or off without a label. The slot stays recessed when it fills with color
— switching on lights the slot, it does not raise it. */
.nv-switch {
--_track-inline: calc(var(--nv-space-6) * 1.5);
@ -344,7 +358,7 @@ textarea.nv-input {
background-color: var(--nv-color-surface-inset);
border: var(--nv-border-width) solid var(--nv-color-border);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-elevation-inset-sm);
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
cursor: pointer;
appearance: none;
transition: var(--nv-transition-control);
@ -358,9 +372,10 @@ textarea.nv-input {
inline-size: var(--_thumb);
block-size: var(--_thumb);
translate: 0 -50%;
background-color: var(--nv-color-surface-elevated);
background-color: var(--nv-color-knob);
background-image: var(--nv-sheen-knob);
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
box-shadow: var(--nv-outline), var(--nv-elevation-2);
transition:
translate var(--nv-duration-travel) var(--nv-ease-emphasized),
background-color var(--nv-duration-fast) var(--nv-ease-standard);
@ -369,6 +384,7 @@ textarea.nv-input {
.nv-switch:checked {
background-color: var(--nv-color-accent);
border-color: transparent;
box-shadow: var(--nv-elevation-inset-md), var(--nv-outline);
}
.nv-switch:checked::after {
@ -386,7 +402,10 @@ textarea.nv-input {
display: flex;
align-items: center;
gap: var(--nv-space-2-5);
/* Labelled at control size, so a row of switches and checkboxes sits level
with the buttons and fields beside it. */
font: var(--nv-font-body);
font-size: var(--nv-font-size-sm);
cursor: pointer;
}

View file

@ -43,9 +43,10 @@
margin-block-start: var(--nv-space-2);
background-color: var(--nv-material-frosted-bg);
background-image: var(--nv-sheen-surface);
border: var(--nv-border-width) solid var(--nv-material-frosted-border);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
border-radius: var(--nv-radius-lg);
box-shadow: var(--nv-material-frosted-shadow);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
color: var(--nv-color-text-primary);

View file

@ -8,8 +8,8 @@
.nv-nav-tabs in-page tab strip with a sliding indicator
Navigation is chrome: it sits above content rather than in it. That is why
the bar is frosted — the blur is not decoration, it is the signal that
content is passing underneath. A sidebar that scrolls with the page is not
the bar is frosted and bevelled — the blur is not decoration, it is the
signal that content is passing underneath. A sidebar that scrolls with the page is not
chrome, so .nv-nav--vertical drops the translucency by default.
========================================================================== */
@ -20,18 +20,19 @@
display: flex;
align-items: center;
gap: var(--nv-space-4);
flex-wrap: wrap;
gap: var(--nv-space-2) var(--nv-space-4);
padding: var(--nv-space-2) var(--nv-space-4);
min-block-size: var(--nv-control-height-lg);
background-color: var(--nv-material-frosted-bg);
background-image: var(--nv-sheen-raised);
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
/* The travelling selection marker sits behind its item at z-index -1; the
bar has to be its own stacking context or the marker would fall behind
the bar's background and disappear. */
isolation: isolate;
box-shadow: var(--nv-highlight-top);
box-shadow: var(--nv-bevel);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
transition: var(--nv-transition-surface);
@ -40,12 +41,13 @@
/* The bar only earns a shadow once something is behind it. Set data-scrolled
from the application on scroll. */
.nv-nav[data-scrolled] {
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
box-shadow: var(--nv-bevel), var(--nv-elevation-2);
transition: box-shadow var(--nv-duration-normal) var(--nv-ease-standard);
}
.nv-nav--plain {
background-color: var(--nv-color-surface);
background-image: var(--nv-sheen-raised);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
@ -64,6 +66,7 @@
background-color: transparent;
border-block-end: 0;
background-image: none;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
@ -88,6 +91,15 @@
padding: 0;
list-style: none;
min-inline-size: 0;
/* On a narrow screen a long list scrolls inside the bar instead of pushing
the page sideways. */
max-inline-size: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.nv-nav__list::-webkit-scrollbar {
display: none;
}
.nv-nav__spacer {
@ -169,14 +181,23 @@
z-index: -1;
background-color: var(--nv-color-surface-elevated);
background-image: var(--nv-sheen-raised);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: inherit;
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
view-transition-name: var(--nv-selection-name);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
view-transition-class: nv-travelling;
}
/* The marker is named only while its own group is changing (niveus.js sets
data-nv-travelling for the length of the transition). A permanent name
would lift every marker on the page out of the page snapshot on every
transition, theme changes included. */
[data-nv-travelling] .nv-nav__item[aria-current]::before,
[data-nv-travelling] .nv-nav__item--active::before,
[data-nv-travelling] .nv-nav__item[aria-selected="true"]::before {
view-transition-name: var(--nv-selection-name);
}
/* Hovering an already-selected item should not restate the emphasis. */
@media (hover: hover) {
.nv-nav__item[aria-current]:hover,
@ -226,11 +247,15 @@
gap: var(--nv-space-1);
inline-size: 15rem;
block-size: 100svh;
flex-wrap: nowrap;
padding: var(--nv-space-4) var(--nv-space-3);
background-color: var(--nv-color-background-subtle);
backdrop-filter: none;
-webkit-backdrop-filter: none;
/* A sidebar is a pane of the canvas rather than chrome over content, so it
stays mostly opaque — but it lets the light of the canvas through, which
is what stops it reading as a grey slab beside the page. */
background-color: color-mix(in oklab, var(--nv-color-background-subtle) 72%, transparent);
background-image: var(--nv-sheen-surface);
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
border-block-end: 0;
border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
box-shadow: none;
@ -252,11 +277,11 @@
margin-block-end: var(--nv-space-3);
}
/* Engraved, like .nv-overline: a section marks a region, it is not read. */
.nv-nav__section {
padding: var(--nv-space-4) var(--nv-space-3) var(--nv-space-1-5);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
font: var(--nv-font-engraved);
letter-spacing: var(--nv-letter-spacing-engraved);
text-transform: uppercase;
color: var(--nv-color-text-tertiary);
}
@ -272,13 +297,13 @@
.nv-nav-tabs {
display: inline-flex;
align-items: center;
max-inline-size: 100%;
gap: var(--nv-space-0-5);
padding: var(--nv-space-1);
background-color: var(--nv-color-surface-inset);
background-color: color-mix(in oklab, var(--nv-color-surface-inset) 80%, transparent);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-elevation-inset-sm);
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
overflow-x: auto;
scrollbar-width: none;
isolation: isolate;
@ -307,12 +332,12 @@
/* --- Panels ----------------------------------------------------------------------
Switching tabs should feel like moving through one surface rather than
swapping the interface. The panel carries the shared content name, so the
outgoing and incoming panels cross-fade inside the same box — and slide
with the direction of travel when the application supplies one. */
swapping the interface. nv.selectTab() gives the outgoing and incoming
panels one shared name for the length of the change, so they hand over
inside the same box — and slide with the direction of travel. The class is
what picks up the content animation in continuity.css. */
.nv-tab-panel {
view-transition-name: var(--nv-content-name, nv-content);
view-transition-class: nv-content;
}
/* --- Breadcrumbs -------------------------------------------------------------------- */

View file

@ -0,0 +1,60 @@
/* ==========================================================================
Niveus — Skeleton
--------------------------------------------------------------------------
<span class="nv-skeleton nv-skeleton--text"></span>
The shape of content that has not arrived yet. A skeleton is a shallow well
cut where the content will sit, with a slow band of light crossing it — the
surface is recessed and waiting, not broken.
.nv-skeleton a block; size it with the layout
.nv-skeleton--text one line of body text
.nv-skeleton--circle an avatar or a tile
Under reduced motion the light stops crossing and the well stays: the state
is still legible, it just does not move.
========================================================================== */
.nv-skeleton {
display: block;
min-block-size: var(--nv-space-4);
border-radius: var(--nv-radius-md);
background-color: var(--nv-color-surface-inset);
background-image: linear-gradient(
100deg,
transparent 30%,
var(--nv-color-sheen-top) 50%,
transparent 70%
);
background-size: 220% 100%;
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
animation: nv-shimmer var(--nv-duration-shimmer) var(--nv-ease-linear) infinite;
}
.nv-skeleton--text {
inline-size: 100%;
min-block-size: 0;
block-size: 0.75em;
border-radius: var(--nv-radius-full);
}
/* A paragraph of skeleton lines ends short, like the paragraph it stands for. */
.nv-skeleton--text:last-child:not(:first-child) {
inline-size: 60%;
}
.nv-skeleton--circle {
aspect-ratio: 1;
border-radius: var(--nv-radius-full);
}
@keyframes nv-shimmer {
from { background-position: 100% 0; }
to { background-position: -120% 0; }
}
@media (prefers-reduced-motion: reduce) {
.nv-skeleton {
animation: none;
}
}

View file

@ -0,0 +1,74 @@
/* ==========================================================================
Niveus — Stat
--------------------------------------------------------------------------
<dl class="nv-stat-group">
<div class="nv-stat">
<dt class="nv-stat__label">Servers</dt>
<dd class="nv-stat__value">1,204</dd>
</div>
</dl>
A number the user is meant to read at a glance. A group of stats is set into
one shallow well, like the readout panel of an instrument: the figures sit
behind the surface, separated by hairlines, so a row of numbers reads as one
display instead of as four loose labels.
.nv-stat-group the readout well; wraps without a media query
.nv-stat one reading
.nv-stat__label what is measured
.nv-stat__value the figure, in tabular numerals
.nv-stat__delta change since last time; --up / --down
========================================================================== */
.nv-stat-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr));
/* The hairlines between readings are the well itself showing through a
one-pixel gap, so they stay continuous however the row wraps. */
gap: var(--nv-border-width);
margin: 0;
background-color: var(--nv-color-border-subtle);
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
border-radius: var(--nv-radius-lg);
box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
overflow: hidden;
}
.nv-stat {
display: flex;
flex-direction: column;
gap: var(--nv-space-1);
min-inline-size: 0;
padding: var(--nv-space-4) var(--nv-space-5);
}
.nv-stat-group > .nv-stat {
background-color: var(--nv-color-surface-inset);
}
.nv-stat__label {
font: var(--nv-font-caption);
color: var(--nv-color-text-tertiary);
}
.nv-stat__value {
margin: 0;
font: var(--nv-font-heading);
font-variant-numeric: tabular-nums;
letter-spacing: var(--nv-letter-spacing-tight);
color: var(--nv-color-text-primary);
}
.nv-stat__delta {
font: var(--nv-font-caption);
font-variant-numeric: tabular-nums;
color: var(--nv-color-text-secondary);
}
.nv-stat__delta--up {
color: var(--nv-color-success);
}
.nv-stat__delta--down {
color: var(--nv-color-danger);
}

View file

@ -0,0 +1,157 @@
/* ==========================================================================
Niveus — Steps
--------------------------------------------------------------------------
<ol class="nv-steps">
<li class="nv-steps__item" data-state="done">
<span class="nv-steps__marker">1</span>
<div class="nv-steps__body">
<h3 class="nv-steps__title">Listen</h3>
<p class="nv-steps__text">…</p>
</div>
</li>
</ol>
A sequence the user moves through: a process, an onboarding, an order's
progress. The steps sit on a rail — a groove cut into the surface — with
each marker a raised knob set into it. Finished steps light their stretch
of the rail, so progress reads as something filling up rather than as a
row of ticks.
.nv-steps horizontal; becomes vertical on narrow screens
.nv-steps--vertical always vertical
data-state="done" the step and its stretch of rail are lit
aria-current="step" the step in progress: lit marker, rail still dark
========================================================================== */
.nv-steps {
--_marker: 2.25rem;
--_rail: 6px;
--_gap: var(--nv-space-4);
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: var(--_gap);
margin: 0;
padding: 0;
list-style: none;
}
.nv-steps__item {
position: relative;
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
min-inline-size: 0;
}
/* Each step draws the rail from its own marker to the next one, so a finished
step can light exactly its stretch and the rail never runs past the end. */
.nv-steps__item:not(:last-child)::before {
content: "";
position: absolute;
inset-block-start: calc((var(--_marker) - var(--_rail)) / 2);
inset-inline-start: calc(var(--_marker) / 2);
inline-size: calc(100% + var(--_gap));
block-size: var(--_rail);
border-radius: var(--nv-radius-full);
background-color: var(--nv-color-surface-inset);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
transition: var(--nv-transition-surface);
}
.nv-steps__item[data-state="done"]:not(:last-child)::before {
background-color: var(--nv-color-accent);
background-image: var(--nv-sheen-filled);
box-shadow: var(--nv-glow-sm);
}
.nv-steps__marker {
position: relative;
z-index: 1;
display: grid;
place-items: center;
flex: none;
inline-size: var(--_marker);
block-size: var(--_marker);
background-color: var(--nv-color-surface-elevated);
background-image: var(--nv-sheen-raised);
color: var(--nv-color-text-secondary);
font: var(--nv-font-label);
font-weight: var(--nv-font-weight-semibold);
font-variant-numeric: tabular-nums;
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
transition: var(--nv-transition-surface);
}
.nv-steps__item[data-state="done"] > .nv-steps__marker,
.nv-steps__item[aria-current="step"] > .nv-steps__marker {
background-color: var(--nv-color-accent);
background-image: var(--nv-sheen-filled);
color: var(--nv-color-on-accent);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), var(--nv-glow-md);
}
.nv-steps__body {
display: flex;
flex-direction: column;
gap: var(--nv-space-1);
min-inline-size: 0;
}
.nv-steps__title {
margin: 0;
font: var(--nv-font-title);
letter-spacing: var(--nv-letter-spacing-tight);
color: var(--nv-color-text-primary);
}
.nv-steps__text {
margin: 0;
font: var(--nv-font-caption);
color: var(--nv-color-text-secondary);
}
/* --- Vertical -------------------------------------------------------------------
Marker beside the text and the rail running down between markers. Also what
a horizontal sequence becomes once its columns are too narrow to read. */
.nv-steps--vertical {
grid-auto-flow: row;
grid-auto-columns: auto;
--_gap: var(--nv-space-5);
}
.nv-steps--vertical > .nv-steps__item {
flex-direction: row;
gap: var(--nv-space-4);
}
.nv-steps--vertical > .nv-steps__item:not(:last-child)::before {
inset-block-start: calc(var(--_marker) / 2);
inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2);
inline-size: var(--_rail);
block-size: calc(100% + var(--_gap));
}
@media (max-width: 40rem) {
.nv-steps {
grid-auto-flow: row;
grid-auto-columns: auto;
--_gap: var(--nv-space-5);
}
.nv-steps > .nv-steps__item {
flex-direction: row;
gap: var(--nv-space-4);
}
.nv-steps > .nv-steps__item:not(:last-child)::before {
inset-block-start: calc(var(--_marker) / 2);
inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2);
inline-size: var(--_rail);
block-size: calc(100% + var(--_gap));
}
}

View file

@ -0,0 +1,83 @@
/* ==========================================================================
Niveus — Tile
--------------------------------------------------------------------------
<span class="nv-tile"><svg …></svg></span>
A tile is a small raised key that holds one glyph: the icon beside a
feature, the mark of a product, the symbol on a stat. It is the physical
version of an icon — something with an edge you could press — which is why
a row of features reads as a set of objects rather than as clip art.
.nv-tile soft key: tinted face, the glyph in its tint
.nv-tile--solid filled key: saturated face, a light glyph
.nv-tile--glow the key is lit from inside
.nv-tile--sm / --lg / --xl
Everything derives from --nv-tile-tint, the accent unless set. A tile is
not a button: if it does something, it is an .nv-button--icon.
========================================================================== */
.nv-tile {
--nv-tile-tint: var(--nv-color-accent);
--_size: 2.75rem;
--_radius: var(--nv-radius-md);
--_bg: color-mix(in oklab, var(--nv-tile-tint) 16%, var(--nv-color-surface-elevated));
--_ink: color-mix(in oklab, var(--nv-tile-tint) 70%, var(--nv-color-text-primary));
--_sheen: var(--nv-sheen-raised);
--_glow: 0 0 transparent;
position: relative;
display: inline-grid;
place-items: center;
flex: none;
inline-size: var(--_size);
block-size: var(--_size);
background-color: var(--_bg);
background-image: var(--_sheen);
color: var(--_ink);
border: var(--nv-border-width) solid color-mix(in oklab, var(--nv-tile-tint) 30%, var(--nv-color-border-subtle));
border-radius: var(--_radius);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow);
transition: var(--nv-transition-surface);
}
/* Glyphs take half the key; a mark that is itself a picture (an image, a
logo) fills it, clipped to the key's corners. */
.nv-tile > svg {
inline-size: 50%;
block-size: 50%;
}
.nv-tile > img {
inline-size: 100%;
block-size: 100%;
border-radius: inherit;
object-fit: cover;
}
.nv-tile--solid {
--_bg: var(--nv-tile-tint);
--_ink: var(--nv-color-on-accent);
--_sheen: var(--nv-sheen-filled);
}
.nv-tile--glow {
--_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm)
color-mix(in oklab, var(--nv-tile-tint) var(--nv-glow-strength), transparent);
}
.nv-tile--sm {
--_size: 2rem;
--_radius: var(--nv-radius-sm);
}
.nv-tile--lg {
--_size: 3.5rem;
--_radius: var(--nv-radius-lg);
}
.nv-tile--xl {
--_size: 4.5rem;
--_radius: var(--nv-radius-xl);
}

View file

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

View file

@ -60,9 +60,9 @@
The fix is to capture the labels too and order the groups explicitly. The
marker travels below; the items ride above it, exactly as they do at rest.
niveus.js assigns each item its name — they have to be unique per document,
which no stylesheet can guarantee — and items it has not named simply stay in
the page snapshot.
niveus.js names the items of the group that is changing, and only while it
changes — names have to be unique per document, which no stylesheet can
guarantee — and every other item simply stays in the page snapshot.
Because the layering is now correct, the marker needs no trick to stay out of
the way: it travels at full opacity, like the solid object it is. */
@ -71,6 +71,16 @@
z-index: 0;
}
/* The marker changes shape as it travels — a short label's pill becomes a
long one's. By default the browser scales each snapshot to the group's
width and keeps its aspect ratio, so mid-way a 40×26 pill stretched to
80px wide is drawn 52px tall, hanging out below its strip. The marker is a
plain surface, so its snapshots fill the box instead. */
::view-transition-old(.nv-travelling),
::view-transition-new(.nv-travelling) {
block-size: 100%;
}
::view-transition-group(.nv-carried) {
z-index: 1;
}
@ -101,40 +111,56 @@
Moving forward and moving back should not look the same. The application
sets data-nv-direction on the root for the length of the transition; content
then leaves towards where the user came from and enters from where they are
going. Without a direction, content simply rises into place. */
going. Without a direction, content simply rises into place.
::view-transition-old(nv-content) {
animation: nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both;
Content hands over; it does not cross-fade. Two blocks of text at half
opacity on top of each other read as a rendering fault, not as motion, so
the outgoing content is gone (fast) before the incoming arrives (delayed by
that same beat). The box itself never empties for longer than a blink.
Any element whose content changes in place takes part by carrying the
nv-content class: .nv-continuous and .nv-tab-panel both do.
The box clips what it holds. When a tall panel gives way to a short one,
the page around it has already reflowed to the new height, so unclipped
outgoing content would spill out below its own card. */
::view-transition-group(.nv-content) {
overflow: clip;
}
::view-transition-new(nv-content) {
animation:
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
nv-rise var(--nv-duration-normal) var(--nv-ease-entrance) both;
::view-transition-old(.nv-content) {
animation: nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both;
}
:root[data-nv-direction="forward"]::view-transition-old(nv-content) {
::view-transition-new(.nv-content) {
animation:
nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both,
nv-exit-to-start var(--nv-duration-normal) var(--nv-ease-emphasized) both;
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both,
nv-rise var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both;
}
:root[data-nv-direction="forward"]::view-transition-new(nv-content) {
:root[data-nv-direction="forward"]::view-transition-old(.nv-content) {
animation:
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
nv-enter-from-end var(--nv-duration-normal) var(--nv-ease-emphasized) both;
nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both,
nv-exit-to-start var(--nv-duration-fast) var(--nv-ease-exit) both;
}
:root[data-nv-direction="backward"]::view-transition-old(nv-content) {
:root[data-nv-direction="forward"]::view-transition-new(.nv-content) {
animation:
nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both,
nv-exit-to-end var(--nv-duration-normal) var(--nv-ease-emphasized) both;
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both,
nv-enter-from-end var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both;
}
:root[data-nv-direction="backward"]::view-transition-new(nv-content) {
:root[data-nv-direction="backward"]::view-transition-old(.nv-content) {
animation:
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both,
nv-enter-from-start var(--nv-duration-normal) var(--nv-ease-emphasized) both;
nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both,
nv-exit-to-end var(--nv-duration-fast) var(--nv-ease-exit) both;
}
:root[data-nv-direction="backward"]::view-transition-new(.nv-content) {
animation:
nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both,
nv-enter-from-start var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both;
}
@keyframes nv-fade-in {
@ -171,6 +197,34 @@
.nv-continuous {
view-transition-name: var(--nv-content-name, nv-content);
view-transition-class: nv-content;
}
/* --- Reveal ------------------------------------------------------------------------
Content arriving as it scrolls into view: it rises a short way and fades in,
once, so a long page is read in the order it was written instead of all at
once. .nv-reveal-group does the same for its children one after another,
--nv-stagger apart.
niveus.js marks what is waiting. Without the script, and under reduced
motion, nothing is ever marked — so nothing is ever hidden. */
.nv-reveal[data-nv-reveal="pending"],
.nv-reveal-group > [data-nv-reveal="pending"] {
opacity: 0;
}
.nv-reveal[data-nv-reveal="shown"],
.nv-reveal-group > [data-nv-reveal="shown"] {
animation: nv-arrive var(--nv-duration-slower) var(--nv-ease-entrance)
calc(var(--nv-reveal-index, 0) * var(--nv-stagger)) both;
}
@keyframes nv-arrive {
from {
opacity: 0;
translate: 0 var(--nv-travel-lg);
}
}
/* Collapsible region for markup that is not a <details>. The 0fr -> 1fr grid

View file

@ -0,0 +1,42 @@
/* ==========================================================================
Niveus — Fonts
--------------------------------------------------------------------------
Niveus ships its own faces, so it reads the same wherever it is used
instead of depending on what a machine happens to have installed. Both are
variable fonts under the SIL Open Font License (fonts/OFL-*.txt), free to
bundle with anything.
Instrument Sans the interface. Crisp, slightly narrow, warm at small
sizes — the voice of every label, control and heading.
JetBrains Mono code, figures, and the engraved labels (.nv-overline)
that mark sections the way markings mark an instrument.
The paths are relative to this file and to dist/niveus.css alike: both sit
one level below the root, where fonts/ lives.
========================================================================== */
@font-face {
font-family: "Instrument Sans";
src: url("../fonts/InstrumentSans.woff2") format("woff2");
font-weight: 400 700;
font-stretch: 75% 100%;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Instrument Sans";
src: url("../fonts/InstrumentSans-Italic.woff2") format("woff2");
font-weight: 400 700;
font-stretch: 75% 100%;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("../fonts/JetBrainsMono.woff2") format("woff2");
font-weight: 100 800;
font-style: normal;
font-display: swap;
}

View file

@ -1,5 +1,5 @@
/* ==========================================================================
Niveus — a calm, adaptive interface language.
Niveus — a tactile, adaptive interface language.
Made by Nexoniarz.
--------------------------------------------------------------------------
The single entry point. An application links this file and nothing else:
@ -22,6 +22,9 @@
@layer niveus.tokens, niveus.theme, niveus.base, niveus.continuity,
niveus.components, niveus.utilities;
/* --- Fonts: the faces Niveus ships, so it reads the same everywhere ---------- */
@import url("fonts.css");
/* --- Tokens: the values, appearance-agnostic ------------------------------- */
@import url("tokens/color.css") layer(niveus.tokens);
@import url("tokens/typography.css") layer(niveus.tokens);
@ -54,6 +57,13 @@
@import url("components/bed.css") layer(niveus.components);
@import url("components/badge.css") layer(niveus.components);
@import url("components/table.css") layer(niveus.components);
@import url("components/window.css") layer(niveus.components);
@import url("components/tile.css") layer(niveus.components);
@import url("components/avatar.css") layer(niveus.components);
@import url("components/stat.css") layer(niveus.components);
@import url("components/steps.css") layer(niveus.components);
@import url("components/deck.css") layer(niveus.components);
@import url("components/skeleton.css") layer(niveus.components);
/* --- Utilities ----------------------------------------------------------------- */
@import url("utilities.css") layer(niveus.utilities);

View file

@ -13,7 +13,7 @@
state (a router, a framework) should call nv.transition() around its own
update and ignore the rest.
nv.transition(update, { direction, kind }) run an update continuously
nv.transition(update, { direction, kind, scope }) run an update continuously
nv.setTheme("dark" | "light" | "system") cross-fade the appearance
nv.openDialog(dialog, trigger) open it out of its trigger
nv.selectTab(tablist, tab) move through the same surface
@ -27,9 +27,16 @@
[data-nv-close] button that closes its dialog or popover
[data-nv-scroller] frame whose [data-nv-scroll] controls move
its .nv-scroller a page at a time
.nv-reveal / .nv-reveal-group content that arrives as it scrolls into
view, once (see continuity.css)
.nv-card--spotlight card whose light follows the pointer
Wiring also names every navigation item, which is what lets a travelling
selection marker pass *behind* the labels rather than over them.
Only what changes is lifted. A transition names the selection marker and
the items of the groups it is given as `scope`, and only for as long as it
runs; everything else stays in the one page snapshot. Naming every item on
the page permanently made every transition — a theme change included —
tear the page into dozens of layers, which is both slow and what pulled
items out from under frosted chrome.
Reduced motion is honoured by skipping the view transition entirely: the
update still runs, it simply arrives instead of travelling.
@ -51,7 +58,10 @@
/**
* Run `update` so the user can follow the change.
* `direction` ("forward" | "backward") tells content which way to travel;
* `kind` ("theme") marks a change the whole surface takes part in.
* `kind` ("theme") marks a change the whole surface takes part in;
* `scope` (an element or a list of them) names the selection groups whose
* marker should travel. Without a scope, nothing is lifted and the change
* cross-fades in place.
*/
function transition(update, options) {
options = options || {};
@ -63,11 +73,20 @@
if (options.direction) root.dataset.nvDirection = options.direction;
if (options.kind) root.dataset.nvTransition = options.kind;
var groups = [].concat(options.scope || []).filter(Boolean);
var lifted = [];
groups.forEach(function (group) {
group.setAttribute("data-nv-travelling", "");
lifted = lifted.concat(liftLabels(group));
});
var view = document.startViewTransition(update);
function clear() {
delete root.dataset.nvDirection;
delete root.dataset.nvTransition;
groups.forEach(function (group) { group.removeAttribute("data-nv-travelling"); });
lifted.forEach(function (item) { item.style.viewTransitionName = ""; });
}
view.finished.then(clear, clear);
@ -146,16 +165,32 @@
}
if (next < 0 || next === current) return Promise.resolve();
// The outgoing and incoming panels carry one name between them — the old
// one in the old state, the new one in the new — so the browser treats
// them as one surface whose content changes. Named only for the length of
// the change, so any number of tab sets can live on one page.
var panelOf = function (candidate) {
return candidate ? document.getElementById(candidate.getAttribute("aria-controls")) : null;
};
var outgoing = panelOf(tabs[current]);
var incoming = panelOf(tab);
if (outgoing && canTransition()) outgoing.style.viewTransitionName = "nv-panel";
var done = transition(function () {
tabs.forEach(function (candidate, index) {
var selected = index === next;
candidate.setAttribute("aria-selected", String(selected));
candidate.tabIndex = selected ? 0 : -1;
var panel = document.getElementById(candidate.getAttribute("aria-controls"));
var panel = panelOf(candidate);
if (panel) panel.hidden = !selected;
});
}, { direction: next > current ? "forward" : "backward" });
if (outgoing) outgoing.style.viewTransitionName = "";
if (incoming && canTransition()) incoming.style.viewTransitionName = "nv-panel";
}, { direction: next > current ? "forward" : "backward", scope: tablist });
var settle = function () { if (incoming) incoming.style.viewTransitionName = ""; };
done.then(settle, settle);
tab.focus();
return done;
@ -240,29 +275,84 @@
}
/**
* Give every item in a selection group a view-transition name, so it is
* captured as its own layer and rides *above* the travelling marker instead
* of being painted over by it.
* Give every item in `scope` a view-transition name, so it is captured as
* its own layer and rides *above* the travelling marker instead of being
* painted over by it. Returns the items it named, so the caller can take
* the names back off; transition() does this for its `scope` and clears
* them when the change is over.
*
* This cannot live in CSS: a view-transition-name must be unique across the
* whole document, and a stylesheet cannot count how many navigations a page
* happens to contain. Items already carrying a name are left alone, so an
* application can assign its own.
* whole document, and a stylesheet cannot count items. Items already
* carrying a name are left alone, so an application can assign its own.
*/
var carried = 0;
function liftLabels(scope) {
var groups = scope.querySelectorAll(".nv-nav, .nv-nav-tabs, [data-nv-travel]");
for (var i = 0; i < groups.length; i++) {
var items = groups[i].querySelectorAll(".nv-nav__item");
var items = scope.querySelectorAll(".nv-nav__item");
var named = [];
for (var j = 0; j < items.length; j++) {
if (items[j].style.viewTransitionName) continue;
carried += 1;
items[j].style.viewTransitionName = "nv-carried-" + carried;
named.push(items[j]);
}
return named;
}
/**
* Reveal content as it scrolls into view. Elements are only marked as
* waiting here, so a page without this script — or under reduced motion —
* never hides anything. Each element arrives once and is then left alone.
*/
var revealer = null;
function arrive(target) {
var items = target.classList.contains("nv-reveal-group") ? target.children : [target];
for (var i = 0; i < items.length; i++) {
var item = items[i];
item.style.setProperty("--nv-reveal-index", String(i));
item.dataset.nvReveal = "shown";
item.addEventListener("animationend", function settle(event) {
if (event.target !== this || event.animationName !== "nv-arrive") return;
this.removeEventListener("animationend", settle);
delete this.dataset.nvReveal;
this.style.removeProperty("--nv-reveal-index");
});
}
}
function initReveal(scope) {
if (prefersReducedMotion() || typeof global.IntersectionObserver !== "function") return;
revealer = revealer || new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
revealer.unobserve(entry.target);
arrive(entry.target);
});
}, { rootMargin: "0px 0px -8% 0px" });
scope.querySelectorAll(".nv-reveal, .nv-reveal-group").forEach(function (target) {
if (target.dataset.nvRevealWired) return;
target.dataset.nvRevealWired = "";
var items = target.classList.contains("nv-reveal-group") ? target.children : [target];
for (var i = 0; i < items.length; i++) items[i].dataset.nvReveal = "pending";
revealer.observe(target);
});
}
/**
* The light on a .nv-card--spotlight sits under the pointer. One listener
* for the whole document, so cards added later need no wiring.
*/
function trackSpotlight(event) {
if (event.pointerType && event.pointerType !== "mouse") return;
var surface = event.target.closest && event.target.closest(".nv-card--spotlight");
if (!surface) return;
var box = surface.getBoundingClientRect();
surface.style.setProperty("--nv-pointer-x", event.clientX - box.left + "px");
surface.style.setProperty("--nv-pointer-y", event.clientY - box.top + "px");
}
function wire(scope) {
@ -282,7 +372,7 @@
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
liftLabels(scope);
initReveal(scope);
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
control.addEventListener("click", function () {
@ -314,6 +404,8 @@
prefersReducedMotion: prefersReducedMotion,
};
document.addEventListener("pointermove", trackSpotlight, { passive: true });
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () { wire(); });
} else {

View file

@ -5,11 +5,13 @@
light-dark() in tokens/color.css. Like the light appearance, almost nothing
here is a literal color — every role is derived from --nv-brand.
Dark Niveus is not the light theme inverted. Surfaces get *lighter* as they
rise, shadows deepen rather than disappear, and the top highlight thins to a
faint rim so elevation still reads without glare. The accent moves up its
own ramp too: a mid-tone that reads as vivid on paper reads as muddy on a
dark canvas.
Dark Niveus is warm graphite, not the light theme inverted. Surfaces get
*lighter* as they rise and each step is large enough to see; shadows are
deep and every raised thing carries a near-black contact line, because on a
dark canvas a shadow alone has nothing left to darken. The bevel is what
carries depth here — a lit top edge, a shaded bottom one — and filled color
stays saturated: a vivid accent with a light label reads as a lit key, not
as a hole.
========================================================================== */
:root[data-theme="dark"] {
@ -19,20 +21,20 @@
:root,
[data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */
--nv-dark-background: color-mix(in oklab, #0b0e14, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-background-subtle: color-mix(in oklab, #10141b, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface: color-mix(in oklab, #171c24, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-elevated: color-mix(in oklab, #1e242e, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-inset: color-mix(in oklab, #10141b, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-hover: color-mix(in oklab, #222835, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-active: color-mix(in oklab, #2a3140, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-scrim: rgb(3 5 9 / 0.62);
--nv-dark-background: color-mix(in oklab, #0f0f12, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-background-subtle: color-mix(in oklab, #18181b, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface: color-mix(in oklab, #1f1f22, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-elevated: color-mix(in oklab, #2a2a2e, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-inset: color-mix(in oklab, #111113, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-hover: color-mix(in oklab, #313136, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-active: color-mix(in oklab, #39393f, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-scrim: rgb(6 6 8 / 0.62);
/* --- Text --------------------------------------------------------------- */
--nv-dark-text-primary: #e9edf4;
--nv-dark-text-secondary: color-mix(in oklab, #a3aebf, var(--nv-brand) 8%);
--nv-dark-text-tertiary: color-mix(in oklab, #7a8494, var(--nv-brand) 8%);
--nv-dark-text-disabled: #58616f;
--nv-dark-text-primary: #ecebe8;
--nv-dark-text-secondary: color-mix(in oklab, #a9a8a3, var(--nv-brand) 8%);
--nv-dark-text-tertiary: color-mix(in oklab, #7d7c77, var(--nv-brand) 8%);
--nv-dark-text-disabled: #58585c;
--nv-dark-text-inverted: var(--nv-palette-neutral-975);
/* --- Borders ------------------------------------------------------------ */
@ -41,30 +43,38 @@
--nv-dark-border-strong: rgb(255 255 255 / 0.2);
/* --- Accent -------------------------------------------------------------
Lifted up the ramp and slightly desaturated: saturated mid-tones read as
muddy on dark canvases, and fully saturated light ones read as neon. The
floor also puts every filled color above the pivot, so on a dark canvas
the derived label is always the dark one — which is what makes an accent
button read as lit rather than as a hole. */
--nv-dark-accent: oklch(from var(--nv-brand) clamp(0.72, calc(l + 0.12), 0.86) clamp(0.02, calc(c * 0.92), 0.2) h);
The same derivation as light: a fill is pushed out of the mid-tones until
its label clears 4.5:1, so the brand keeps its full saturation and a light
label. Earlier Niveus lifted the dark accent into pastel to avoid neon;
with bevel and sheen doing the shaping, a saturated fill reads as a lit
key instead, and the pastel was what made the whole system feel washed
out. Only the accent *text* (links, subtle labels) moves up the ramp,
because text on a dark canvas needs the lightness that a fill does not. */
--nv-dark-accent: oklch(from var(--nv-brand) calc(
clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
(1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
) clamp(0.02, c, 0.25) h);
--nv-dark-accent-hover: oklch(from var(--nv-dark-accent) calc(l + 0.05) c h);
--nv-dark-accent-active: oklch(from var(--nv-dark-accent) calc(l + 0.09) c h);
--nv-dark-accent-subtle: oklch(from var(--nv-dark-accent) l c h / 0.15);
--nv-dark-accent-subtle-hover: oklch(from var(--nv-dark-accent) l c h / 0.22);
--nv-dark-accent-border: oklch(from var(--nv-dark-accent) l c h / 0.38);
--nv-dark-accent-text: oklch(from var(--nv-dark-accent) clamp(0.72, calc(l + 0.08), 0.9) calc(c * 0.9) h);
--nv-dark-accent-subtle: oklch(from var(--nv-dark-accent) l c h / 0.2);
--nv-dark-accent-subtle-hover: oklch(from var(--nv-dark-accent) l c h / 0.28);
--nv-dark-accent-border: oklch(from var(--nv-dark-accent) l c h / 0.5);
--nv-dark-accent-text: oklch(from var(--nv-dark-accent) clamp(0.74, calc(l + 0.2), 0.9) calc(c * 0.9) h);
--nv-dark-on-accent: oklch(from var(--nv-dark-accent)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Success ------------------------------------------------------------ */
--nv-dark-success: oklch(from
color-mix(in oklab, var(--nv-palette-success-400), var(--nv-brand) var(--nv-brand-harmony))
clamp(0.72, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
color-mix(in oklab, var(--nv-palette-success-500), var(--nv-brand) var(--nv-brand-harmony))
calc(
clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
(1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
) clamp(0.02, c, 0.25) h);
--nv-dark-success-hover: oklch(from var(--nv-dark-success) calc(l + 0.05) c h);
--nv-dark-success-subtle: oklch(from var(--nv-dark-success) l c h / 0.15);
--nv-dark-success-subtle: oklch(from var(--nv-dark-success) l c h / 0.2);
--nv-dark-success-border: oklch(from var(--nv-dark-success) l c h / 0.35);
--nv-dark-success-text: oklch(from var(--nv-dark-success) clamp(0.74, calc(l + 0.08), 0.9) calc(c * 0.9) h);
--nv-dark-success-text: oklch(from var(--nv-dark-success) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h);
--nv-dark-on-success: oklch(from var(--nv-dark-success)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
@ -72,49 +82,78 @@
/* --- Warning ------------------------------------------------------------ */
--nv-dark-warning: oklch(from
color-mix(in oklab, var(--nv-palette-warning-400), var(--nv-brand) var(--nv-brand-harmony))
clamp(0.74, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
calc(
clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
(1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
) clamp(0.02, c, 0.25) h);
--nv-dark-warning-hover: oklch(from var(--nv-dark-warning) calc(l + 0.05) c h);
--nv-dark-warning-subtle: oklch(from var(--nv-dark-warning) l c h / 0.15);
--nv-dark-warning-subtle: oklch(from var(--nv-dark-warning) l c h / 0.2);
--nv-dark-warning-border: oklch(from var(--nv-dark-warning) l c h / 0.35);
--nv-dark-warning-text: oklch(from var(--nv-dark-warning) clamp(0.76, calc(l + 0.08), 0.9) calc(c * 0.9) h);
--nv-dark-warning-text: oklch(from var(--nv-dark-warning) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h);
--nv-dark-on-warning: oklch(from var(--nv-dark-warning)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Danger ------------------------------------------------------------- */
--nv-dark-danger: oklch(from
color-mix(in oklab, var(--nv-palette-danger-400), var(--nv-brand) var(--nv-brand-harmony))
clamp(0.72, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h);
color-mix(in oklab, var(--nv-palette-danger-500), var(--nv-brand) var(--nv-brand-harmony))
calc(
clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) +
(1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light))
) clamp(0.02, c, 0.25) h);
--nv-dark-danger-hover: oklch(from var(--nv-dark-danger) calc(l + 0.05) c h);
--nv-dark-danger-active: oklch(from var(--nv-dark-danger) calc(l + 0.09) c h);
--nv-dark-danger-subtle: oklch(from var(--nv-dark-danger) l c h / 0.15);
--nv-dark-danger-subtle: oklch(from var(--nv-dark-danger) l c h / 0.2);
--nv-dark-danger-border: oklch(from var(--nv-dark-danger) l c h / 0.35);
--nv-dark-danger-text: oklch(from var(--nv-dark-danger) clamp(0.74, calc(l + 0.08), 0.9) calc(c * 0.9) h);
--nv-dark-danger-text: oklch(from var(--nv-dark-danger) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h);
--nv-dark-on-danger: oklch(from var(--nv-dark-danger)
calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84)
calc(c * 0.2) h);
/* --- Depth and material -------------------------------------------------- */
--nv-dark-shadow-ambient: rgb(0 0 0 / 0.3);
--nv-dark-shadow-penumbra: rgb(0 0 0 / 0.4);
--nv-dark-shadow-umbra: rgb(0 0 0 / 0.35);
--nv-dark-shadow-inset: rgb(0 0 0 / 0.5);
--nv-dark-highlight: rgb(255 255 255 / 0.07);
--nv-dark-highlight-strong: rgb(255 255 255 / 0.12);
--nv-dark-shadow-ambient: rgb(0 0 0 / 0.5);
--nv-dark-shadow-penumbra: rgb(0 0 0 / 0.5);
--nv-dark-shadow-umbra: rgb(0 0 0 / 0.55);
--nv-dark-shadow-inset: rgb(0 0 0 / 0.65);
--nv-dark-highlight: rgb(255 255 255 / 0.15);
--nv-dark-highlight-strong: rgb(255 255 255 / 0.24);
/* The contact line is what separates graphite from graphite: a raised
surface over a dark canvas has no shadow to show, but it always has an
edge. */
--nv-dark-lowlight: rgb(0 0 0 / 0.5);
--nv-dark-outline: rgb(0 0 0 / 0.72);
--nv-dark-sheen-top: rgb(255 255 255 / 0.09);
--nv-dark-sheen-bottom: rgb(0 0 0 / 0.18);
--nv-dark-sheen-strong-top: rgb(255 255 255 / 0.3);
--nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.2);
--nv-dark-glow: oklch(from var(--nv-dark-accent) l c h / 0.5);
--nv-dark-knob: #f4f3ef;
--nv-dark-knob-shade: rgb(40 32 20 / 0.3);
/* The higher a surface sits, the more light reaches it. */
--nv-dark-elevation-tint-1: rgb(255 255 255 / 0.025);
--nv-dark-elevation-tint-2: rgb(255 255 255 / 0.042);
--nv-dark-elevation-tint-3: rgb(255 255 255 / 0.06);
--nv-dark-elevation-tint-4: rgb(255 255 255 / 0.078);
--nv-dark-elevation-tint-5: rgb(255 255 255 / 0.095);
--nv-dark-elevation-tint-1: rgb(255 255 255 / 0.03);
--nv-dark-elevation-tint-2: rgb(255 255 255 / 0.05);
--nv-dark-elevation-tint-3: rgb(255 255 255 / 0.07);
--nv-dark-elevation-tint-4: rgb(255 255 255 / 0.09);
--nv-dark-elevation-tint-5: rgb(255 255 255 / 0.11);
--nv-dark-material-frosted: color-mix(in oklab, rgb(23 28 36 / 0.72), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-material-glass: color-mix(in oklab, rgb(30 36 46 / 0.55), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-material-border: rgb(255 255 255 / 0.1);
--nv-dark-material-frosted: color-mix(in oklab, rgb(34 34 40 / 0.5), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-material-border: rgb(255 255 255 / 0.14);
/* --- Window controls --------------------------------------------------------- */
--nv-dark-window-close: var(--nv-palette-window-close);
--nv-dark-window-minimize: var(--nv-palette-window-minimize);
--nv-dark-window-zoom: var(--nv-palette-window-zoom);
--nv-dark-window-inactive: var(--nv-palette-neutral-700);
--nv-dark-window-control-ring: rgb(0 0 0 / 0.35);
--nv-dark-window-glyph: rgb(0 0 0 / 0.6);
/* --- Interaction ---------------------------------------------------------- */
--nv-dark-focus-ring: oklch(from var(--nv-dark-accent) l c h / 0.5);
--nv-dark-focus-ring-contrast: rgb(6 8 12 / 0.9);
--nv-dark-selection: oklch(from var(--nv-dark-accent) l c h / 0.3);
--nv-dark-focus-ring: oklch(from var(--nv-dark-accent-text) l c h / 0.55);
--nv-dark-focus-ring-contrast: rgb(8 8 10 / 0.9);
--nv-dark-selection: oklch(from var(--nv-dark-accent) l c h / 0.4);
}

View file

@ -10,9 +10,12 @@
how much of the brand bleeds into the greys. See tokens/color.css for the
inputs.
Light Niveus reads as paper on a cool, slightly recessed canvas: surfaces are
near-white, depth comes from soft shadow rather than from contrast, and a
crisp top highlight gives raised elements their edge.
Light Niveus reads as soft paper and enamel on a warm grey desk: surfaces
that hold content stay near-white, so documents never look yellowed; the
canvas around them is a few steps warmer and darker, so a window sits *on*
something. Raised controls are bevelled — a bright top edge, a soft shade
along the bottom, light falling across the face — and shadows are warm,
the color of shade on paper rather than of a grey filter.
========================================================================== */
:root {
@ -32,14 +35,14 @@
Paper stays paper: the two surfaces that hold content are left untinted so
text keeps its contrast. Everything the surfaces sit *in* picks up a trace
of the brand, which is what stops an accent from looking stuck on. */
--nv-light-background: color-mix(in oklab, #e9ecf3, var(--nv-brand) var(--nv-brand-tint));
--nv-light-background-subtle: color-mix(in oklab, #eff1f6, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface: var(--nv-palette-neutral-0);
--nv-light-surface-elevated: var(--nv-palette-neutral-0);
--nv-light-surface-inset: color-mix(in oklab, #e4e8f0, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-hover: color-mix(in oklab, #f4f6fa, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-active: color-mix(in oklab, #e8ebf2, var(--nv-brand) var(--nv-brand-tint));
--nv-light-scrim: rgb(16 21 30 / 0.32);
--nv-light-background: color-mix(in oklab, #e8e5de, var(--nv-brand) var(--nv-brand-tint));
--nv-light-background-subtle: color-mix(in oklab, #eeece6, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface: #fbfbfa;
--nv-light-surface-elevated: #f8f8f6;
--nv-light-surface-inset: color-mix(in oklab, #eeede8, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-hover: color-mix(in oklab, #f1f0ec, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-active: color-mix(in oklab, #e6e5e0, var(--nv-brand) var(--nv-brand-tint));
--nv-light-scrim: rgb(28 24 18 / 0.34);
/* --- Text ---------------------------------------------------------------
Primary text stays neutral — it carries the reading contrast. The quieter
@ -52,9 +55,9 @@
--nv-light-text-inverted: var(--nv-palette-neutral-0);
/* --- Borders ------------------------------------------------------------ */
--nv-light-border-subtle: rgb(16 21 30 / 0.06);
--nv-light-border: rgb(16 21 30 / 0.11);
--nv-light-border-strong: rgb(16 21 30 / 0.2);
--nv-light-border-subtle: rgb(28 24 18 / 0.07);
--nv-light-border: rgb(28 24 18 / 0.13);
--nv-light-border-strong: rgb(28 24 18 / 0.22);
/* --- Accent --------------------------------------------------------------
Every filled color is pushed away from the middle of the lightness range
@ -132,13 +135,27 @@
/* --- Depth and material -------------------------------------------------
Three shadow colors build every elevation level: an ambient contact
shadow, a soft penumbra, and a deeper umbra for high elevations. */
--nv-light-shadow-ambient: rgb(16 21 30 / 0.05);
--nv-light-shadow-penumbra: rgb(16 21 30 / 0.08);
--nv-light-shadow-umbra: rgb(16 21 30 / 0.06);
--nv-light-shadow-inset: rgb(16 21 30 / 0.12);
--nv-light-highlight: rgb(255 255 255 / 0.85);
--nv-light-shadow-ambient: rgb(40 32 20 / 0.08);
--nv-light-shadow-penumbra: rgb(40 32 20 / 0.1);
--nv-light-shadow-umbra: rgb(40 32 20 / 0.16);
--nv-light-shadow-inset: rgb(40 32 20 / 0.14);
--nv-light-highlight: rgb(255 255 255 / 0.9);
--nv-light-highlight-strong: rgb(255 255 255 / 1);
/* Paper already has a border doing the contact work, so the outline only
softens it; the bottom shade is faint for the same reason. */
--nv-light-lowlight: rgb(40 32 20 / 0.09);
--nv-light-outline: rgb(40 32 20 / 0.05);
--nv-light-sheen-top: rgb(255 255 255 / 0.9);
--nv-light-sheen-bottom: rgb(40 32 20 / 0.07);
--nv-light-sheen-strong-top: rgb(255 255 255 / 0.32);
--nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16);
--nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3);
--nv-light-knob: var(--nv-palette-neutral-0);
--nv-light-knob-shade: rgb(40 32 20 / 0.13);
/* Light surfaces are already lighter than their canvas; the shadow alone
carries elevation, so there is nothing to add. */
--nv-light-elevation-tint-1: transparent;
@ -147,9 +164,17 @@
--nv-light-elevation-tint-4: transparent;
--nv-light-elevation-tint-5: transparent;
--nv-light-material-frosted: rgb(255 255 255 / 0.72);
--nv-light-material-glass: rgb(255 255 255 / 0.5);
--nv-light-material-border: rgb(255 255 255 / 0.55);
--nv-light-material-frosted: rgb(252 252 250 / 0.56);
--nv-light-material-glass: rgb(252 252 250 / 0.4);
--nv-light-material-border: rgb(255 255 255 / 0.72);
/* --- Window controls --------------------------------------------------------- */
--nv-light-window-close: var(--nv-palette-window-close);
--nv-light-window-minimize: var(--nv-palette-window-minimize);
--nv-light-window-zoom: var(--nv-palette-window-zoom);
--nv-light-window-inactive: var(--nv-palette-neutral-300);
--nv-light-window-control-ring: rgb(0 0 0 / 0.16);
--nv-light-window-glyph: rgb(0 0 0 / 0.55);
/* --- Interaction ----------------------------------------------------------- */
--nv-light-focus-ring: oklch(from var(--nv-light-accent) l c h / 0.4);

View file

@ -53,25 +53,37 @@
}
:root {
/* --- Neutral ---------------------------------------------------------- */
/* --- Neutral ----------------------------------------------------------
Warm graphite to warm paper. A grey that leans very slightly towards
yellow reads as a material (metal, enamel, paper); a grey that leans blue
reads as a screen. The lean is small enough that the brand tint still
decides the family. */
--nv-palette-neutral-0: #ffffff;
--nv-palette-neutral-25: #fbfcfe;
--nv-palette-neutral-50: #f7f8fb;
--nv-palette-neutral-100: #f1f3f8;
--nv-palette-neutral-150: #e8ebf2;
--nv-palette-neutral-200: #dfe3ec;
--nv-palette-neutral-300: #ccd2de;
--nv-palette-neutral-400: #a7b0c0;
--nv-palette-neutral-500: #7c8797;
--nv-palette-neutral-600: #5b6574;
--nv-palette-neutral-700: #434b59;
--nv-palette-neutral-800: #2d3340;
--nv-palette-neutral-850: #232936;
--nv-palette-neutral-900: #1a1f29;
--nv-palette-neutral-925: #151a22;
--nv-palette-neutral-950: #10141b;
--nv-palette-neutral-975: #0b0e14;
--nv-palette-neutral-1000: #06080c;
--nv-palette-neutral-25: #fcfcfb;
--nv-palette-neutral-50: #f8f8f6;
--nv-palette-neutral-100: #f2f1ee;
--nv-palette-neutral-150: #eae9e5;
--nv-palette-neutral-200: #e1e0db;
--nv-palette-neutral-300: #cfcec8;
--nv-palette-neutral-400: #aeada7;
--nv-palette-neutral-500: #87867f;
--nv-palette-neutral-600: #63625d;
--nv-palette-neutral-700: #4a4945;
--nv-palette-neutral-800: #343337;
--nv-palette-neutral-850: #2a2a2d;
--nv-palette-neutral-900: #1d1d20;
--nv-palette-neutral-925: #18181a;
--nv-palette-neutral-950: #131315;
--nv-palette-neutral-975: #0e0e10;
--nv-palette-neutral-1000: #08080a;
/* --- Window controls ----------------------------------------------------
The three lights at the start of a titlebar. They are the one place the
system uses fixed colors instead of the brand: their meaning is learned
from position and color together, and re-coloring them would unteach it. */
--nv-palette-window-close: #ff5f57;
--nv-palette-window-minimize: #febc2e;
--nv-palette-window-zoom: #28c840;
/* --- Accent (indigo) --------------------------------------------------- */
--nv-palette-accent-50: #eef2ff;
@ -198,6 +210,33 @@
--nv-color-highlight: light-dark(var(--nv-light-highlight), var(--nv-dark-highlight));
--nv-color-highlight-strong: light-dark(var(--nv-light-highlight-strong), var(--nv-dark-highlight-strong));
/* The bevel's other half: the shade along a raised surface's bottom edge,
and the contact line around anything that sits on another surface. Light
along the top and shade along the bottom is what makes a control read as
something pressable instead of a painted rectangle. */
--nv-color-lowlight: light-dark(var(--nv-light-lowlight), var(--nv-dark-lowlight));
--nv-color-outline: light-dark(var(--nv-light-outline), var(--nv-dark-outline));
/* Sheen: the light falling across a raised face, top to bottom. The plain
pair is for neutral controls and surfaces; the strong pair is for filled
color (primary buttons, checked marks), where it has to read through a
saturated fill. Both are laid over a surface's own color, never instead
of it. */
--nv-color-sheen-top: light-dark(var(--nv-light-sheen-top), var(--nv-dark-sheen-top));
--nv-color-sheen-bottom: light-dark(var(--nv-light-sheen-bottom), var(--nv-dark-sheen-bottom));
--nv-color-sheen-strong-top: light-dark(var(--nv-light-sheen-strong-top), var(--nv-dark-sheen-strong-top));
--nv-color-sheen-strong-bottom: light-dark(var(--nv-light-sheen-strong-bottom), var(--nv-dark-sheen-strong-bottom));
/* Glow: light a surface gives off, as opposed to the shade it casts. It is
the accent seen through air — strong enough in dark to read as a lit
edge, faint in light, where paper does not emit. */
--nv-color-glow: light-dark(var(--nv-light-glow), var(--nv-dark-glow));
/* A knob: the thumb of a switch or a slider. It stays light in both
appearances, the way a physical knob does not change color with the room. */
--nv-color-knob: light-dark(var(--nv-light-knob), var(--nv-dark-knob));
--nv-color-knob-shade: light-dark(var(--nv-light-knob-shade), var(--nv-dark-knob-shade));
/* Elevation tint: on a dark canvas a shadow has nothing to darken, so a
raised surface also has to gain light. Transparent in light, where the
shadow does all the work. */
@ -211,6 +250,14 @@
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
/* --- Window controls ------------------------------------------------------- */
--nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));
--nv-color-window-minimize: light-dark(var(--nv-light-window-minimize), var(--nv-dark-window-minimize));
--nv-color-window-zoom: light-dark(var(--nv-light-window-zoom), var(--nv-dark-window-zoom));
--nv-color-window-inactive: light-dark(var(--nv-light-window-inactive), var(--nv-dark-window-inactive));
--nv-color-window-control-ring: light-dark(var(--nv-light-window-control-ring), var(--nv-dark-window-control-ring));
--nv-color-window-glyph: light-dark(var(--nv-light-window-glyph), var(--nv-dark-window-glyph));
/* --- Interaction --------------------------------------------------------- */
--nv-color-focus-ring: light-dark(var(--nv-light-focus-ring), var(--nv-dark-focus-ring));
--nv-color-focus-ring-contrast: light-dark(var(--nv-light-focus-ring-contrast), var(--nv-dark-focus-ring-contrast));

View file

@ -22,34 +22,35 @@
--nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient),
0 1px 3px -1px var(--nv-color-shadow-penumbra);
0 2px 4px -1px var(--nv-color-shadow-penumbra),
0 6px 14px -8px var(--nv-color-shadow-umbra);
--nv-elevation-2:
0 1px 1px var(--nv-color-shadow-ambient),
0 2px 4px -1px var(--nv-color-shadow-penumbra),
0 4px 8px -4px var(--nv-color-shadow-umbra);
0 3px 6px -2px var(--nv-color-shadow-penumbra),
0 10px 20px -10px var(--nv-color-shadow-umbra);
--nv-elevation-3:
0 1px 1px var(--nv-color-shadow-ambient),
0 4px 8px -2px var(--nv-color-shadow-penumbra),
0 10px 20px -6px var(--nv-color-shadow-umbra);
0 1px 2px var(--nv-color-shadow-ambient),
0 6px 12px -4px var(--nv-color-shadow-penumbra),
0 18px 36px -12px var(--nv-color-shadow-umbra);
--nv-elevation-4:
0 1px 2px var(--nv-color-shadow-ambient),
0 8px 16px -4px var(--nv-color-shadow-penumbra),
0 20px 40px -10px var(--nv-color-shadow-umbra);
0 10px 20px -6px var(--nv-color-shadow-penumbra),
0 30px 60px -16px var(--nv-color-shadow-umbra);
--nv-elevation-5:
0 1px 2px var(--nv-color-shadow-ambient),
0 14px 28px -6px var(--nv-color-shadow-penumbra),
0 32px 64px -14px var(--nv-color-shadow-umbra);
0 2px 3px var(--nv-color-shadow-ambient),
0 16px 32px -8px var(--nv-color-shadow-penumbra),
0 44px 88px -20px var(--nv-color-shadow-umbra);
/* --- Inset ---------------------------------------------------------------
Recessed surfaces: fields, wells, tracks, pressed controls. */
--nv-elevation-inset-sm: inset 0 1px 2px var(--nv-color-shadow-inset);
--nv-elevation-inset-md:
inset 0 1px 1px var(--nv-color-shadow-inset),
inset 0 2px 5px -1px var(--nv-color-shadow-inset);
inset 0 3px 6px -2px var(--nv-color-shadow-inset);
--nv-elevation-inset-lg:
inset 0 2px 3px var(--nv-color-shadow-inset),
inset 0 6px 12px -3px var(--nv-color-shadow-inset);
@ -64,6 +65,29 @@
inset 0 1px 0 var(--nv-color-highlight),
inset 0 0 0 1px var(--nv-color-border-subtle);
/* --- Bevel -----------------------------------------------------------------
The highlight's other half. A raised face is lit along its top edge and
shaded along its bottom one; together they are the bevel, and the bevel is
what makes a Niveus control look like something a finger could press.
Raised controls and surfaces take the full bevel; the highlight alone is
for things that sit flush. */
--nv-lowlight-bottom: inset 0 -1px 0 var(--nv-color-lowlight);
--nv-bevel:
inset 0 1px 0 var(--nv-color-highlight),
inset 0 -1px 0 var(--nv-color-lowlight);
/* --- Outline ---------------------------------------------------------------
The contact line: a hairline just outside a raised surface, where it meets
whatever it rests on. In dark it is near-black and does most of the
separating; in light it only softens the border beside it. */
--nv-outline: 0 0 0 1px var(--nv-color-outline);
/* --- Ledge -----------------------------------------------------------------
A recess catches light on its *lower* lip, the edge facing the viewer.
Paired with an inset shadow, it turns a field from a dark rectangle into
a well cut into the surface. */
--nv-ledge: 0 1px 0 var(--nv-color-highlight);
/* --- Focus ---------------------------------------------------------------
A contrast ring sits under the accent ring so focus stays visible on both
light and dark surfaces, and on accent-filled controls. */
@ -71,4 +95,30 @@
--nv-focus-ring:
0 0 0 1px var(--nv-color-focus-ring-contrast),
0 0 0 calc(1px + var(--nv-focus-ring-width)) var(--nv-color-focus-ring);
/* --- Glow ------------------------------------------------------------------
Light a surface emits rather than the shade it casts. Glow is for things
that are *on* — a live status, the selected item in a showcase, a lit key —
and it always sits beside an elevation, never instead of one: a glowing
surface still rests on something.
The geometry is a scale like elevation. The color is the component's: a
tinted surface glows in its own tint, mixed down to --nv-glow-strength. */
--nv-glow-strength: 40%;
--nv-glow-blur-sm: 12px;
--nv-glow-spread-sm: -2px;
--nv-glow-blur-md: 28px;
--nv-glow-spread-md: -6px;
--nv-glow-blur-lg: 64px;
--nv-glow-spread-lg: -12px;
}
/* The accent glows are declared on every branded subtree as well as the root:
a var() inside a custom property resolves where the property is declared, so
a root-only declaration would glow in the root brand everywhere. */
:root,
[data-nv-brand] {
--nv-glow-sm: 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) var(--nv-color-glow);
--nv-glow-md: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) var(--nv-color-glow);
--nv-glow-lg: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-lg) var(--nv-color-glow);
}

View file

@ -8,29 +8,50 @@
solid opaque, stable. The default. Content lives here.
elevated solid with depth and a top highlight. Separated from context.
inset recessed into its parent. Fields, wells, tracks.
frosted translucent, heavily blurred. Chrome that sits over content:
navigation bars, toolbars, sticky headers.
glass more translucent, less blurred. Contextual layers where the
frosted translucent, moderately blurred, bevelled. Chrome that sits
over content: bars, docks, launchers, notifications, menus.
glass more translucent, lightly blurred. Contextual layers where the
content behind should stay legible: overlays, hover cards.
Translucency has a cost — it tints whatever is behind it and forces the GPU
to composite. Use frosted and glass for layers that move over content, not
for content itself.
Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
and everything that holds content are solid; translucency is reserved for
the chrome that floats over them. Translucency has a cost — it tints
whatever is behind it and forces the GPU to composite — and used everywhere
it stops meaning "this floats".
Sheen is the light falling across a raised face. It is laid over a
surface's own color as background-image, so a surface keeps its role color
and gains the gradient; anything that does not draw gradients simply shows
the role color.
========================================================================== */
:root {
/* --- Blur ---------------------------------------------------------------- */
--nv-blur-sm: 8px;
--nv-blur-md: 16px;
--nv-blur-lg: 28px;
--nv-blur-xl: 44px;
--nv-blur-sm: 12px;
--nv-blur-md: 24px;
--nv-blur-lg: 36px;
--nv-blur-xl: 56px;
/* Backdrops lose saturation when blurred; a touch of saturate() puts the
color back so frosted surfaces do not read as gray films. */
color back so frosted surfaces do not read as gray films. Moderate on
purpose: the blur says "this floats", it is not the look itself. */
--nv-backdrop-saturate: 180%;
--nv-backdrop-frosted: blur(var(--nv-blur-lg)) saturate(var(--nv-backdrop-saturate));
--nv-backdrop-glass: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
--nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(120%);
--nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate));
--nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate));
/* --- Sheen ------------------------------------------------------------------
raised neutral controls: buttons, the selected tab, a titlebar
filled saturated fills: primary and danger buttons, checked marks
surface large surfaces: windows, dialogs, cards. Brightest along the
top edge, easing through the middle and settling into a faint
shade at the foot, so a panel reads as a curved, lit body
rather than as a flat sheet.
knob a round thumb: lit from above, shaded towards its rim */
--nv-sheen-raised: linear-gradient(var(--nv-color-sheen-top), var(--nv-color-sheen-bottom));
--nv-sheen-filled: linear-gradient(var(--nv-color-sheen-strong-top), var(--nv-color-sheen-strong-bottom));
--nv-sheen-surface: linear-gradient(var(--nv-color-sheen-top), transparent 7rem, transparent 55%, var(--nv-color-sheen-bottom));
--nv-sheen-knob: radial-gradient(circle at 50% 22%, var(--nv-color-highlight-strong), transparent 45%), radial-gradient(circle at 50% 30%, transparent 40%, var(--nv-color-knob-shade));
--nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(140%);
/* --- Material recipes -----------------------------------------------------
Each recipe is the full set of properties that defines the material.
@ -41,19 +62,19 @@
--nv-material-elevated-bg: var(--nv-color-surface-elevated);
--nv-material-elevated-border: var(--nv-color-border-subtle);
--nv-material-elevated-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
--nv-material-elevated-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
--nv-material-inset-bg: var(--nv-color-surface-inset);
--nv-material-inset-border: var(--nv-color-border-subtle);
--nv-material-inset-shadow: var(--nv-elevation-inset-md);
--nv-material-inset-shadow: var(--nv-elevation-inset-md), var(--nv-ledge);
--nv-material-frosted-bg: var(--nv-color-material-frosted);
--nv-material-frosted-border: var(--nv-color-material-border);
--nv-material-frosted-shadow: var(--nv-highlight-top), var(--nv-elevation-3);
--nv-material-frosted-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
--nv-material-glass-bg: var(--nv-color-material-glass);
--nv-material-glass-border: var(--nv-color-material-border);
--nv-material-glass-shadow: var(--nv-highlight-top-strong), var(--nv-elevation-4);
--nv-material-glass-shadow: var(--nv-highlight-top-strong), var(--nv-outline), var(--nv-elevation-4);
}
/* Where translucency cannot be honoured — reduced transparency, or a browser

View file

@ -82,6 +82,15 @@
--nv-travel-sm: 4px;
--nv-travel-md: 8px;
--nv-travel-lg: 16px;
/* --- Sequence ---------------------------------------------------------------
The gap between siblings arriving one after another. Short enough that a
group reads as one movement, long enough that the eye sees an order. */
--nv-stagger: 60ms;
/* A loading shimmer is a slow, constant sweep — slow enough to read as
"waiting", never fast enough to read as an alert. */
--nv-duration-shimmer: 1600ms;
}
/* Reduced motion is a system-wide contract, not a per-component opt-in: cut
@ -103,5 +112,6 @@
--nv-travel-sm: 0px;
--nv-travel-md: 0px;
--nv-travel-lg: 0px;
--nv-stagger: 0ms;
}
}

View file

@ -2,18 +2,19 @@
Niveus — Radius
--------------------------------------------------------------------------
Corner softness carries as much of the Niveus character as shadow does.
Radii grow with the surface: controls stay modest, containers get rounder,
and only pills and avatars go fully round.
Radii grow with the surface: controls stay modest and machined, containers
get noticeably rounder — a window or a popover is a soft object, a button
is a key — and only pills, knobs and avatars go fully round.
========================================================================== */
:root {
--nv-radius-none: 0;
--nv-radius-xs: 0.25rem; /* 4px — tags, swatches, nested chips */
--nv-radius-sm: 0.5rem; /* 8px — small controls, menu items */
--nv-radius-md: 0.75rem; /* 12px — buttons, inputs, default control radius */
--nv-radius-lg: 1rem; /* 16px — cards, panels */
--nv-radius-xl: 1.375rem; /* 22px — dialogs, sheets, large containers */
--nv-radius-2xl: 1.75rem; /* 28px — hero surfaces */
--nv-radius-md: 0.625rem; /* 10px — buttons, inputs, default control radius */
--nv-radius-lg: 0.875rem; /* 14px — cards, panels, menus */
--nv-radius-xl: 1.25rem; /* 20px — windows, dialogs, sheets, popovers */
--nv-radius-2xl: 1.5rem; /* 24px — docks, hero surfaces */
--nv-radius-full: 999rem; /* — pills, avatars, switches */
/* Nested radius: an inner surface inset by N should be N smaller than its

View file

@ -7,11 +7,13 @@
:root {
/* --- Families ----------------------------------------------------------- */
/* Both ship with Niveus (fonts.css); the rest of each stack only covers the
moment before they load, or a context that cannot load them. */
--nv-font-family-sans:
"Inter var", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Helvetica Neue", Arial, sans-serif;
"Instrument Sans", "Inter var", "Inter", -apple-system, BlinkMacSystemFont,
"Segoe UI", "Helvetica Neue", Arial, sans-serif;
--nv-font-family-mono:
"SF Mono", "JetBrains Mono", "IBM Plex Mono", ui-monospace, "Menlo",
"JetBrains Mono", "SF Mono", "IBM Plex Mono", ui-monospace, "Menlo",
"Consolas", monospace;
/* --- Size scale ---------------------------------------------------------- */
@ -46,6 +48,8 @@
--nv-letter-spacing-normal: 0em;
--nv-letter-spacing-wide: 0.02em;
--nv-letter-spacing-wider: 0.06em;
/* Engraved labels: small mono capitals, spaced like markings on a panel. */
--nv-letter-spacing-engraved: 0.1em;
/* --- Composite roles ------------------------------------------------------
Applied as classes in base.css (.nv-display, .nv-heading, …). Kept as
@ -65,6 +69,11 @@
var(--nv-line-height-normal) var(--nv-font-family-sans);
--nv-font-code: var(--nv-font-weight-regular) var(--nv-font-size-sm) /
var(--nv-line-height-normal) var(--nv-font-family-mono);
/* The engraved label: section markers, the clock, anything that names a
region rather than being read. Paired with uppercase and the engraved
tracking (see .nv-overline in base.css). */
--nv-font-engraved: var(--nv-font-weight-medium) var(--nv-font-size-2xs) /
var(--nv-line-height-snug) var(--nv-font-family-mono);
/* --- Measure --------------------------------------------------------------- */
--nv-measure-narrow: 44ch;

View file

@ -0,0 +1,944 @@
"""Evaluate the subset of CSS that Niveus's token layer is written in.
Niveus derives its entire color system from one input with relative color
syntax, `color-mix()` and `calc()`. None of that survives a copy-paste into
another platform's resources (Android XML, a Qt header, GTK CSS), and
re-stating the results by hand would fork the system: the CSS would change and
the port would quietly keep the old values.
So this module implements the language instead, once, here — every port's
generator imports it rather than carrying its own copy.
It is deliberately narrow — it covers exactly what `niveus/tokens/*.css` and
`niveus/themes/*.css` use:
#rrggbb / #rgb / #rrggbbaa rgb(r g b / a) transparent
oklch(L C H) oklch(from <color> L C H [/ A])
color-mix(in oklab, <a>, <b> <pct>)
light-dark(<a>, <b>) var(--x[, fallback])
calc(...) clamp(min,v,max) min(...) max(...)
Inside `oklch(from X ...)` the identifiers `l`, `c`, `h` and `alpha` are bound
to X's own OKLCH channels, which is what makes one brand value re-colour
everything.
Color math follows CSS Color 4: sRGB <-> linear sRGB <-> OKLab <-> OKLCH, and
out-of-gamut results are gamut-mapped by chroma reduction against deltaEOK,
the same approach browsers take. `color-mix()` mixes premultiplied, in OKLab.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
# ---------------------------------------------------------------------------
# Color space conversions (CSS Color 4)
# ---------------------------------------------------------------------------
def _srgb_to_linear(v: float) -> float:
return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4
def _linear_to_srgb(v: float) -> float:
return v * 12.92 if v <= 0.0031308 else 1.055 * (v ** (1 / 2.4)) - 0.055
# linear sRGB -> LMS -> OKLab, per Björn Ottosson.
_LSRGB_TO_LMS = (
(0.4122214708, 0.5363325363, 0.0514459929),
(0.2119034982, 0.6806995451, 0.1073969566),
(0.0883024619, 0.2817188376, 0.6299787005),
)
_LMS_TO_OKLAB = (
(0.2104542553, 0.7936177850, -0.0040720468),
(1.9779984951, -2.4285922050, 0.4505937099),
(0.0259040371, 0.7827717662, -0.8086757660),
)
_OKLAB_TO_LMS = (
(1.0, 0.3963377774, 0.2158037573),
(1.0, -0.1055613458, -0.0638541728),
(1.0, -0.0894841775, -1.2914855480),
)
_LMS_TO_LSRGB = (
(4.0767416621, -3.3077115913, 0.2309699292),
(-1.2684380046, 2.6097574011, -0.3413193965),
(-0.0041960863, -0.7034186147, 1.7076147010),
)
def _mul(m, v):
return tuple(sum(r[i] * v[i] for i in range(3)) for r in m)
def srgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]:
lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b))
lms = _mul(_LSRGB_TO_LMS, lin)
lms_ = tuple(math.copysign(abs(x) ** (1 / 3), x) for x in lms)
return _mul(_LMS_TO_OKLAB, lms_)
def oklab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]:
lms_ = _mul(_OKLAB_TO_LMS, (L, a, b))
lms = tuple(x ** 3 for x in lms_)
lin = _mul(_LMS_TO_LSRGB, lms)
return tuple(_linear_to_srgb(x) for x in lin)
def oklab_to_oklch(L: float, a: float, b: float) -> tuple[float, float, float]:
c = math.hypot(a, b)
# Below this chroma the hue is meaningless; CSS treats it as `none`, and
# carrying a garbage angle through a derivation produces garbage hues.
h = 0.0 if c < 1e-6 else math.degrees(math.atan2(b, a)) % 360.0
return (L, c, h)
def oklch_to_oklab(L: float, c: float, h: float) -> tuple[float, float, float]:
rad = math.radians(h)
return (L, c * math.cos(rad), c * math.sin(rad))
# ---------------------------------------------------------------------------
# CIELAB, for the Android system palette
# ---------------------------------------------------------------------------
#
# Niveus works in OKLab, but Android's tonal palette is defined in terms of
# *tone*, which is CIE L* — the same scale Material You's HCT uses for its T
# channel. Generating those ramps in OKLab lightness would put every tone at a
# slightly wrong luminance, so the palette gets its own, correct, colour space
# rather than an approximation.
# sRGB (D65) -> XYZ
_LRGB_TO_XYZ = (
(0.4123907993, 0.3575843394, 0.1804807884),
(0.2126390059, 0.7151686788, 0.0721923154),
(0.0193308187, 0.1191947798, 0.9505321522),
)
_XYZ_TO_LRGB = (
(3.2409699419, -1.5373831776, -0.4986107603),
(-0.9692436363, 1.8759675015, 0.0415550574),
(0.0556300797, -0.2039769589, 1.0569715142),
)
# D65 white point
_WHITE = (0.9504559271, 1.0, 1.0890577508)
_EPS = 216 / 24389
_KAPPA = 24389 / 27
def _f(t: float) -> float:
return t ** (1 / 3) if t > _EPS else (_KAPPA * t + 16) / 116
def _f_inv(t: float) -> float:
t3 = t ** 3
return t3 if t3 > _EPS else (116 * t - 16) / _KAPPA
def srgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]:
"""sRGB to CIE L*a*b*. L* is 0-100 and is what Android calls tone."""
lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b))
x, y, z = _mul(_LRGB_TO_XYZ, lin)
fx, fy, fz = _f(x / _WHITE[0]), _f(y / _WHITE[1]), _f(z / _WHITE[2])
return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz))
def lab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]:
fy = (L + 16) / 116
fx = fy + a / 500
fz = fy - b / 200
xyz = (_f_inv(fx) * _WHITE[0], _f_inv(fy) * _WHITE[1], _f_inv(fz) * _WHITE[2])
return tuple(_linear_to_srgb(v) for v in _mul(_XYZ_TO_LRGB, xyz))
def lch_to_srgb_clamped(L: float, C: float, H: float) -> tuple[float, float, float]:
"""A tone at a given hue and chroma, reduced until it fits sRGB.
Tone is fixed — the whole point of a tonal palette is that tone 40 is the
same lightness in every ramp — so chroma is what gives way, never L*.
"""
rad = math.radians(H)
lo, hi = 0.0, max(C, 0.0)
best = lab_to_srgb(L, 0.0, 0.0)
for _ in range(28):
mid = (lo + hi) / 2
candidate = lab_to_srgb(L, mid * math.cos(rad), mid * math.sin(rad))
if all(-1e-4 <= v <= 1 + 1e-4 for v in candidate):
best = candidate
lo = mid
else:
hi = mid
return tuple(min(1.0, max(0.0, v)) for v in best)
# ---------------------------------------------------------------------------
# Values
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Num:
value: float
unit: str = ""
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"Num({self.value}{self.unit})"
@dataclass(frozen=True)
class Color:
"""A color held in OKLab plus alpha. `none` means fully transparent."""
L: float
a: float
b: float
alpha: float = 1.0
@staticmethod
def from_srgb(r: float, g: float, bl: float, alpha: float = 1.0) -> "Color":
L, a, b = srgb_to_oklab(r, g, bl)
return Color(L, a, b, alpha)
@property
def oklch(self) -> tuple[float, float, float]:
return oklab_to_oklch(self.L, self.a, self.b)
def to_srgb(self) -> tuple[float, float, float]:
return oklab_to_srgb(self.L, self.a, self.b)
# -- gamut mapping ----------------------------------------------------
def _in_gamut(self, eps: float = 1e-4) -> bool:
return all(-eps <= x <= 1 + eps for x in self.to_srgb())
def gamut_mapped(self) -> tuple[float, float, float]:
"""sRGB triple, chroma-reduced into gamut (CSS Color 4 §13.2)."""
if self._in_gamut():
return tuple(min(1.0, max(0.0, x)) for x in self.to_srgb())
L, C, H = self.oklch
if L >= 1.0:
return (1.0, 1.0, 1.0)
if L <= 0.0:
return (0.0, 0.0, 0.0)
lo, hi = 0.0, C
clipped = None
for _ in range(32):
mid = (lo + hi) / 2
candidate = Color(*oklch_to_oklab(L, mid, H), self.alpha)
raw = candidate.to_srgb()
if candidate._in_gamut():
lo = mid
clipped = tuple(min(1.0, max(0.0, x)) for x in raw)
continue
clip = tuple(min(1.0, max(0.0, x)) for x in raw)
# deltaEOK between the candidate and its clipped form: once the
# clip is imperceptible we accept it rather than desaturating more.
cl = Color.from_srgb(*clip)
if math.dist((candidate.L, candidate.a, candidate.b), (cl.L, cl.a, cl.b)) < 0.02:
return clip
hi = mid
if clipped is not None:
return clipped
return tuple(min(1.0, max(0.0, x)) for x in self.to_srgb())
# -- output -----------------------------------------------------------
def hex8(self) -> str:
"""#AARRGGBB — Android's order."""
r, g, b = self.gamut_mapped()
a = min(1.0, max(0.0, self.alpha))
return "#{:02X}{:02X}{:02X}{:02X}".format(
round(a * 255), round(r * 255), round(g * 255), round(b * 255)
)
def hex_rgb(self) -> str:
r, g, b = self.gamut_mapped()
return "#{:02X}{:02X}{:02X}".format(round(r * 255), round(g * 255), round(b * 255))
def argb_int(self) -> int:
r, g, b = self.gamut_mapped()
a = min(1.0, max(0.0, self.alpha))
return (
(round(a * 255) << 24)
| (round(r * 255) << 16)
| (round(g * 255) << 8)
| round(b * 255)
)
def relative_luminance(self) -> float:
r, g, b = self.gamut_mapped()
lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b))
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
TRANSPARENT = Color(0.0, 0.0, 0.0, 0.0)
def contrast_ratio(fg: Color, bg: Color) -> float:
"""WCAG 2.1 contrast, compositing `fg` over `bg` if it is translucent."""
if fg.alpha < 1.0:
fr, fg_, fb = fg.gamut_mapped()
br, bg_, bb = bg.gamut_mapped()
a = fg.alpha
fg = Color.from_srgb(
fr * a + br * (1 - a), fg_ * a + bg_ * (1 - a), fb * a + bb * (1 - a)
)
l1, l2 = fg.relative_luminance(), bg.relative_luminance()
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)
# ---------------------------------------------------------------------------
# Tokenizer / parser
# ---------------------------------------------------------------------------
_TOKEN = re.compile(
r"""
(?P<ws>\s+)
| (?P<hex>\#[0-9a-fA-F]{3,8})
| (?P<func>[a-zA-Z_][-a-zA-Z0-9_]*(?=\())
# A leading `-` starts an identifier only when a digit does not follow,
# or `-0.022em` would lex as the identifier `-0`.
| (?P<ident>-{1,2}(?![0-9])[-a-zA-Z0-9_]+|[a-zA-Z_][-a-zA-Z0-9_]*)
| (?P<num>[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?(?:%|[a-zA-Z]+)?)
| (?P<punct>[(),/*+\-])
""",
re.VERBOSE,
)
def _lex(s: str) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
i = 0
while i < len(s):
m = _TOKEN.match(s, i)
if not m:
raise ValueError(f"cannot lex at {s[i:i+30]!r} in {s!r}")
i = m.end()
kind = m.lastgroup
if kind == "ws":
continue
out.append((kind, m.group()))
return out
class _Parser:
"""Recursive descent over the component-value grammar Niveus uses."""
def __init__(self, tokens: list[tuple[str, str]], ev: "Evaluator"):
self.t = tokens
self.i = 0
self.ev = ev
# -- plumbing ---------------------------------------------------------
def peek(self) -> tuple[str, str] | None:
return self.t[self.i] if self.i < len(self.t) else None
def next(self) -> tuple[str, str]:
tok = self.t[self.i]
self.i += 1
return tok
def expect(self, text: str) -> None:
tok = self.next()
if tok[1] != text:
raise ValueError(f"expected {text!r}, got {tok[1]!r}")
def at(self, text: str) -> bool:
tok = self.peek()
return tok is not None and tok[1] == text
def args(self) -> list[list[tuple[str, str]]]:
"""Consume `( a, b, c )` and return each argument's raw tokens."""
self.expect("(")
groups: list[list[tuple[str, str]]] = [[]]
depth = 0
while True:
tok = self.peek()
if tok is None:
raise ValueError("unterminated (")
if tok[1] == "(":
depth += 1
elif tok[1] == ")":
if depth == 0:
self.next()
break
depth -= 1
elif tok[1] == "," and depth == 0:
self.next()
groups.append([])
continue
groups.append(groups.pop() + [self.next()])
return [g for g in groups if g]
# -- arithmetic -------------------------------------------------------
def sum_(self) -> Num:
left = self.product()
while True:
tok = self.peek()
if tok is None:
break
if tok[1] in ("+", "-"):
op = self.next()[1]
left, right = _same_unit(left, self.product())
unit = left.unit or right.unit
left = Num(
left.value + right.value if op == "+" else left.value - right.value, unit
)
continue
# `calc(l -0.045)` lexes the operand as one signed number, so the
# sign has to be read back as the operator it was written as.
if tok[0] == "num" and tok[1][0] in "+-":
left, right = _same_unit(left, self.product())
left = Num(left.value + right.value, left.unit or right.unit)
continue
break
return left
def product(self) -> Num:
left = self.term()
while self.peek() and self.peek()[1] in ("*", "/"):
op = self.next()[1]
right = self.term()
unit = left.unit or right.unit
if op == "*":
left = Num(left.value * right.value, unit)
else:
left = Num(left.value / right.value, left.unit)
return left
def term(self) -> Num:
tok = self.peek()
if tok is None:
raise ValueError("unexpected end of expression")
if tok[1] == "(":
self.next()
v = self.sum_()
self.expect(")")
return v
if tok[1] == "-":
self.next()
v = self.term()
return Num(-v.value, v.unit)
if tok[1] == "+":
self.next()
return self.term()
val = self.value()
if isinstance(val, Num):
return val
raise ValueError(f"expected a number, got {val!r}")
# -- values -----------------------------------------------------------
def value(self):
tok = self.next()
kind, text = tok
if kind == "hex":
return _parse_hex(text)
if kind == "num":
return _parse_num(text)
if kind == "func":
name = text.lower()
if name == "calc":
inner = self.args()
return _Parser(inner[0], self.ev).sum_()
if name in ("clamp", "min", "max"):
vals = [_Parser(g, self.ev).sum_() for g in self.args()]
if name == "clamp":
lo, v, hi = vals
unit = v.unit or lo.unit or hi.unit
return Num(min(max(v.value, lo.value), hi.value), unit)
pick = min if name == "min" else max
chosen = pick(vals, key=lambda n: n.value)
return chosen
if name == "var":
groups = self.args()
varname = "".join(t[1] for t in groups[0])
fallback = groups[1] if len(groups) > 1 else None
return self.ev.resolve(varname, fallback)
if name == "rgb" or name == "rgba":
return _parse_rgb(self.args(), self.ev)
if name == "oklch":
return _parse_oklch(self.args(), self.ev)
if name == "oklab":
return _parse_oklab(self.args(), self.ev)
if name == "color-mix":
return _parse_color_mix(self.args(), self.ev)
if name == "light-dark":
groups = self.args()
pick = groups[0] if self.ev.appearance == "light" else groups[1]
return _Parser(pick, self.ev).value()
raise ValueError(f"unsupported function {name}()")
if kind == "ident":
low = text.lower()
if low == "transparent":
return TRANSPARENT
if low in ("none", "currentcolor"):
return TRANSPARENT
if low in _NAMED:
return _NAMED[low]
if low in self.ev.bindings:
return Num(self.ev.bindings[low])
raise ValueError(f"unknown identifier {text!r}")
raise ValueError(f"unexpected token {text!r}")
# The initial value of the root font size. Tokens never mix units, but
# component rules do (`calc(var(--_track-block) - 6px)` with a rem on one
# side), and adding the numbers without converting is silently wrong.
ROOT_FONT_SIZE_PX = 16.0
def _same_unit(a: Num, b: Num) -> tuple[Num, Num]:
"""Bring two lengths to a common unit before adding them."""
if a.unit == b.unit or not a.unit or not b.unit:
return a, b
lengths = {"px": 1.0, "rem": ROOT_FONT_SIZE_PX, "em": ROOT_FONT_SIZE_PX}
if a.unit in lengths and b.unit in lengths:
return (Num(a.value * lengths[a.unit], "px"), Num(b.value * lengths[b.unit], "px"))
raise ValueError(f"cannot add {a.unit} to {b.unit}")
_NAMED = {
"white": Color.from_srgb(1, 1, 1),
"black": Color.from_srgb(0, 0, 0),
}
def _parse_hex(text: str) -> Color:
h = text[1:]
if len(h) in (3, 4):
h = "".join(ch * 2 for ch in h)
if len(h) == 6:
r, g, b = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4))
return Color.from_srgb(r, g, b)
if len(h) == 8:
r, g, b, a = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4, 6))
return Color.from_srgb(r, g, b, a)
raise ValueError(f"bad hex color {text!r}")
def _parse_num(text: str) -> Num:
m = re.fullmatch(r"([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)(%|[a-zA-Z]*)", text)
if not m:
raise ValueError(f"bad number {text!r}")
v = float(m.group(1))
unit = m.group(2)
if unit == "%":
return Num(v / 100.0, "%")
return Num(v, unit)
def _flat(groups: list[list[tuple[str, str]]]) -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
for i, g in enumerate(groups):
if i:
out.append(("punct", ","))
out.extend(g)
return out
def _split_slash(tokens: list[tuple[str, str]]) -> tuple[list, list | None]:
depth = 0
for i, (_, text) in enumerate(tokens):
if text == "(":
depth += 1
elif text == ")":
depth -= 1
elif text == "/" and depth == 0:
return tokens[:i], tokens[i + 1 :]
return tokens, None
def _components(tokens: list[tuple[str, str]], ev: "Evaluator", n: int) -> list[Num]:
"""Read `n` space-separated numeric components from a token run."""
p = _Parser(tokens, ev)
out: list[Num] = []
for _ in range(n):
out.append(p.sum_())
return out
def _parse_rgb(groups, ev) -> Color:
tokens = _flat(groups)
# Both `rgb(r g b / a)` and the legacy `rgb(r, g, b, a)` appear in the wild.
body, alpha_tokens = _split_slash(tokens)
body = [t for t in body if t[1] != ","]
nums = _components(body, ev, 3)
vals = []
for n in nums:
vals.append(n.value if n.unit == "%" else n.value / 255.0)
alpha = 1.0
if alpha_tokens:
alpha = _Parser(alpha_tokens, ev).sum_().value
return Color.from_srgb(*vals, alpha)
def _oklch_channels(tokens, ev, base: Color | None):
body, alpha_tokens = _split_slash(tokens)
bindings = {}
if base is not None:
L, C, H = base.oklch
bindings = {"l": L, "c": C, "h": H, "alpha": base.alpha}
with ev.bound(bindings):
nums = _components(body, ev, 3)
alpha = base.alpha if base is not None else 1.0
if alpha_tokens:
alpha = _Parser(alpha_tokens, ev).sum_().value
return nums, alpha
def _parse_oklch(groups, ev) -> Color:
tokens = _flat(groups)
base = None
if tokens and tokens[0][0] == "ident" and tokens[0][1].lower() == "from":
rest = tokens[1:]
p = _Parser(rest, ev)
base = p.value()
if not isinstance(base, Color):
raise ValueError("oklch(from …) needs a color")
tokens = rest[p.i :]
nums, alpha = _oklch_channels(tokens, ev, base)
L, C, H = (n.value for n in nums)
# A percentage lightness is 0–1; `oklch(50% …)` and `oklch(0.5 …)` agree.
return Color(*oklch_to_oklab(L, C, H), alpha)
def _parse_oklab(groups, ev) -> Color:
tokens = _flat(groups)
base = None
if tokens and tokens[0][0] == "ident" and tokens[0][1].lower() == "from":
rest = tokens[1:]
p = _Parser(rest, ev)
base = p.value()
tokens = rest[p.i :]
body, alpha_tokens = _split_slash(tokens)
bindings = {}
if base is not None:
bindings = {"l": base.L, "a": base.a, "b": base.b, "alpha": base.alpha}
with ev.bound(bindings):
nums = _components(body, ev, 3)
alpha = base.alpha if base is not None else 1.0
if alpha_tokens:
alpha = _Parser(alpha_tokens, ev).sum_().value
return Color(nums[0].value, nums[1].value, nums[2].value, alpha)
def _parse_color_mix(groups, ev) -> Color:
# color-mix(in oklab, <a> [pct], <b> [pct])
space_tokens = groups[0]
space = " ".join(t[1] for t in space_tokens).replace("in ", "").strip()
if space not in ("oklab", "oklch", "srgb"):
raise ValueError(f"unsupported color-mix space {space!r}")
parsed: list[tuple[Color, float | None]] = []
for g in groups[1:3]:
p = _Parser(g, ev)
col = p.value()
if not isinstance(col, Color):
raise ValueError("color-mix needs colors")
pct = None
if p.peek() is not None:
pct = p.sum_().value
parsed.append((col, pct))
(ca, pa), (cb, pb) = parsed
if pa is None and pb is None:
pa = pb = 0.5
elif pa is None:
pa = 1.0 - pb
elif pb is None:
pb = 1.0 - pa
total = pa + pb
if total == 0:
raise ValueError("color-mix percentages sum to zero")
pa, pb = pa / total, pb / total
# CSS mixes premultiplied by alpha, then un-premultiplies.
alpha = ca.alpha * pa + cb.alpha * pb
if space == "srgb":
ra, ga, ba = ca.gamut_mapped()
rb, gb, bb = cb.gamut_mapped()
comps = [
(x * ca.alpha * pa + y * cb.alpha * pb) for x, y in ((ra, rb), (ga, gb), (ba, bb))
]
if alpha > 0:
comps = [x / alpha for x in comps]
return Color.from_srgb(*comps, alpha)
if space == "oklch":
La, Ca, Ha = ca.oklch
Lb, Cb, Hb = cb.oklch
# shorter hue arc
if abs(Hb - Ha) > 180:
if Hb > Ha:
Ha += 360
else:
Hb += 360
L = La * ca.alpha * pa + Lb * cb.alpha * pb
C = Ca * ca.alpha * pa + Cb * cb.alpha * pb
H = Ha * ca.alpha * pa + Hb * cb.alpha * pb
if alpha > 0:
L, C, H = L / alpha, C / alpha, H / alpha
return Color(*oklch_to_oklab(L, C, H % 360), alpha)
L = ca.L * ca.alpha * pa + cb.L * cb.alpha * pb
a = ca.a * ca.alpha * pa + cb.a * cb.alpha * pb
b = ca.b * ca.alpha * pa + cb.b * cb.alpha * pb
if alpha > 0:
L, a, b = L / alpha, a / alpha, b / alpha
return Color(L, a, b, alpha)
# ---------------------------------------------------------------------------
# Evaluator
# ---------------------------------------------------------------------------
class _Bound:
def __init__(self, ev: "Evaluator", extra: dict):
self.ev = ev
self.extra = extra
def __enter__(self):
self.saved = self.ev.bindings
self.ev.bindings = {**self.ev.bindings, **self.extra}
def __exit__(self, *exc):
self.ev.bindings = self.saved
return False
class Evaluator:
"""Resolves `--nv-*` custom properties for one appearance."""
def __init__(self, declarations: dict[str, str], appearance: str = "light"):
self.declarations = declarations
self.appearance = appearance
self.bindings: dict[str, float] = {}
self._cache: dict[tuple[str, str], object] = {}
self._stack: list[str] = []
def bound(self, extra: dict) -> _Bound:
return _Bound(self, extra)
def override(self, name: str, raw: str) -> None:
self.declarations[name] = raw
self._cache.clear()
def resolve(self, name: str, fallback=None):
key = (name, self.appearance)
if key in self._cache:
return self._cache[key]
if name not in self.declarations:
if fallback is not None:
return _Parser(fallback, self).value()
raise KeyError(f"undefined custom property {name}")
if name in self._stack:
raise ValueError(f"cyclic token reference: {' -> '.join(self._stack + [name])}")
self._stack.append(name)
# Bindings belong to the oklch(from …) that created them, not to a
# token resolved from inside it.
saved, self.bindings = self.bindings, {}
try:
value = self.eval(self.declarations[name])
finally:
self.bindings = saved
self._stack.pop()
self._cache[key] = value
return value
def eval(self, raw: str):
return _Parser(_lex(raw.strip()), self).value()
def color(self, name: str) -> Color:
v = self.resolve(name)
if not isinstance(v, Color):
raise TypeError(f"{name} is not a color: {v!r}")
return v
# ---------------------------------------------------------------------------
# Reading declarations out of the CSS
# ---------------------------------------------------------------------------
_COMMENT = re.compile(r"/\*.*?\*/", re.S)
_DECL = re.compile(r"(--[-a-zA-Z0-9_]+)\s*:\s*([^;}]+)")
def read_declarations(paths, skip_at_rules=True) -> dict[str, str]:
"""Collect every `--nv-*: value` declaration, last one winning.
At-rule bodies (`@media (prefers-reduced-motion)`, `@supports not …`) are
skipped: they are alternate appearances of a token, and baking a
reduced-motion duration into the default build would be wrong.
"""
out: dict[str, str] = {}
for path in paths:
with open(path, encoding="utf-8") as fh:
css = _COMMENT.sub("", fh.read())
if skip_at_rules:
css = _strip_at_rules(css)
for name, value in _DECL.findall(css):
out[name] = " ".join(value.split())
return out
def _strip_at_rules(css: str) -> str:
out = []
i = 0
while i < len(css):
at = css.find("@", i)
if at == -1:
out.append(css[i:])
break
out.append(css[i:at])
# Skip to the matching close brace of the at-rule block.
brace = css.find("{", at)
if brace == -1:
break
depth = 0
j = brace
while j < len(css):
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
if depth == 0:
break
j += 1
i = j + 1
return "".join(out)
def read_at_rule_declarations(paths, condition: str) -> dict[str, str]:
"""Declarations from at-rule blocks whose prelude contains `condition`."""
out: dict[str, str] = {}
for path in paths:
with open(path, encoding="utf-8") as fh:
css = _COMMENT.sub("", fh.read())
for prelude, body in _iter_at_rules(css):
if condition in prelude:
for name, value in _DECL.findall(body):
out[name] = " ".join(value.split())
return out
def _iter_at_rules(css: str):
i = 0
while True:
at = css.find("@", i)
if at == -1:
return
brace = css.find("{", at)
if brace == -1:
return
depth = 0
j = brace
while j < len(css):
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
if depth == 0:
break
j += 1
yield css[at:brace], css[brace + 1 : j]
i = j + 1
# ---------------------------------------------------------------------------
# Reading component rules
# ---------------------------------------------------------------------------
#
# The token layer is custom properties on :root, which read_declarations
# covers. A port that draws controls natively (a Qt style, Compose) also needs
# the component layer: which role a secondary button's background is, which
# shadow it gains on hover, what its disabled border becomes. Those live in
# ordinary rules in components/*.css, and restating them in a port would fork
# the system just as surely as restating a colour. So they are read here too.
def _split_selectors(prelude: str) -> list[str]:
"""`a, b:not(c, d)` -> [`a`, `b:not(c, d)`], whitespace collapsed."""
out, depth, cur = [], 0, []
for ch in prelude:
if ch in "([":
depth += 1
elif ch in ")]":
depth -= 1
if ch == "," and depth == 0:
out.append(" ".join("".join(cur).split()))
cur = []
continue
cur.append(ch)
tail = " ".join("".join(cur).split())
if tail:
out.append(tail)
return out
def _parse_block_declarations(body: str) -> dict[str, str]:
out: dict[str, str] = {}
depth, cur = 0, []
for ch in body + ";":
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if ch == ";" and depth == 0:
decl = "".join(cur).strip()
cur = []
if ":" in decl:
name, value = decl.split(":", 1)
out[name.strip()] = " ".join(value.split())
continue
cur.append(ch)
return out
def read_rules(paths, media: tuple[str, ...] = ("hover: hover",)) -> dict[str, dict[str, str]]:
"""Every style rule, as selector -> {property: value}, last one winning.
A grouped selector (`a, b { … }`) is recorded under each of its members.
Rules inside `@media` blocks whose condition contains one of `media` are
read as if they were top level: `(hover: hover)` only says the device has a
pointer, which a desktop always does. Every other at-rule is skipped;
those are alternate states (reduced motion, missing features, entry
animations) rather than the component's normal appearance.
"""
rules: dict[str, dict[str, str]] = {}
def walk(css: str) -> None:
i = 0
while True:
brace = css.find("{", i)
if brace == -1:
return
prelude = css[i:brace].strip()
depth, j = 0, brace
while j < len(css):
if css[j] == "{":
depth += 1
elif css[j] == "}":
depth -= 1
if depth == 0:
break
j += 1
body = css[brace + 1 : j]
if prelude.startswith("@"):
if prelude.startswith("@media") and any(m in prelude for m in media):
walk(body)
else:
decls = _parse_block_declarations(body)
for sel in _split_selectors(prelude):
rules.setdefault(sel, {}).update(decls)
i = j + 1
for path in paths:
with open(path, encoding="utf-8") as fh:
walk(_COMMENT.sub("", fh.read()))
return rules