Merge pull request #3 from Nexoniarz/glaze-and-pickers

Glaze material, pickers, slider, light-theme rework and design guidelines
This commit is contained in:
Nexoniarz 2026-10-28 10:22:20 +01:00 • committed by GitHub
commit 8a04d110ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 3485 additions and 173 deletions

783
GUIDELINES.md Normal file
View file

@ -0,0 +1,783 @@
# Niveus Design Guidelines
**Status:** normative · **Version:** 1.0 · **Applies to:** every person and every
AI agent that designs with, builds on, or changes Niveus.
This document says what Niveus is, what it decides, and what it forbids. It is
written to be followed, not admired. Where it conflicts with a habit, a
preference, or a model's prior, this document wins.
The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT** and **MAY** are
used as in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). A **MUST** is
enforced by a tool or is a defect when broken. A **SHOULD** needs a stated
reason to break. A **MAY** is a freedom, not a recommendation.
---
## Contents
1. [How to use this document](#1-how-to-use-this-document)
2. [Identity](#2-identity)
3. [Principles and their order](#3-principles-and-their-order)
4. [Materials](#4-materials)
5. [Glaze](#5-glaze)
6. [Elevation and light](#6-elevation-and-light)
7. [Color and appearance](#7-color-and-appearance)
8. [Type, space and shape](#8-type-space-and-shape)
9. [Motion and continuity](#9-motion-and-continuity)
10. [Interaction and state](#10-interaction-and-state)
11. [Accessibility](#11-accessibility)
12. [Components](#12-components)
13. [Customization contract](#13-customization-contract)
14. [Authoring rules](#14-authoring-rules)
15. [Protocol for AI agents](#15-protocol-for-ai-agents)
16. [Changing the system](#16-changing-the-system)
17. [Anti-patterns](#17-anti-patterns)
18. [Compliance checklist](#18-compliance-checklist)
---
## 1. How to use this document
### 1.1 Sources of truth, in order of authority
When two sources disagree, the higher one is right and the lower one has a bug.
| # | Source | Answers |
| - | ------ | ------- |
| 1 | `tools/check.py`, `tools/build.py --check` | What is *allowed*. Mechanical, not negotiable. |
| 2 | **This document** | What is *intended*, and why. |
| 3 | [`REFERENCE.md`](REFERENCE.md) | What exists: every class, token, attribute, function, event. |
| 4 | [`README.md`](README.md) | How to install and orient. |
| 5 | The CSS and JS | How it is currently built. |
`REFERENCE.md` is the complete API. If a class, token or attribute is not in it,
**it does not exist** and MUST NOT be used, guessed, or invented.
### 1.2 Who this is for
- **Designers and engineers** use it to decide, and to review each other.
- **AI agents** use it as an operating contract. Section 15 is written for them
directly; sections 2–14 apply to them as to anyone.
- **Product authors** who ship Niveus to end users use section 13 to decide what
their users may customize.
### 1.3 What this document is not
It is not a style preference list. Every rule here exists because breaking it
produces a specific, nameable failure: an unreadable surface, a broken theme, a
control that behaves differently in two places, a port that has drifted. Rules
state their reason so they can be applied to cases the document did not foresee.
---
## 2. Identity
Niveus is a **design-system layer**, not a component library bolted onto an
application. Applications consume it; they do not redefine it.
**It is:** tactile (controls are bevelled keys with light falling across them),
adaptive (one brand colour drives light and dark), and continuous (state
changes can be followed). Fields are wells cut into a surface. Windows are
solid. Glass is kept for what floats.
**It is not:** a flat system, a material-for-its-own-sake system, or a
glassmorphism kit. Skeuomorphism here is **functional**: a surface imitates a
physical property only where that property answers a question the user is
already asking.
> Niveus is not about making interfaces look physical. It is about making
> digital surfaces feel understandable.
The questions a surface must answer are: **What is this? Is it above or below?
Where did it come from? Did my action register?** An effect that answers none
of them is decoration, and decoration makes an interface tiring.
### 2.1 Naming
Names in Niveus describe a thing's role, never another vendor's product. A
feature MUST NOT be named after a third party's trademark, and documentation
MUST NOT present a Niveus feature as a clone of one. (Niveus's adjustable-frost
glass is called **Glaze** for this reason.)
---
## 3. Principles and their order
| Principle | Rule | What it costs |
| --------- | ---- | ------------- |
| **Clarity** | Every element has a purpose. Decoration never competes with content. | Fewer flourishes. |
| **Soft physicality** | Depth comes from shadow, highlight and inset, not ornament. | No gratuitous textures. |
| **Material** | Surfaces are made of something: solid, elevated, inset, frosted, glass, glaze. | Every surface must pick one. |
| **Restraint** | No effect exists because it is possible. Translucency and shadow are spent, not sprinkled. | Saying no to good-looking things. |
| **Consistency** | The same control behaves the same way everywhere, including under a pointer. | No local variations. |
| **Adaptability** | Nothing is defined in terms of one appearance or one colour. | Every value is a role. |
| **Continuity** | Nothing pops into a state the user could have watched it reach. | Motion must be earned and bounded. |
| **Customizability** | Where the author allows it, the person using the product can tune it. | A bounded, documented set of dials. |
### 3.1 Precedence
When principles conflict, resolve in this order:
1. **Accessibility** (section 11) overrides everything. A beautiful surface that
cannot be read or operated is broken.
2. **Clarity** overrides material and motion.
3. **Restraint** overrides every effect. A quiet interface with one thing moving
reads clearly; a busy one does not.
4. **Consistency** overrides local preference.
5. **Customizability** operates *inside* the limits above, never through them.
---
## 4. Materials
A material answers *what light does when it hits a surface*. Elevation answers
*how far the surface is from the canvas*. They are independent and combine
freely.
| Material | Class | Use for | MUST NOT be used for |
| -------- | ----- | ------- | -------------------- |
| Solid | `.nv-material-solid` | Content. The default. | — |
| Elevated | `.nv-material-elevated` | Surfaces that must be read before their context. | — |
| Inset | `.nv-material-inset` | Fields, wells, tracks: anything recessed. | Anything that is pressed *toward* the user. |
| Frosted | `.nv-material-frosted` | Chrome that content passes under: bars, docks, menus, toolbars. | Containers of body copy or forms. |
| Glass | `.nv-material-glass` | Contextual layers where the content behind must stay legible. | Surfaces with no content behind them. |
| Glaze | `.nv-material-glaze` | Floating, interactive panes where the visitor's control over frost is a feature. See section 5. | Whole-page backgrounds; anything the user must read at length. |
### 4.1 Rules
0. **One dimensionality per interface.** Niveus is skeuomorphic *all the way
through*. A raised key, a recessed well and a lit lens are physical objects;
a flat colour block beside them reads as a different system. Every
interactive or selected element MUST take its shape from the depth
vocabulary — bevel, sheen, inset, ledge, outline, elevation — and MUST NOT be
a flat fill. If a state seems to need a flat wash (a hover, a selection band,
a pressed toggle), give it depth instead: press it *into* the surface, or
lay a lit lens over it. Mixing flat and physical is the single most common
way a Niveus interface starts to look unfinished.
1. **Solid by default.** Anything that *holds content* — windows, cards, dialogs,
tables, forms — is solid. Translucency is reserved for what *floats over*
content.
2. **Translucency means "this floats".** Used everywhere, it stops meaning
anything, tints whatever is behind it, and forces the GPU to composite.
Spend it.
3. **Raised things are bevelled and sheened.** Lit along the top edge, shaded
along the bottom, with a contact outline. Compose with
`box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2)` and
`background-image: var(--nv-sheen-raised)` over a role colour.
4. **Never invent a material.** If none fits, the right answer is a different
composition, or a proposal under section 16 — not a local `backdrop-filter`.
5. **Translucent materials MUST degrade.** `prefers-reduced-transparency` and
missing `backdrop-filter` fall back to the solid equivalent. A new
translucent surface without that fallback is incomplete.
---
## 5. Glaze
Glaze is glass with a dial. It exists to make Niveus feel *alive* — a surface
that catches light and answers to the person looking at it — without giving up
legibility.
### 5.1 Anatomy
| Part | Mechanism | Controlled by |
| ---- | --------- | ------------- |
| **Frost** | Backdrop blur, tint opacity and saturation, moved together | `--nv-glass-frost`, `0` (clear) … `1` (heavy frost); range set by `--nv-glaze-blur-max`, `--nv-glaze-tint-min`, `--nv-glaze-tint-max` |
| **Rim** | A 1.5px masked gradient border, bright on the side facing the light | `--nv-glaze-rim` (width), `--nv-color-glaze-rim-lit` / `-far` |
| **Specular** | A soft bloom under the pointer | `--nv-color-glaze-specular` |
| **Light** | Pointer position and angle written per surface | `niveus.js` → `--nv-light-x/-y/-angle` |
Frost moves blur, tint and saturation **together** on purpose. A pane that
changed only one of them would stop looking like the same pane.
### 5.2 Rules
1. **Frost is read where the surface is drawn.** Set `--nv-glass-frost` on
`:root` for the site, or on any subtree to change just that region. Never
copy a frost value into another token; that breaks subtree overrides.
2. **Something colourful MUST be behind it.** Glaze over a flat canvas looks like
a grey film. Use it over imagery, gradients, or an `.nv-bed`. If nothing is
behind it, use `elevated`.
3. **Never place Glaze behind work.** Forms, tables and long reading belong on
solid surfaces. Glaze is for what the user passes *through*: pickers,
popovers, docks, notifications, hover cards, command surfaces.
4. **Text on Glaze MUST meet contrast at every frost the person can choose.**
Clear glass over a busy backdrop is the worst case. A pane carrying body text
SHOULD bound its slider (`min="0.2"` or higher) so the lowest setting is
still legible. Verify against the busiest content that can sit behind it.
5. **Do not stack Glaze.** Glaze over Glaze compounds blur and tint into mud.
One translucent layer between content and the eye.
6. **Do not animate the blur radius.** Blur is expensive; change frost by
discrete steps or on user input, not as ambient motion.
7. **No JavaScript, no problem.** Without `niveus.js` the light sits at its
default (top left) and the material is still correct. Light tracking MUST
NOT be required for legibility, and is skipped for touch and under reduced
motion.
8. **Position.** Glaze carries zero-specificity `position: relative`; a menu or
dialog using it keeps the positioning its own component gave it.
---
## 6. Elevation and light
Six levels, each describing one relationship between a surface and the canvas.
A shadow that is on neither scale is a shadow that means nothing.
| Level | Class | Relationship | Typical use |
| ----- | ----- | ------------ | ----------- |
| 0 | `.nv-elevation-0` | flush | painted on the canvas |
| 1 | `.nv-elevation-1` | resting | cards, rows, panels |
| 2 | `.nv-elevation-2` | raised | anything that reacts |
| 3 | `.nv-elevation-3` | floating | popovers, menus |
| 4 | `.nv-elevation-4` | lifted | dialogs, sheets |
| 5 | `.nv-elevation-5` | overlay | topmost; used sparingly |
Inset depth uses `.nv-elevation-inset-{sm,md,lg}`.
1. **Light comes from above.** Highlights sit on top edges, shade on bottom
edges. A highlight on a lower edge is a bug.
2. **Dark is not light inverted.** Surfaces get *lighter* as they rise, shadows
deepen, and the top highlight thins. Elevation therefore carries a tint, not
only a shadow.
3. **Elevation tracks the user's mental model, not the visual taste.** Raise
what the user can act on or must notice; do not raise to decorate.
4. **A recess catches light on its lower lip** (`--nv-ledge`). Wells are cut into
the surface, not painted darker.
---
## 7. Color and appearance
### 7.1 The model
The whole colour system has **one input**: `--nv-brand`. Every role, in both
appearances, is derived from it with relative colour syntax. There is no second
place to define a colour.
- A component names a **role** (`--nv-color-*`); the appearance decides what the
role is made of, through `light-dark()`.
- `--nv-palette-*` is raw and does not adapt. **MUST NOT** be referenced from
application code.
- Appearance is `data-theme="light" | "dark"`, or absent to follow the OS.
- A subtree may rebrand with `data-nv-brand`.
### 7.2 Rules
1. **No literal colours** in components or examples (the sole exception is the
value of `--nv-brand`). `tools/check.py` fails on any.
2. **Every role exists in both appearances.** Adding `--nv-light-x` without
`--nv-dark-x` fails the parity check.
3. **Contrast is measured, not assumed.** The derived label on any filled colour
clears 4.5:1 for any brand. New roles that carry text MUST do the same.
4. **State is never colour alone.** Pair colour with a shape, icon, text or
attribute change (see 11).
5. **Semantic colours mean one thing.** `success`, `warning`, `danger` carry
meaning; they are not accents. Use `accent` for emphasis and brand.
6. **Every neutral carries the brand.** Surfaces, canvases, glass and shadows
are mixed with `--nv-brand` in *both* appearances, so changing one value
re-colours the whole interface and light and dark read as one material. A
neutral that ignores the brand — a literal grey or a bright white field — is
a defect. Light is a soft stone, not paper-white: a glaring field flattens
the bevels meant to shape it.
7. **No native colour survives.** Anything the browser draws itself (checkbox
ticks, range and progress fills, the caret, selection, date glyphs) takes the
brand through `accent-color`, `caret-color` and `::selection`. Do not leave a
control in its default blue.
8. **Dark mode is designed, not generated.** When a new surface is added, look at
it in dark. If it only works on white, it is not finished.
---
## 8. Type, space and shape
- **Type.** Instrument Sans for the interface, JetBrains Mono for code and
engraved labels, shipped in `fonts/`. Use the role classes — `.nv-display`,
`.nv-heading`, `.nv-title`, `.nv-body`, `.nv-label`, `.nv-caption`,
`.nv-overline` — not raw sizes. Sizes are in `rem` so the user's own font
size is respected. Tabular figures (`font-variant-numeric: tabular-nums`) for
any number that changes or aligns.
- **Space.** A 4px rhythm, expressed as `--nv-space-*`. Values are tokens; a
one-off `13px` is a defect.
- **Shape.** `--nv-radius-*`. Nested radii MUST be concentric: use
`--nv-radius-nested-*`, or the outer radius minus the gap. Pills
(`--nv-radius-full`) are for pills, avatars and switches.
- **Logical properties.** Use `inline`/`block` (`padding-inline`,
`inset-block-start`), never `left`/`right`, so the system works in RTL.
Where a property has no logical form (`translate`), flip it explicitly for
RTL.
- **Application-specific values** that describe *your content* — a 15rem sidebar,
an aspect ratio, a grid template — are yours. A value that restates the design
language is the system's, and hard-coding it is how a system quietly stops
being one.
---
## 9. Motion and continuity
Every interaction MUST read as a continuation of the state before it. The goal is
not to animate everything. It is that **nothing pops into a state the user could
have watched it reach.**
### 9.1 Rules
1. **Motion explains.** It shows where something came from, where it went, or
that an action registered. Motion with no such job is removed.
2. **Durations and easings are tokens.** `--nv-duration-*`, `--nv-ease-*`,
`--nv-transition-*`. A literal `200ms` is a defect, and it bypasses reduced
motion.
3. **Reduced motion collapses at the token level.** Components never opt in
individually. `prefers-reduced-motion` skips view transitions entirely; the
change still happens, it just arrives.
4. **Selection travels.** When emphasis moves between elements, the marker
itself moves (shared `view-transition-name`). When a panel replaces another,
it slides in the direction of travel.
5. **View transition names are unique per document.** A duplicate makes the
browser skip the transition. Give each selection group its own
`--nv-selection-name`.
6. **Origin matters.** Menus scale out of their trigger's edge; dialogs expand
away from the control that opened them.
7. **No JavaScript, no breakage.** Continuity enhances; it MUST NOT be required
for function.
8. **Edges give.** A scroller that reaches its end yields and springs back
(rubber-band) instead of stopping dead, the way a phone's list does. Use the
spring easing token, cap the stretch, and leave touch to the platform where
it already does this. The wheels in the time picker are the reference.
9. **Let scroll drive depth.** Where an effect is a function of scroll
position — a drum's rows fading and tilting away from the centre — express it
as a scroll-driven animation in CSS, not as script, so it tracks a finger
exactly and costs no frame budget. Provide a fallback where the feature is missing — either the plain state, or the same curve from script (as the wheels do for Firefox) — and test input differences between engines: wheel notches arrive in pixels in Chromium and in *lines* in Firefox, so normalise `deltaMode` before using `deltaY`.
10. **Local motion for local change.** Changing a month moves the month, not the
page. Reserve page-wide view transitions for changes that are about the
page: a route change, never appearance or selection — see 14.4.1.
11. **Restraint wins ties.** When continuity and restraint conflict, animate less.
---
## 10. Interaction and state
1. **One treatment per state, written once per component.** Hover lift, press
sink and focus ring are defined once; a new variant MUST NOT redefine them.
Variants are made by reassigning private `--_*` properties.
2. **Hover is guarded.** Hover styles live in `@media (hover: hover)`. Touch
devices never see a stuck hover.
3. **Pressed means pressed.** Raised controls sink on press. A control that does
not respond to a press does not look pressable.
4. **Targets are at least 44px**, including controls drawn smaller (the system
extends the pointer target).
5. **Every state has a visible form:** default, hover, focus-visible, active,
selected, disabled, invalid, loading. A component missing one is unfinished.
6. **Disabled is not hidden.** A disabled control keeps its place, dims, and
uses `not-allowed`. Prefer explaining *why* over disabling silently.
7. **Selection state lives in attributes with a platform meaning:**
`aria-selected`, `aria-current`, `aria-pressed`, `aria-checked`,
`aria-invalid`, `disabled`, `data-outside`. Not in ad-hoc classes.
---
## 11. Accessibility
Accessibility is a precondition, not a feature. Target **WCAG 2.2 level AA**.
Niveus provides the base; the author is responsible for the rest.
**Provided by the system:**
- One focus treatment: a contrast ring under an accent ring, visible on any
surface including accent-filled controls.
- A minimum 44px pointer target.
- `prefers-reduced-motion` and `prefers-reduced-transparency` honoured at the
token level.
- Filled colours with a derived on-colour that clears 4.5:1.
**Required of the author:**
1. **Icon-only buttons MUST have `aria-label`.** The system cannot name them.
2. **Use native elements** — `<button>`, `<dialog>`, `<details>`, `<input>` —
before ARIA. ARIA repairs; it does not replace.
3. **Keyboard parity.** Everything pointer-operable is keyboard-operable, with a
visible focus order that follows reading order.
4. **Labels.** Every field has a programmatic label. Errors pair
`aria-invalid="true"` with `aria-describedby` pointing at the message.
5. **Focus management.** A modal `<dialog>` opened with `showModal()` traps focus
and restores it on close. A `popover` does **not** trap focus: move focus into
it on open where it holds controls, and return it to the trigger on close, as
the popup list does. Custom overlays MUST do both.
6. **Live regions** (`aria-live`) for content that changes without focus —
a calendar's month title is one.
7. **Test the extremes.** Verify text on translucent materials at the
lowest-contrast backdrop and the lowest frost; verify at 200% text size and
in both appearances.
8. **Do not rely on colour, motion or transparency to carry meaning.**
---
## 12. Components
The complete list and API live in `REFERENCE.md`. These are the rules for
*choosing and composing*.
1. **Compose before you create.** Most needs are an existing component plus a
modifier, or a pattern — a composition that needs no new CSS.
2. **Native first.** A native `<select>` (`.nv-select`) is the default choice for
a short list. Use the popup list (`.nv-picker` + `.nv-menu--listbox`) when you
need search, rich options, or anchored positioning; it re-implements keys,
type-ahead and filtering, so it must earn its place.
3. **Pickers.**
- **Calendar** (`.nv-calendar`): holds content, so it takes no material of its
own — put it on `.nv-surface`, or on Glaze when it floats. Six weeks every
month so it never changes height. The grid and states are plain markup;
`niveus.js` only builds days, steps months and provides keyboard movement.
- **Time picker** (`.nv-timepicker`): scroll-snap wheels. **24-hour is the
default**; `data-nv-format-toggle` adds a third wheel of 24H / AM / PM: swiping it
changes the format, and the hour wheel follows. The value is always 24-hour (`HH:MM`), so a form
never needs to know which the person used. For a no-script fallback use
`<input type="time" class="nv-input">`; the wheels are an enhancement, not
the only path.
- **Popup list** (`.nv-menu--listbox`): needs an `id`, a trigger with that
`popovertarget`, and one shared `--nv-anchor` name per pair.
- **Slider** (`.nv-slider`): a native range input; bind it to a token with
`data-nv-var`.
4. **Overlays.** Menus and popovers anchor to their trigger. A menu and trigger
MUST name the same `--nv-anchor`, unique per pair. Light dismiss, Escape and
top-layer stacking come from `popover`; do not script them.
5. **A component earns its place** when it can be stated as a reusable rule.
"Selection emphasis travels" is a rule. "This menu is 14px from its button"
is not.
6. **Variants reassign private properties** (`--_*`); they do not restate
interaction.
### 12.1 Not yet in the system
The following are known gaps. Until they ship, compose from existing parts, and
do not fake them with unlisted classes: toast/snackbar, tooltip, drawer, alert
banner, progress and spinner, chips, segmented control, pagination, command
palette, context menu, date-range selection, a `data-nv-style` personality
switch.
---
## 13. Customization contract
Niveus lets the **author** decide what the **person using the product** may
tune. This is a first-class goal, bounded by sections 3.1 and 11.
| Dial | Mechanism | Safe to expose to end users? |
| ---- | --------- | ---------------------------- |
| Appearance | `data-theme`, `data-nv-theme` buttons | **Yes.** Always offer system, light, dark. |
| Brand colour | `--nv-brand`, `data-nv-brand` | **Yes**, within the contrast guarantee (7.2.3). |
| Frost | `--nv-glass-frost` via `.nv-slider[data-nv-var]` | **Yes**, with a floor where text sits on Glaze (5.2.4). |
| Motion | the OS setting | **Respect, do not override.** Never add a control that forces motion on. |
| Transparency | the OS setting | **Respect, do not override.** |
| Density, radius, type scale | tokens | Possible; expose only as named presets, not free values. |
**Rules**
1. **Customization changes tokens, never component CSS.** If a dial needs a new
selector, it is a new feature and goes through section 16.
2. **Free-form values are a hazard.** Expose steps or bounded ranges; never raw
colour or size inputs that can break contrast or layout.
3. **Persist deliberately.** If a choice is saved, it is the author's
responsibility (the system stores nothing), and the page MUST render
correctly before the stored value is read.
4. **The default is the product.** Most people never open settings. The default
configuration must be correct, accessible and good-looking on its own.
5. **Per-subtree overrides are the unit.** Tokens like `--nv-glass-frost` and
`data-nv-brand` apply to a region, which lets one product mix a calm
content area with a lively floating layer.
---
## 14. Authoring rules
### 14.1 CSS
1. **Reference the system, never restate it.**
```css
/* No */
.thing { background: #fff; border-radius: 12px; padding: 10px 16px; }
/* Yes */
.thing {
background: var(--nv-color-surface-elevated);
border-radius: var(--nv-radius-md);
padding: var(--nv-space-2-5) var(--nv-space-4);
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
}
```
2. **Everything ships in the `niveus` cascade layer.** Application styles are
unlayered and win by default. Overriding Niveus MUST NOT require `!important`
or selector escalation. If it does, the structure is wrong.
3. **Naming.** `nv-` prefix; BEM `__element` and `--modifier`; `--_name` for
component-private properties; `--nv-` for public tokens.
4. **Layer order is fixed:** tokens → theme → base → continuity → components →
utilities. Do not rely on file load order.
5. **A header comment explains *why*,** not what. Match the density and idiom of
the file you are in.
6. **Register new files** in `niveus/niveus.css` with `layer(niveus.components)`,
then rebuild. **Never hand-edit `dist/`.**
7. **`niveus/tokens` and `niveus/themes` use a deliberately narrow CSS subset**
(`rgb`, `oklch`, `color-mix in oklab`, `light-dark`, `var`, `calc`, `clamp`,
`min`, `max`) so ports can evaluate them. Component CSS is not so limited.
### 14.2 JavaScript
1. **Optional and dependency-free.** Everything works without it; it adds
continuity and the keyboard models CSS cannot provide.
2. **Declarative first.** Behaviour is attached through `data-nv-*` attributes and
wired by `nv.wire(scope)`. Call `nv.wire` again after injecting markup.
3. **Events are namespaced `nv:`** and documented in `REFERENCE.md`.
4. **Respect reduced motion** via `nv.prefersReducedMotion()`.
5. **One listener, one write per frame** for pointer-driven effects.
6. **ES5-compatible style** in `niveus.js`, to match the file.
### 14.4 Cross-engine rules
Each of these was a real defect, found by driving Firefox with real input.
1. **Never wrap a change in a page-wide view transition.** The snapshot cannot
blur what sits behind frosted glass, so for its whole duration every frosted
bar, menu and Glaze pane goes clear and shows the page sharply through
itself. This applied to theme changes, brand changes *and* selection markers,
and was only noticed on the third. Let surfaces transition their own colours;
move a selection marker by animating the marker itself (measure, change,
animate the difference); animate arriving content on its own. `nv.transition`
is built this way; do not reintroduce `document.startViewTransition` around
anything on a page that has glass.
2. **Do not use padding to make a scroll container's last item reachable.**
Firefox leaves the container's bottom padding out of its scrollable area, so
the final rows were unreachable and a short list could not scroll at all. Use
real boxes — `::before` / `::after` spacers — which every engine counts.
3. **Normalise wheel deltas.** Chromium reports a notch in pixels (~100);
Firefox reports it in lines (~3). Convert with `deltaMode` before using
`deltaY`, or an effect that works in one engine is invisible in the other.
4. **A scrollable list is not draggable on desktop.** Touch scrolls natively; a
mouse does not. Anything that is meant to feel like a phone control (a wheel,
a carousel) needs pointer-drag handling, with inertia, an edge that gives, and
a click that still works when the pointer did not move.
5. **Shadows need something to darken.** A black shadow on a near-black canvas
is invisible, however correct its computed value. The dark canvas is
deliberately lighter than the shadow colour, and soft layers use only a mild
negative spread so the blur reaches outside the element.
6. **Where a CSS feature is missing, supply the same curve in script** (scroll-
driven animation and `interpolate-size` are absent in Firefox), and keep the
plain state correct without either.
7. **State that the browser only half-supports needs an attribute.** A checkbox
can *show* a partial state but a click never *chooses* it;
`data-nv-tristate` makes the cycle explicit.
8. **Same-page links travel.** `scroll-behavior: smooth` under
`prefers-reduced-motion: no-preference`; a jump to a section is the textbook
change a user could have watched.
9. **Own a gesture completely or not at all.** A control that is half native
scrolling and half script behaves differently in every engine, in a desktop
browser's phone mode, and on a real phone. The wheels take every pointer —
mouse, pen and touch — with `touch-action: none` and run one code path, so
what was tested is what runs everywhere.
10. **An effect must not look like a value.** A rubber-band stretch of a whole
row makes the neighbouring row sit under the lens and read as a different
selection. Cap give at a fraction of a row (about 40%).
11. **One animation-frame slot per element, never per component.** A slot shared
between a picker's wheels dropped the last scroll event of one wheel and
left its rows and value a step behind.
12. **Know your test rig.** A headless browser without a GPU does not render
`backdrop-filter`, reports no hover capability (so hover-only controls such
as scroller arrows disappear), and may not deliver scroll events reliably.
It also cannot pan on synthesized touch (not even the page itself), so any
behaviour that depends on native touch scrolling cannot be verified there.
Treat these as limits of the rig, not of the system, and confirm them on a
real display.
### 14.3 Documentation
Every new class, token, attribute, function or event is added to `REFERENCE.md`
**in the same change.** `check.py` fails otherwise, and a documented thing that
no longer exists is worse than a missing one.
---
## 15. Protocol for AI agents
This section is an operating contract. If you are a model generating or changing
Niveus code, follow it literally.
### 15.1 Before you write
1. **Read `REFERENCE.md` in full.** It is the entire API. Do not rely on memory
of other design systems.
2. **Identify the task type:** *consume* (build an interface with Niveus) or
*change* (modify Niveus itself). Section 15.3 applies to the first, 15.4 to
the second.
3. **State your assumptions** about which components, materials and appearance
apply, when the request is ambiguous. Ask only when the answer would change
what you build and cannot be inferred.
### 15.2 Hard rules (never break)
- **MUST NOT** invent a class, token, attribute, function or event. If it is not
in `REFERENCE.md`, it does not exist.
- **MUST NOT** write a literal colour, shadow geometry, duration or easing. Use
tokens.
- **MUST NOT** reference `--nv-palette-*`.
- **MUST NOT** use `!important`, inline `style` for visual values, or `left/right`
where a logical property exists.
- **MUST NOT** put Glaze, frosted or glass behind content the user works in.
- **MUST NOT** name anything after another vendor's trademark.
- **MUST NOT** hand-edit `dist/`.
- **MUST NOT** weaken a check, delete a rule, or widen an allowlist to make a
change pass. If a change cannot be made without breaking a check, the change
is probably not the problem the system has. Report it.
- **MUST** keep interaction states complete, and both appearances correct.
- **MUST** leave the page legible when `niveus.js` fails to load.
### 15.3 Consuming Niveus
1. Choose the **material** by what the surface does (section 4), then the
**elevation** by its relationship to the canvas (section 6).
2. Compose existing components; reach for a pattern before new CSS.
3. Use role classes for type, tokens for space and shape.
4. Give icon-only buttons `aria-label`; label every field.
5. Include `data-theme` handling only if the product offers a toggle; otherwise
follow the OS.
6. Application CSS goes in unlayered styles and uses only tokens for anything
that is *about the design language*.
### 15.4 Changing Niveus
1. Apply the three questions of section 16 **before** writing code, and say the
answers.
2. Make the smallest change that satisfies a rule, not the most complete one.
3. Colours: add `--nv-light-*` **and** `--nv-dark-*` in `themes/`, then the role
in `tokens/color.css`. Both, always.
4. Add the class/token/attribute to `REFERENCE.md` in the same change.
5. Register new component files in `niveus/niveus.css`.
6. **Run the gates, in order, after every file, not only at the end:**
```bash
python3 tools/build.py # regenerate dist/
python3 tools/check.py # parity, references, hard-coded colours, docs
python3 tools/build.py --check # dist/ matches sources
```
7. **Look at it, in Firefox first.** Chromium implements nearly everything
Niveus uses, so passing there proves little; Firefox is the stricter test and
is where regressions have actually appeared. Render both appearances at a
realistic size and exercise the interactive parts with *real input* — pointer
drags, wheel notches, key presses — not only synthetic events or static
screenshots. Passing checks prove the rules; they do not prove it looks right
or works. Then confirm in Chromium. See 14.4 for what differs.
8. **Report faithfully.** State what you verified, what you could not, and what
failed. A test that failed because of the test harness is still reported.
### 15.5 Behaviour
- **Prefer the smaller diff.** Do not refactor, rename or restyle beyond the
task.
- **Prefer doing to asking,** except for actions that are hard to reverse or
outward-facing (publishing, deleting, force-pushing), which need explicit
authorization.
- **Surface conflicts** between a request and this document. Do not silently
pick a side: say which rule is in tension, and recommend.
---
## 16. Changing the system
### 16.1 The three questions
Ask in order, and stop at the first *yes* to question 1.
1. **Can it be expressed with what already exists?** Most new requirements are
an existing component with a modifier, or a composition that needs no new
CSS.
2. **Is it a rule, or is it this once?** A component earns its place when it can
be stated as a reusable principle.
3. **Does it hold in both appearances, at every elevation, and under a
pointer?** If it only works on white, or only when nothing is behind it, it
is not finished.
### 16.2 Tokens
If a value appears three times and means the same thing each time, it is a
token. If it appears three times and means something different each time, three
different things were given the same number by accident.
### 16.3 Definition of done
A change is done when:
- [ ] `build.py`, `check.py` and `build.py --check` all pass.
- [ ] It is documented in `REFERENCE.md`; `README.md` and this document are
updated if a principle or the component list changed.
- [ ] It renders correctly in **light and dark**, with and without `niveus.js`.
- [ ] It was seen and exercised in **Firefox** with real input, then in Chromium.
- [ ] Every interaction state exists and is reachable by keyboard.
- [ ] Translucent surfaces have a solid fallback.
- [ ] It respects reduced motion and reduced transparency.
- [ ] It was seen in a browser, and interactive parts were exercised.
- [ ] It adds no unlisted class, literal value or `!important`.
### 16.4 Deprecation
Do not delete a documented name silently. Mark it deprecated in `REFERENCE.md`
for at least one release, state its replacement, then remove it from the CSS and
the reference together.
---
## 17. Anti-patterns
| Anti-pattern | Why it fails | Instead |
| ------------ | ------------ | ------- |
| Glass on everything | Translucency stops meaning "floats"; cost and tint everywhere | Solid by default; glass for chrome |
| Glaze over a flat background | Reads as a grey film; the effect is invisible | Put it over a bed, imagery or gradient |
| Body copy on clear glass | Contrast collapses on busy backdrops | Solid surface, or floor the frost |
| Stacked translucent layers | Blur and tint compound | One translucent layer |
| Raw hex, `px` shadows, literal `ms` | Restates the language; bypasses theme and reduced motion | Tokens |
| A new shadow "just here" | Off-scale shadow means nothing | Pick an elevation |
| `!important` or selector escalation | The layer system already gives you precedence | Unlayered application CSS |
| Animating everything | Busy interfaces read as unclear | Animate what explains |
| Colour as the only state signal | Fails colour-blind and low-vision users | Add shape, text or attribute |
| Re-defining hover/press/focus per variant | Variants drift from the system | Reassign `--_*` properties |
| Icon button with no `aria-label` | No accessible name | Add it |
| Inventing a class that "should" exist | Documented API is the contract | Check `REFERENCE.md`; propose it |
| Hand-editing `dist/` | Next build erases it; check fails | Edit sources, rebuild |
| Weakening a check to pass | Hides a real defect | Fix the cause or report it |
| Naming a feature after another vendor's mark | Legal and brand risk | Name it for its role |
---
## 18. Compliance checklist
Use this for review, human or automated. Every line is a yes/no.
**Material and depth**
- [ ] Each surface has exactly one material, chosen by what it does.
- [ ] No translucent surface holds body copy or forms.
- [ ] Translucent surfaces have colour behind them and a solid fallback.
- [ ] Every shadow is on the elevation scale.
**Colour and theme**
- [ ] No literal colours; roles only; no `--nv-palette-*`.
- [ ] Looks correct in light and dark, and with a different `--nv-brand`.
- [ ] Text contrast ≥ 4.5:1 (3:1 for large text and UI components), including
on translucent materials at their worst-case backdrop.
**Interaction and motion**
- [ ] All states exist; hover is behind `@media (hover: hover)`.
- [ ] Targets ≥ 44px; focus visible; full keyboard operation.
- [ ] Durations and easings are tokens; reduced motion honoured.
- [ ] Nothing depends on `niveus.js` to be readable or operable.
**Code and docs**
- [ ] `nv-` naming, BEM, `--_` privates, logical properties.
- [ ] No `!important`; application CSS is unlayered.
- [ ] `REFERENCE.md` updated; `dist/` rebuilt, not hand-edited.
- [ ] `build.py`, `check.py`, `build.py --check` pass.
- [ ] Seen and exercised in Firefox with real input, then Chromium.
---
*Niveus Design Guidelines 1.0. Maintained with the system: change the rules and
the code in the same commit, or neither.*

View file

@ -31,6 +31,10 @@ Open `index.html` for the full specimen: materials, the elevation scale, every
semantic color, the type scale, and each component in both appearances.
`examples/` holds four complete products built with it.
**[GUIDELINES.md](GUIDELINES.md) is the rulebook** — what Niveus is, what it
decides, what it forbids, and a protocol for AI agents. Read it before you
design with the system or change it.
**[REFERENCE.md](REFERENCE.md) is the complete API** — every class, token,
attribute and function, each listed beside the name the rest of the industry
uses for it. It is written to be read by a person *or* handed to a model on its
@ -85,7 +89,8 @@ niveus/
├── base.css elements, before any component
├── continuity.css how one state becomes the next
├── components/ surface, button, input, card, dialog, navigation,
│ menu, disclosure, scroller, bed, badge, table
│ menu, picker, calendar, timepicker, slider,
│ disclosure, scroller, bed, badge, table
├── utilities.css the short list
├── niveus.css the entry point
└── niveus.js optional — continuity for changes CSS cannot animate
@ -136,6 +141,7 @@ answers how far that surface is from the canvas. They are independent.
| `inset` | fields, wells, tracks — anything recessed |
| `frosted` | chrome that content passes under: bars, toolbars |
| `glass` | contextual layers where content behind must stay legible |
| `glaze` | glass the person looking can tune: `--nv-glass-frost` (0 clear … 1 frosted), with a rim that follows the pointer |
| Elevation | Relationship |
| --------- | ------------------------------- |
@ -180,22 +186,24 @@ could have watched it reach.
| Change | How it stays continuous |
| ------ | ----------------------- |
| Selection moves between items | The marker itself travels. It is a pseudo-element with a shared `view-transition-name`, so the browser interpolates its old box into its new one. |
| Switching tabs | Panels cross-fade inside one box and slide with the direction of travel — forward one way, back the other. |
| Selection moves between items | The marker itself travels. It is a pseudo-element; `niveus.js` measures it before and after the change and animates the new marker from the old box to its own. |
| Switching tabs | The incoming panel slides in from the side the user is moving towards, and the marker travels with it. |
| Content expanding | `interpolate-size` lets `block-size: auto` animate, so content grows from the row that revealed it. Nothing is measured. |
| Menus and dialogs | A menu is anchored to its trigger and scales out of its edge; a dialog expands away from the control that opened it. |
| Theme change | The whole surface cross-fades. Surfaces also transition their own colors, so it glides even with no script. |
| Theme or brand change | Surfaces transition their own colors, so it glides even with no script. Nothing snapshots the page, so frosted glass stays live while it happens. |
| Hover, press, focus, select | All have a duration. Marks grow from their centre, the switch thumb travels its track. |
Most of this is plain CSS. The three cases CSS cannot do alone — a selection
moving *between two elements*, one panel replacing another, and the whole page
changing appearance — need a view transition, which needs one line of script:
Most of this is plain CSS. The two cases CSS cannot do alone — a selection
moving *between two elements*, and one panel replacing another — need one line
of script. It deliberately does **not** use a view transition: a page-wide
snapshot cannot blur what sits behind frosted glass, so every frosted surface
went clear for the length of the change.
```js
nv.transition(() => { // or document.startViewTransition directly
nv.transition(() => {
previous.removeAttribute("aria-current");
next.setAttribute("aria-current", "page");
});
}, { scope: group }); // the group whose marker should travel
```
`niveus.js` is optional and dependency-free. Without it every component still
@ -208,27 +216,13 @@ 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
### Nothing to name, nothing to keep unique
A `view-transition-name` must be unique among all rendered elements, or the
browser skips the transition entirely. Each selection group therefore needs its
own marker name:
```html
<nav class="nv-nav" style="--nv-selection-name: nv-sel-sidebar"> … </nav>
<nav class="nv-nav-tabs" style="--nv-selection-name: nv-sel-tabs"> … </nav>
```
Tab strips default to `nv-tabs-selection` and everything else to
`nv-selection`, which is enough for one of each per page.
### Motion under a transition
Transition layers always paint above the page, so a travelling marker cannot
stay behind the labels it passes the way it does at rest. Rather than let an
opaque marker wipe them, Niveus thins it while it is in flight: it stays
visible the whole way — it can still be followed — but reads as having lifted
off the surface to travel.
Because the marker is animated directly, several selection groups can change at
once and none of them needs a name. (`--nv-selection-name` only matters to
application code that starts its own view transition; if you do, remember that
a `view-transition-name` must be unique among all rendered elements, and that
the snapshot flattens frosted glass for as long as it runs.)
`prefers-reduced-motion` collapses every duration at the token level and skips
view transitions entirely. The change still happens; it just arrives.
@ -352,7 +346,7 @@ interface tiring.
| --- | --- |
| **Clarity** | Every element has a purpose. Decoration never competes with content. |
| **Soft physicality** | Depth comes from shadow, highlight and inset — not from ornament. |
| **Material** | Surfaces are made of something. Solid, elevated, inset, frosted, glass. |
| **Material** | Surfaces are made of something. Solid, elevated, inset, frosted, glass, glaze. |
| **Restraint** | No effect exists because it is possible. Translucency and shadow are spent, not sprinkled. |
| **Consistency** | The same control behaves the same way everywhere, including under a pointer. |
| **Adaptability** | Nothing is defined in terms of an appearance or a color. |
@ -432,6 +426,10 @@ probably not the problem the system has.
| `.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-slider` | native range input as an inset track and a knob; `data-nv-var` binds it to a token |
| `.nv-calendar` | month grid with keyboard model; chosen day is a small raised key |
| `.nv-timepicker` | scroll-snap wheels, 24-hour by default, optional 24H / AM/PM switch |
| `.nv-picker` | field-shaped trigger for a popup list (`.nv-menu--listbox`) with search and type-ahead |
| `.nv-surface` | materials and elevation as utilities |
Variants are defined by reassigning private `--_*` properties. Interaction —
@ -480,10 +478,11 @@ to the state change simply happening rather than travelling:
| Feature | Used for | Without it |
| ------- | -------- | ---------- |
| View transitions | travelling selection, tab panels, theme cross-fade | the change applies instantly |
| Web Animations (`pseudoElement`) | travelling selection marker, tab panels | the change applies instantly |
| `interpolate-size` | disclosure height | content appears at full height (`.nv-collapse` still animates) |
| Anchor positioning | menus tied to their trigger | the menu centres itself |
| `popover` | menus, light dismiss | needs the application's own toggle |
| Scroll-driven animation | the time picker's drum effect | `niveus.js` applies the same curve from the scroll position (Firefox) |
---

View file

@ -41,7 +41,7 @@ styles win without `!important`.
| `.nv-button--pill` | pill button | Fully rounded. |
| `.nv-button--block` | full-width button | Fills its container. |
| `.nv-button__icon` | button icon slot | Sizes an inline SVG to the label. |
| `.nv-button-group` | segmented control, button group | Buttons sharing edges; only outer corners round. |
| `.nv-button-group` | segmented control, button group | Buttons sharing edges; only outer corners round. A button with `aria-pressed="true"` is drawn pressed in, in the brand colour. |
States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
(shows a spinner and keeps the width), `aria-pressed` for toggles.
@ -63,8 +63,28 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
| `.nv-checkbox` | checkbox | Native input, custom mark. Supports `:indeterminate`. |
| `.nv-radio` | radio button | Native input, custom mark. |
| `.nv-switch` | toggle switch | Inset track, elevated thumb that travels. |
| `.nv-slider` | range slider, track bar | `input[type=range]`. Inset track, accent fill, knob thumb. Add `data-nv-var="--token"` to write its value into a CSS property. |
| `.nv-choice` | control with inline label | `<label>` wrapping a control and its text. |
## Pickers
| Class | Known as | Does |
| --- | --- | --- |
| `.nv-calendar` | calendar, date picker | Month grid on whatever surface you give it (`.nv-surface`, or `.nv-material-glaze` when floating). Configure with `data-nv-calendar` plus `data-nv-value` / `-min` / `-max` / `-locale` / `-week-start`. Fires `nv:date`. |
| `.nv-calendar__header` | calendar toolbar | Title and month-step buttons (`data-nv-calendar-step="-1"` / `"1"`). |
| `.nv-calendar__title` | month and year heading | Filled by `niveus.js`. |
| `.nv-calendar__grid` | day grid | `role="grid"`; built by `niveus.js` or written by hand. |
| `.nv-calendar__weekday` | weekday column header | |
| `.nv-calendar__day` | day cell | `aria-selected="true"` chosen · `aria-current="date"` today · `data-outside` neighbouring month · `disabled` out of range. |
| `.nv-timepicker` | time picker, wheel picker | Scroll-snap wheels built from `data-nv-timepicker` plus `data-nv-value="HH:MM"` / `-hours` (`24` default, `12` adds an AM/PM wheel) / `-step` / `-name` / `-format-toggle` (a third wheel of 24H / AM / PM; swipe it to change the format). The value is always 24-hour. Fires `nv:time`. Empty without the script; use `<input type="time" class="nv-input">` as the fallback. |
| `.nv-timepicker__wheels` | wheel row, picker body | Holds the columns and the selection lens. |
| `.nv-timepicker__column` | wheel, picker column | One scrolling list (`role="listbox"`). |
| `.nv-timepicker__option` | wheel row | `aria-selected="true"` is the row in the lens. |
| `.nv-timepicker__separator` | colon between wheels | |
| `.nv-picker` | select trigger, dropdown button | Goes on the trigger beside `.nv-input` and `.nv-menu-trigger`; chevron turns while its list is open. `data-empty` shows placeholder colour. |
| `.nv-menu--listbox` | popup list, listbox, combobox list | A `.nv-menu` whose `.nv-menu__item`s are `role="option"`. At least as wide as its trigger. `niveus.js` adds keys, type-ahead and filtering; trigger fires `nv:select`. |
| `.nv-menu__search` | list filter, combobox input | An `.nv-input` as the first child of a listbox; typing filters the options. |
## Card
| Class | Known as | Does |
@ -297,6 +317,7 @@ color before they are read, so they never take the brand.
| `.nv-material-inset` | recessed surface | Fields, wells, tracks. |
| `.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-material-glaze` | glaze, adjustable frosted glass | Glass with a dial. Frost comes from `--nv-glass-frost` (0 clear … 1 heavy), read where the surface is drawn, so a subtree or a slider can change it. Rim catches light and follows the pointer with `niveus.js`. |
| `.nv-elevation-0` `.nv-elevation-1` `.nv-elevation-2` | elevation / z-depth | flush · resting · raised |
| `.nv-elevation-3` `.nv-elevation-4` `.nv-elevation-5` | elevation / z-depth | floating · lifted · overlay |
| `.nv-elevation-inset-sm` `.nv-elevation-inset-md` `.nv-elevation-inset-lg` | inner shadow | Recessed surfaces. |
@ -305,7 +326,7 @@ color before they are read, so they never take the brand.
Material and elevation are independent: combine freely.
Niveus is solid, with glass used sparingly: anything that holds content
Niveus is solid by default, 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
@ -446,7 +467,11 @@ level. Components never opt in individually.
### Other
`--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
`--nv-glass-frost` (0–1, the glaze material's one input; set on `:root` or any
subtree) · `--nv-glaze-blur-max` · `--nv-glaze-tint-min` ·
`--nv-glaze-tint-max` · `--nv-glaze-rim` · `--nv-light-x` / `--nv-light-y` /
`--nv-light-angle` (set by `niveus.js` on each glaze surface) ·
`--nv-slider-pos` (set by `niveus.js`) · `--nv-pull` (set by `niveus.js` on a time-picker wheel pulled past its end) · `--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
`--nv-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` ·
@ -463,16 +488,29 @@ everything still renders — state changes simply arrive instead of travelling.
| Function | Signature | Does |
| --- | --- | --- |
| `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.transition` | `(update, { direction, kind, scope, content }) => Promise` | Runs `update` so the change can be followed, **without snapshotting the page**: a snapshot flattens frosted glass for its duration. `scope` is the selection group (or list of groups) whose marker should travel — the marker is measured before and after `update` and animated between the two boxes. `content` is an element (or list) that arrives from the side `direction` (`"forward"`/`"backward"`) names, or rises into place. `kind: "theme"` and reduced motion run `update` directly: surfaces transition their own colours. |
| `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.initSlider` | `(input) => void` | Keeps one `.nv-slider`'s fill in step with its value, and applies `data-nv-var`. |
| `nv.initCalendar` | `(el) => void` | Builds and drives one `[data-nv-calendar]`. |
| `nv.initTimePicker` | `(el) => void` | Builds and drives one `[data-nv-timepicker]`. |
| `nv.initListbox` | `(list) => void` | Wires one `.nv-menu--listbox` (needs an `id` and a trigger with that `popovertarget`). |
| `nv.initScroller` | `(frame) => void` | Wires one scroller's controls. |
| `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.liftLabels` | `(scope) => Element[]` | Names the navigation items in `scope`. Only for code that starts its own view transition; `nv.transition` no longer needs it. |
| `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` | |
### Events
| Event | On | Detail |
| --- | --- | --- |
| `nv:date` | `.nv-calendar` | `{ value: "YYYY-MM-DD", date }` |
| `nv:time` | `.nv-timepicker` | `{ value: "HH:MM" }` (24-hour) |
| `nv:select` | picker trigger | `{ value, label }` |
| `nv:theme` | `<html>` | the new appearance |
### Data attributes
| Attribute | On | Does |
@ -483,22 +521,25 @@ everything still renders — state changes simply arrive instead of travelling.
| `data-nv-dialog` | button | Opens the dialog with that id, from itself. |
| `data-nv-close` | button | Closes its dialog or popover. |
| `data-nv-tabs` | tablist | Wires `[role=tab]` children to their `aria-controls` panels, with arrow keys. |
| `data-nv-var` | `.nv-slider` | Name of the custom property (e.g. `--nv-glass-frost`) the slider writes its value to. It starts at that property's current value. |
| `data-nv-var-target` | `.nv-slider` | Selector of the element that receives the property. Default: `<html>`. |
| `data-nv-calendar` | `.nv-calendar` | Marks a calendar for `niveus.js`. Also `data-nv-value`, `-min`, `-max` (`YYYY-MM-DD`), `-locale`, `-week-start` (0 = Sunday). |
| `data-nv-calendar-step` | button in a calendar | `-1` or `1`: months to move. |
| `data-nv-timepicker` | `.nv-timepicker` | Marks a time picker. Also `data-nv-value` (`HH:MM`), `-hours` (`24` default / `12`), `-step` (minutes), `-name` (hidden form field), `-format-toggle` (adds the 24H / AM / PM wheel). |
| `data-nv-picker-label` | child of a picker trigger | Receives the chosen option's text; default is the trigger itself. |
| `data-nv-tristate` | `input[type=checkbox]` | Click cycles unchecked → checked → partial. `="partial"` starts partial. Exposes `data-nv-state` (`off`/`on`/`partial`). |
| `data-nv-scroller` | `.nv-scroller-frame` | Wires its `[data-nv-scroll="prev"\|"next"]` controls. |
| `data-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. |
| `data-nv-direction` | `<html>` | Set during a transition by `nv.transition`. |
| `data-nv-transition` | `<html>` | `"theme"` during an appearance change. |
| `data-nv-direction` | `<html>` | Set during a view transition that application code starts itself. |
| `data-nv-transition` | `<html>` | `"theme"` during a view transition the application starts for 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. |
| `data-nv-travelling` | selection group | Names the marker for a view transition the application starts itself. |
---
## Rules that bite
1. **`view-transition-name` must be unique per document.** A duplicate makes
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`.
1. **Do not start your own view transition around a page that has glass.** The snapshot cannot blur what sits behind frosted surfaces, so they go clear for its duration. Use `nv.transition`, which never snapshots the page. (If you must, `view-transition-name` has to be unique per document, or the browser skips the transition; give each selection group its own `--nv-selection-name`.)
2. **A menu and its trigger must name the same anchor** through `--nv-anchor`,
and the value must be unique per pair.
3. **Hide inactive `.nv-tab-panel`s with `hidden`.** `nv.selectTab` relies

789
dist/niveus.css vendored
View file

@ -324,6 +324,10 @@
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
--nv-color-material-glaze: light-dark(var(--nv-light-material-glaze), var(--nv-dark-material-glaze));
--nv-color-glaze-rim-lit: light-dark(var(--nv-light-glaze-rim-lit), var(--nv-dark-glaze-rim-lit));
--nv-color-glaze-rim-far: light-dark(var(--nv-light-glaze-rim-far), var(--nv-dark-glaze-rim-far));
--nv-color-glaze-specular: light-dark(var(--nv-light-glaze-specular), var(--nv-dark-glaze-specular));
/* --- Window controls ------------------------------------------------------- */
--nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));
@ -523,6 +527,12 @@
consistent between appearances: an ambient contact shadow that keeps the
edge crisp, a soft penumbra for the spread, and an umbra that only appears
once a surface is genuinely far from the canvas.
Two things keep a shadow visible, and both have failed before. The soft
layers use only a mild negative spread: a deep one tucks the blur under the
element and leaves almost nothing outside it. And the canvas has to be
lighter than the shadow colour — black cannot darken black — which is why the
dark canvas is not the darkest value it could be.
========================================================================== */
:root {
@ -531,27 +541,27 @@
--nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient),
0 2px 4px -1px var(--nv-color-shadow-penumbra),
0 6px 14px -8px var(--nv-color-shadow-umbra);
0 6px 14px -5px var(--nv-color-shadow-umbra);
--nv-elevation-2:
0 1px 1px var(--nv-color-shadow-ambient),
0 3px 6px -2px var(--nv-color-shadow-penumbra),
0 10px 20px -10px var(--nv-color-shadow-umbra);
0 10px 20px -7px var(--nv-color-shadow-umbra);
--nv-elevation-3:
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);
0 18px 36px -9px var(--nv-color-shadow-umbra);
--nv-elevation-4:
0 1px 2px var(--nv-color-shadow-ambient),
0 10px 20px -6px var(--nv-color-shadow-penumbra),
0 30px 60px -16px var(--nv-color-shadow-umbra);
0 30px 60px -12px var(--nv-color-shadow-umbra);
--nv-elevation-5:
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);
0 44px 88px -16px var(--nv-color-shadow-umbra);
/* --- Inset ---------------------------------------------------------------
Recessed surfaces: fields, wells, tracks, pressed controls. */
@ -765,6 +775,10 @@
over content: bars, docks, launchers, notifications, menus.
glass more translucent, lightly blurred. Contextual layers where the
content behind should stay legible: overlays, hover cards.
glaze glass with a dial. How frosted it is belongs to whoever is
looking at it, so it is one number (--nv-glass-frost) rather
than a fixed recipe, and it carries a rim that catches the
light and follows the pointer.
Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
and everything that holds content are solid; translucency is reserved for
@ -792,6 +806,21 @@
--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));
/* --- Frost ------------------------------------------------------------------
The one input the glaze material derives from: 0 is clear glass, 1 is
nearly opaque frost. Set it on :root for the whole site, on a subtree to
change just that region, or bind a slider to it with data-nv-var and let
the person looking choose. The glaze recipe reads it where it is used,
so a subtree override always reaches the surfaces inside it. */
--nv-glass-frost: 0.5;
--nv-glaze-blur-max: 56px; /* blur at frost 1 */
--nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */
--nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */
--nv-glaze-rim: 1.5px;
/* The light a pane holds just inside its edge, so it reads as a thick lens
rather than a flat film. */
--nv-glaze-glow: inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular);
/* --- Sheen ------------------------------------------------------------------
raised neutral controls: buttons, the selected tab, a titlebar
filled saturated fills: primary and danger buttons, checked marks
@ -887,16 +916,18 @@
:root,
[data-nv-brand] {
/* --- Canvas and surfaces -----------------------------------------------
Paper stays paper: the two surfaces that hold content are left untinted so
text keeps its contrast. Everything the surfaces sit *in* picks up a trace
of the brand, which is what stops an accent from looking stuck on. */
--nv-light-background: color-mix(in oklab, #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));
Every neutral carries a trace of the brand, content surfaces included, the
way dark does: that is what stops an accent from looking stuck on, and what
makes the two appearances read as one material. The paper is a soft
grey-stone, not white — a bright field glares, and flattens the bevels that
are meant to shape it. Text keeps its contrast: the primary is neutral. */
--nv-light-background: color-mix(in oklab, #d8d5ce, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-background-subtle: color-mix(in oklab, #dedbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-surface: color-mix(in oklab, #eeede9, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-elevated: color-mix(in oklab, #f2f1ed, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-inset: color-mix(in oklab, #dddbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-surface-hover: color-mix(in oklab, #e6e4de, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-active: color-mix(in oklab, #dad8d1, var(--nv-brand) var(--nv-brand-tint));
--nv-light-scrim: rgb(28 24 18 / 0.34);
/* --- Text ---------------------------------------------------------------
@ -905,7 +936,7 @@
loss of legibility. */
--nv-light-text-primary: var(--nv-palette-neutral-900);
--nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%);
--nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%);
--nv-light-text-tertiary: color-mix(in oklab, color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-palette-neutral-600) 40%), var(--nv-brand) 8%);
--nv-light-text-disabled: var(--nv-palette-neutral-400);
--nv-light-text-inverted: var(--nv-palette-neutral-0);
@ -990,20 +1021,20 @@
/* --- 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(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);
--nv-light-shadow-ambient: rgb(32 28 22 / 0.12);
--nv-light-shadow-penumbra: rgb(32 28 22 / 0.15);
--nv-light-shadow-umbra: rgb(32 28 22 / 0.24);
--nv-light-shadow-inset: rgb(32 28 22 / 0.2);
--nv-light-highlight: rgb(255 255 255 / 0.7);
--nv-light-highlight-strong: rgb(255 255 255 / 0.85);
/* 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-lowlight: rgb(32 28 22 / 0.14);
--nv-light-outline: rgb(32 28 22 / 0.08);
--nv-light-sheen-top: rgb(255 255 255 / 0.9);
--nv-light-sheen-bottom: rgb(40 32 20 / 0.07);
--nv-light-sheen-top: rgb(255 255 255 / 0.55);
--nv-light-sheen-bottom: rgb(32 28 22 / 0.11);
--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);
@ -1019,9 +1050,16 @@
--nv-light-elevation-tint-4: transparent;
--nv-light-elevation-tint-5: transparent;
--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);
--nv-light-material-frosted: color-mix(in oklab, rgb(228 227 222 / 0.6), var(--nv-brand) var(--nv-brand-tint));
--nv-light-material-glass: color-mix(in oklab, rgb(228 227 222 / 0.44), var(--nv-brand) var(--nv-brand-tint));
--nv-light-material-border: rgb(255 255 255 / 0.5);
/* Glaze: the tint it is made of, the rim that catches light on the side
facing the pointer, the rim on the far side, and the specular bloom. */
--nv-light-material-glaze: color-mix(in oklab, rgb(232 231 226 / 1), var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-glaze-rim-lit: rgb(255 255 255 / 0.9);
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.4);
--nv-light-glaze-specular: rgb(255 255 255 / 0.4);
/* --- Window controls --------------------------------------------------------- */
--nv-light-window-close: var(--nv-palette-window-close);
@ -1061,10 +1099,10 @@
:root,
[data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */
--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-background: color-mix(in oklab, #17171b, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-background-subtle: color-mix(in oklab, #1d1d21, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface: color-mix(in oklab, #232327, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-elevated: color-mix(in oklab, #2d2d32, 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));
@ -1151,9 +1189,9 @@
calc(c * 0.2) h);
/* --- Depth and material -------------------------------------------------- */
--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-ambient: rgb(0 0 0 / 0.6);
--nv-dark-shadow-penumbra: rgb(0 0 0 / 0.62);
--nv-dark-shadow-umbra: rgb(0 0 0 / 0.72);
--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);
@ -1184,6 +1222,11 @@
--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);
--nv-dark-material-glaze: color-mix(in oklab, rgb(30 30 38 / 1), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-glaze-rim-lit: rgb(255 255 255 / 0.55);
--nv-dark-glaze-rim-far: rgb(255 255 255 / 0.14);
--nv-dark-glaze-specular: rgb(255 255 255 / 0.18);
/* --- Window controls --------------------------------------------------------- */
--nv-dark-window-close: var(--nv-palette-window-close);
--nv-dark-window-minimize: var(--nv-palette-window-minimize);
@ -1337,6 +1380,21 @@
border-radius: var(--nv-radius-xs);
}
/* A link to a place on the same page travels there instead of cutting. */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
/* Whatever the browser draws itself — a bare checkbox, a progress bar, the
text caret, a date field's glyphs — takes the brand, so no native colour
survives next to the system's own. */
:root {
accent-color: var(--nv-color-accent);
caret-color: var(--nv-color-accent);
}
::selection {
background-color: var(--nv-color-selection);
color: var(--nv-color-text-primary);
@ -1770,6 +1828,105 @@
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Glaze ------------------------------------------------------------------
Glass with a dial, and a rim that catches the light.
.nv-material-glaze the material
--nv-glass-frost: 0 … 1 clear glass … heavy frost (set anywhere)
Frost is read here, where the surface is drawn, not copied into a token
upstream: that is what lets a subtree (or a slider the visitor holds) change
it and have every surface inside follow. It moves three things together —
how far the backdrop is blurred, how much of the tint is laid over it, and
how much color the blur gives back — because a pane that changed only one
of them would stop looking like the same pane.
The rim is a 1.5px gradient border made with a masked ::before: bright on
the side facing the light, faint on the far side. ::after is the specular
bloom, a soft glow under the pointer. niveus.js sets --nv-light-x, -y and
-angle on the surface; without it both sit at their defaults (light from the
top left), so the material still reads as lit, just not as following you.
Zero specificity on position: a menu or dialog using it keeps the positioning
its own component gave it. */
:where(.nv-material-glaze) {
position: relative;
}
.nv-material-glaze {
--_frost: var(--nv-glass-frost);
isolation: isolate;
background-color: color-mix(
in oklab,
var(--nv-color-material-glaze)
calc(var(--nv-glaze-tint-min) + (var(--nv-glaze-tint-max) - var(--nv-glaze-tint-min)) * var(--_frost)),
transparent
);
border: var(--nv-border-width) solid transparent;
box-shadow:
var(--nv-highlight-top-strong),
var(--nv-glaze-glow),
var(--nv-outline),
var(--nv-elevation-4);
backdrop-filter:
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
saturate(calc(140% + var(--_frost) * 60%));
-webkit-backdrop-filter:
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
saturate(calc(140% + var(--_frost) * 60%));
}
.nv-material-glaze::before,
.nv-material-glaze::after {
content: "";
position: absolute;
inset: calc(var(--nv-border-width) * -1);
border-radius: inherit;
pointer-events: none;
}
.nv-material-glaze::before {
padding: var(--nv-glaze-rim);
background: linear-gradient(
var(--nv-light-angle, 135deg),
var(--nv-color-glaze-rim-lit) 0%,
transparent 38%,
transparent 62%,
var(--nv-color-glaze-rim-far) 100%
);
mask:
linear-gradient(currentcolor 0 0) content-box,
linear-gradient(currentcolor 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
}
.nv-material-glaze::after {
z-index: -1;
background: radial-gradient(
circle 16rem at var(--nv-light-x, 15%) var(--nv-light-y, 0%),
var(--nv-color-glaze-specular),
transparent 70%
);
}
/* Where translucency cannot be honoured, glaze falls back to the elevated
solid, as the other glass materials do. */
@media (prefers-reduced-transparency: reduce) {
.nv-material-glaze {
background-color: var(--nv-color-surface-elevated);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-material-glaze::after { display: none; }
}
@supports not (backdrop-filter: blur(1px)) {
.nv-material-glaze { background-color: var(--nv-color-surface-elevated); }
.nv-material-glaze::after { display: none; }
}
/* --- Elevation -------------------------------------------------------------
Elevation utilities carry three things at once, because a lifted surface is
never only a shadow: the shadow itself, the highlight along its top edge,
@ -2139,6 +2296,16 @@
z-index: 1;
translate: none;
}
/* The chosen segment is a key held down: pressed into the surface, in the
brand colour, rather than a flat wash. This is what makes the appearance
toggle (and any segmented choice) show which option is in use. */
.nv-button-group > .nv-button[aria-pressed="true"] {
background-color: var(--nv-color-accent-subtle);
background-image: none;
color: var(--nv-color-accent-text);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
}
}
@layer niveus.components {
/* ==========================================================================
@ -2563,6 +2730,122 @@
translate: calc(var(--_travel) * -1) -50%;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Slider
--------------------------------------------------------------------------
<input class="nv-slider" type="range" min="0" max="100" value="50">
A native range input dressed as the rest of the controls are: the track is a
well cut into the surface, the fill is the accent seen inside it, and the
thumb is a knob lit from above. Keyboard, touch, step and form behaviour are
the browser's.
Firefox draws the fill itself (::-moz-range-progress). Chromium and Safari
do not expose one, so the fill is a gradient sized by --nv-slider-pos, which
niveus.js keeps in step with the value. Without the script the fill is
simply absent in those two engines and the thumb still tells the truth.
To bind a slider to a design token, add data-nv-var="--nv-glass-frost" (and
optionally data-nv-var-target="#region" for a subtree instead of :root).
========================================================================== */
.nv-slider {
--_track: 0.5rem;
--_thumb: 1.5rem;
--_pos: var(--nv-slider-pos, 50%);
display: block;
inline-size: 100%;
block-size: var(--_thumb);
margin: 0;
background: transparent;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.nv-slider:focus-visible {
outline: none;
}
/* --- Track ------------------------------------------------------------------ */
.nv-slider::-webkit-slider-runnable-track {
block-size: var(--_track);
background:
linear-gradient(var(--nv-color-accent) 0 0) 0 / var(--_pos) 100% no-repeat,
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-md), var(--nv-ledge);
}
.nv-slider::-moz-range-track {
block-size: var(--_track);
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-md), var(--nv-ledge);
}
.nv-slider::-moz-range-progress {
block-size: var(--_track);
background-color: var(--nv-color-accent);
border-radius: var(--nv-radius-full);
}
/* --- Thumb ------------------------------------------------------------------ */
.nv-slider::-webkit-slider-thumb {
inline-size: var(--_thumb);
block-size: var(--_thumb);
margin-block-start: calc((var(--_track) - var(--_thumb)) / 2);
background-color: var(--nv-color-knob);
background-image: var(--nv-sheen-knob);
border: 0;
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), var(--nv-elevation-2);
appearance: none;
-webkit-appearance: none;
transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
}
.nv-slider::-moz-range-thumb {
inline-size: var(--_thumb);
block-size: var(--_thumb);
box-sizing: border-box;
background-color: var(--nv-color-knob);
background-image: var(--nv-sheen-knob);
border: 0;
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), var(--nv-elevation-2);
transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
}
/* Hover only where there is a pointer to hover with; touch would leave the
thumb enlarged after a tap. */
@media (hover: hover) {
.nv-slider:hover::-webkit-slider-thumb { scale: 1.06; }
.nv-slider:hover::-moz-range-thumb { scale: 1.06; }
}
.nv-slider:active::-webkit-slider-thumb { scale: 0.96; }
.nv-slider:active::-moz-range-thumb { scale: 0.96; }
.nv-slider:focus-visible::-webkit-slider-thumb {
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
}
.nv-slider:focus-visible::-moz-range-thumb {
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
}
.nv-slider:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Card
@ -3066,6 +3349,32 @@
position: static;
}
/* On a phone the bar keeps to one row. Wrapping put the controls on a second
line that sat over the links, and with glass behind it that read as a
collision. Instead the list gives way: it takes the room that is left and
scrolls sideways inside the bar, and everything else keeps its size. */
@media (max-width: 40rem) {
.nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) {
flex-wrap: nowrap;
gap: var(--nv-space-2);
padding-inline: var(--nv-space-3);
}
.nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > * {
flex-shrink: 0;
}
.nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > .nv-nav__list {
flex: 1 1 0;
flex-shrink: 1;
overflow-x: auto;
}
.nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > .nv-nav__spacer {
display: none;
}
}
/* A navigation group used inside content rather than as page chrome: no bar,
no border, no blur — just the items and their travelling selection. */
.nv-nav--inline {
@ -3528,7 +3837,8 @@
cursor: not-allowed;
}
.nv-menu__item[aria-checked="true"]::before {
.nv-menu__item[aria-checked="true"]::before,
.nv-menu__item[aria-selected="true"]::before {
content: "";
inline-size: 0.28em;
block-size: 0.55em;
@ -3561,6 +3871,398 @@
color: var(--nv-color-text-tertiary);
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Picker
--------------------------------------------------------------------------
<button class="nv-input nv-picker nv-menu-trigger" popovertarget="fruit"
style="--nv-anchor: --fruit">
<span data-nv-picker-label>Choose a fruit</span>
</button>
<div class="nv-menu nv-menu--listbox" popover id="fruit" role="listbox"
style="--nv-anchor: --fruit">
<input class="nv-input nv-input--sm nv-menu__search" placeholder="Search…">
<button class="nv-menu__item" role="option" data-value="apple">Apple</button>
<button class="nv-menu__item" role="option" data-value="pear" aria-selected="true">Pear</button>
</div>
A popup list is a menu whose items are options: the same frosted surface,
anchoring and light dismiss, with a selection that stays. A picker is its
trigger: a field-shaped button that shows the current choice and a chevron
that turns over while the list is open.
niveus.js adds what a native <select> would give for free and a styled
popover does not: arrow-key movement, type-ahead, Enter or Space to choose,
filtering when a .nv-menu__search is present, and an `nv:select` event on the
trigger carrying { value, label }.
========================================================================== */
.nv-picker {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nv-space-2);
text-align: start;
cursor: pointer;
}
.nv-picker[data-empty] {
color: var(--nv-color-text-tertiary);
}
.nv-picker::after {
content: "";
flex: none;
inline-size: 0.4em;
block-size: 0.4em;
translate: 0 -0.12em;
rotate: 45deg;
border-inline-end: 2px solid var(--nv-color-text-tertiary);
border-block-end: 2px solid var(--nv-color-text-tertiary);
transition: rotate var(--nv-duration-quick) var(--nv-ease-standard);
}
.nv-picker:has(+ .nv-menu:popover-open)::after {
rotate: 225deg;
translate: 0 0.12em;
}
.nv-menu--listbox {
min-inline-size: anchor-size(inline);
}
.nv-menu__search {
inline-size: 100%;
margin-block-end: var(--nv-space-1);
}
/* display on .nv-menu__item would otherwise win over the hidden attribute. */
.nv-menu__item[hidden] {
display: none;
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Calendar
--------------------------------------------------------------------------
<div class="nv-calendar nv-surface" data-nv-calendar data-nv-value="2026-10-28">
<div class="nv-calendar__header">
<button class="nv-button nv-button--ghost nv-button--icon"
data-nv-calendar-step="-1" aria-label="Previous month">‹</button>
<h3 class="nv-calendar__title" aria-live="polite">October 2026</h3>
<button class="nv-button nv-button--ghost nv-button--icon"
data-nv-calendar-step="1" aria-label="Next month">›</button>
</div>
<div class="nv-calendar__grid" role="grid" aria-labelledby="…">
<div role="row"> <span class="nv-calendar__weekday" role="columnheader">Mo</span> … </div>
<div role="row">
<button class="nv-calendar__day" role="gridcell" data-date="2026-10-01">1</button> …
</div>
</div>
</div>
A calendar holds content, so it takes no material of its own: put it on
.nv-surface, or on .nv-material-glaze when it floats over something. The
grid and its states are plain markup and render without niveus.js; the
script's only jobs are to build the days for a month, step between months,
and keep the keyboard model (arrows, PageUp/PageDown, Home/End).
State lives in attributes the platform already has a meaning for:
aria-selected="true" the chosen day
aria-current="date" today
data-outside a day that belongs to the neighbouring month
disabled outside data-nv-min / data-nv-max
========================================================================== */
.nv-calendar {
--_cell: 2.5rem;
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
inline-size: fit-content;
max-inline-size: 100%;
padding: var(--nv-space-4);
border-radius: var(--nv-radius-xl);
color: var(--nv-color-text-primary);
}
.nv-calendar__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nv-space-2);
}
.nv-calendar__title {
margin: 0;
font: var(--nv-font-label);
font-size: var(--nv-font-size-md);
font-weight: var(--nv-font-weight-semibold);
}
.nv-calendar__grid {
display: grid;
grid-template-columns: repeat(7, var(--_cell));
gap: var(--nv-space-0-5) 0;
}
.nv-calendar__grid > [role="row"] {
display: contents;
}
.nv-calendar__weekday {
display: grid;
place-items: center;
block-size: var(--nv-space-8);
color: var(--nv-color-text-tertiary);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
}
.nv-calendar__day {
inline-size: var(--_cell);
block-size: var(--_cell);
padding: 0;
background-color: transparent;
border: 0;
border-radius: var(--nv-radius-full);
color: var(--nv-color-text-primary);
font: var(--nv-font-label);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: var(--nv-transition-colors), var(--nv-transition-depth);
}
@media (hover: hover) {
/* Hover presses the day slightly into the surface; it is never a flat wash
of colour, because the chosen day is a raised key and the two must read as
the same material. */
.nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
background-color: var(--nv-color-surface-hover);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
}
}
.nv-calendar__day[data-outside] {
color: var(--nv-color-text-tertiary);
}
/* Today is ringed, not filled: filled means chosen. */
.nv-calendar__day[aria-current="date"] {
color: var(--nv-color-accent-text);
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent), var(--nv-elevation-inset-sm);
}
/* The chosen day is a small raised key, lit like the filled buttons. */
.nv-calendar__day[aria-selected="true"] {
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-1);
}
.nv-calendar__day:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
.nv-calendar__day[aria-selected="true"]:focus-visible {
box-shadow: var(--nv-bevel), var(--nv-focus-ring);
}
.nv-calendar__day:disabled {
color: var(--nv-color-text-disabled);
cursor: not-allowed;
}
/* A day pressed into place springs up to full size. Only when it was just
chosen: a selection that is merely *there* on load stays still. */
.nv-calendar__day[aria-selected="true"][data-nv-pop] {
animation: nv-day-pop var(--nv-duration-quick) var(--nv-ease-spring);
}
@keyframes nv-day-pop {
from { scale: 0.72; }
to { scale: 1; }
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Time picker
--------------------------------------------------------------------------
<div class="nv-timepicker nv-surface" data-nv-timepicker
data-nv-value="09:30" data-nv-step="5" data-nv-format-toggle></div>
Wheels, as on a phone: each column scrolls and snaps, and whatever sits in
the lens across the middle is the value. Scrolling, snapping, momentum and
touch are the browser's; niveus.js builds the columns, reads which row is
centred, and exposes the value as data-nv-value and an `nv:time` event.
The lens is the selection colour laid over the centre row, so it follows the
brand. Rows fade towards the top and bottom with a mask rather than a
coloured gradient, which keeps the fade right on any surface — solid, or
Glaze with something moving behind it.
The format is 24-hour unless told otherwise. With data-nv-format-toggle a
third wheel reads 24H / AM / PM: swipe it to AM or PM and the hour wheel
becomes 1–12; swipe back to 24H and it returns to 0–23. The value stays
24-hour either way, so a form never has to know which the person used.
Without niveus.js the element is empty. For a no-script fallback, use a
native <input type="time" class="nv-input">.
========================================================================== */
.nv-timepicker {
--_row: 2.25rem;
--_rows: 5;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--nv-space-2);
padding: var(--nv-space-2) var(--nv-space-3);
border-radius: var(--nv-radius-xl);
color: var(--nv-color-text-primary);
}
.nv-timepicker__wheels {
position: relative;
display: flex;
gap: var(--nv-space-1);
}
/* The lens: a bar of tinted glass lying across the centre row. It is raised
like the keys elsewhere in the system — lit on top, shaded below, with a
contact line — not a flat band of colour, so it reads as a thing the wheels
pass under. */
.nv-timepicker__wheels::before {
content: "";
position: absolute;
inset-inline: calc(var(--nv-space-2) * -1);
inset-block-start: 50%;
block-size: var(--_row);
translate: 0 -50%;
background-color: var(--nv-color-selection);
background-image: var(--nv-sheen-raised);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
pointer-events: none;
}
.nv-timepicker__column {
position: relative;
inline-size: 3.5rem;
block-size: calc(var(--_row) * var(--_rows));
margin: 0;
padding: 0;
list-style: none;
cursor: grab;
user-select: none;
/* The wheel owns vertical gestures, whatever the pointer: niveus.js drags it
itself so a finger, a mouse and a desktop browser's phone mode behave
alike. Without the script the wheel still scrolls natively with a wheel
or keys; set touch-action back to pan-y if you ship it without. */
touch-action: none;
overflow-y: auto;
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
scrollbar-width: none;
mask-image: linear-gradient(
transparent,
currentcolor 28%,
currentcolor 72%,
transparent
);
/* Depth for the drum effect below: rows turn away from the viewer as they
leave the centre. */
perspective: 14rem;
}
.nv-timepicker__column::-webkit-scrollbar {
display: none;
}
/* Spacers, not padding: they let the first and last rows reach the centre.
Firefox leaves the bottom padding of a scroll container out of its
scrollable area, which stranded the last rows (and made a three-row wheel
unscrollable); real boxes always count. */
.nv-timepicker__column::before,
.nv-timepicker__column::after {
content: "";
display: block;
block-size: calc(var(--_row) * (var(--_rows) - 1) / 2);
}
.nv-timepicker__column[data-nv-dragging] {
cursor: grabbing;
}
.nv-timepicker__column:focus-visible {
outline: none;
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-focus-ring);
}
.nv-timepicker__option {
display: grid;
place-items: center;
block-size: var(--_row);
scroll-snap-align: center;
color: var(--nv-color-text-tertiary);
font: var(--nv-font-label);
font-size: var(--nv-font-size-lg);
font-variant-numeric: tabular-nums;
cursor: pointer;
/* --nv-pull is how far the wheel has been dragged past its end. Letting go
removes it, and the spring easing carries the rows back with a small
overshoot — the way a phone's list gives at its limit and settles. */
translate: 0 var(--nv-pull, 0);
transition:
var(--nv-transition-colors),
translate var(--nv-duration-slower) var(--nv-ease-spring);
}
.nv-timepicker__column[data-nv-pulling] .nv-timepicker__option {
transition: var(--nv-transition-colors);
}
/* A drum: each row shrinks, fades and tilts away in proportion to how far it
is from the lens. Driven by the scroll position itself, so it needs no
script and tracks a finger exactly. Where scroll-driven animation is missing
the rows are simply flat, which is still correct. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.nv-timepicker__option {
animation: nv-wheel-drum linear both;
animation-timeline: view(block);
}
}
}
@keyframes nv-wheel-drum {
0% { scale: 0.78; opacity: 0.25; rotate: x 52deg; }
50% { scale: 1; opacity: 1; rotate: x 0deg; }
100% { scale: 0.78; opacity: 0.25; rotate: x -52deg; }
}
.nv-timepicker__option[aria-selected="true"] {
color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-semibold);
}
.nv-timepicker__separator {
display: grid;
place-items: center;
align-self: center;
color: var(--nv-color-text-tertiary);
font: var(--nv-font-label);
font-size: var(--nv-font-size-lg);
}
}
@layer niveus.components {
/* ==========================================================================
Niveus — Disclosure
@ -3799,6 +4501,19 @@
--nv-button-offset: 0 -50%;
}
/* The arrows float over the cards, so they are glass: frosted, with the content
passing blurred beneath them, rather than an opaque key hiding what it sits
on. This is the system's rule for anything that floats over content. */
.nv-scroller-frame [data-nv-scroll] {
--_bg: var(--nv-material-frosted-bg);
--_bg-hover: var(--nv-material-frosted-bg);
--_bg-active: var(--nv-material-frosted-bg);
--_border: var(--nv-material-frosted-border);
--_sheen: none;
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
/* Belt and braces: the frame knows these controls float, so their centring
survives even if the markup forgets --float. */
.nv-scroller-frame [data-nv-scroll],

View file

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

View file

@ -491,9 +491,13 @@
const count = document.getElementById("basket-count");
document.querySelectorAll("[data-add]").forEach((button) => {
button.addEventListener("click", () => {
nv.transition(() => {
count.textContent = String(Number(count.textContent) + 1);
if (!nv.prefersReducedMotion()) {
count.animate([{ scale: 1.4 }, { scale: 1 }], {
duration: 280,
easing: getComputedStyle(document.documentElement).getPropertyValue("--nv-ease-spring"),
});
}
button.textContent = "Added";
setTimeout(() => { button.textContent = "Add to basket"; }, 1200);
});

View file

@ -67,6 +67,72 @@
place-items: center;
}
/* Glaze specimen: color behind the pane, so the frost has something to
act on. The orbs and the line of type are only there to be blurred. */
.glaze-demo {
position: relative;
margin-block-start: var(--nv-space-6);
min-block-size: 22rem;
display: grid;
place-items: center;
overflow: hidden;
}
.glaze-demo__scene {
position: absolute;
inset: 0;
pointer-events: none;
}
.glaze-demo__orb {
position: absolute;
border-radius: var(--nv-radius-full);
inline-size: var(--_size);
block-size: var(--_size);
inset-inline-start: var(--_x);
inset-block-start: var(--_y);
background: var(--_fill);
}
.glaze-demo__type {
position: absolute;
inset-inline: 0;
inset-block-end: var(--nv-space-4);
text-align: center;
font: var(--nv-font-display);
font-size: clamp(2.5rem, 9vw, 5.5rem);
font-weight: 700;
color: var(--nv-color-text-primary);
opacity: 0.85;
white-space: nowrap;
}
.glaze-demo__panel {
position: relative;
inline-size: min(26rem, 100%);
padding: var(--nv-space-6);
border-radius: var(--nv-radius-xl);
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
}
.picker-demo {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: center;
gap: var(--nv-space-6);
margin-block-start: var(--nv-space-6);
}
.picker-demo__column {
display: flex;
flex-direction: column;
gap: var(--nv-space-4);
inline-size: min(16rem, 100%);
}
.specimen__name {
font: var(--nv-font-label);
}
@ -297,6 +363,84 @@
</div>
</div>
</div>
<div class="nv-bed glaze-demo" id="glaze-bed">
<div class="glaze-demo__scene" aria-hidden="true">
<span class="glaze-demo__orb" style="--_size: 11rem; --_x: 8%; --_y: 8%; --_fill: var(--nv-color-accent)"></span>
<span class="glaze-demo__orb" style="--_size: 8rem; --_x: 68%; --_y: 14%; --_fill: var(--nv-color-danger)"></span>
<span class="glaze-demo__orb" style="--_size: 9rem; --_x: 52%; --_y: 58%; --_fill: var(--nv-color-success)"></span>
<span class="glaze-demo__type">Niveus glaze</span>
</div>
<div class="nv-material-glaze glaze-demo__panel">
<div>
<h3 class="nv-heading">Glaze</h3>
<p class="nv-prose">
One dial controls how frosted this pane is. The rim brightens on
the side facing your pointer.
</p>
</div>
<div class="nv-field">
<label class="nv-field__label" for="frost">Frost</label>
<input class="nv-slider" id="frost" type="range" min="0" max="1" step="0.01"
data-nv-var="--nv-glass-frost" data-nv-var-target="#glaze-bed">
<span class="nv-field__hint">Clear glass on the left, heavy frost on the right.</span>
</div>
<div class="nv-cluster nv-cluster--tight">
<button class="nv-button nv-button--primary">Continue</button>
<button class="nv-button">Cancel</button>
</div>
</div>
</div>
</section>
<!-- ================================================================= -->
<section id="pickers">
<div class="section__head">
<h2 class="nv-heading">Pickers</h2>
<p class="nv-prose">
A calendar, wheels for time, and a popup list. They hold content, so
they sit on solid surfaces; here the first two float on Glaze
over a bed, which is where a date field's popover would be.
</p>
</div>
<div class="nv-bed picker-demo" id="picker-bed">
<div class="nv-calendar nv-material-glaze" data-nv-calendar data-nv-value="2026-10-28"
data-nv-min="2026-10-05">
<div class="nv-calendar__header">
<button class="nv-button nv-button--ghost nv-button--icon" data-nv-calendar-step="-1"
aria-label="Previous month">&lsaquo;</button>
<h3 class="nv-calendar__title" aria-live="polite"></h3>
<button class="nv-button nv-button--ghost nv-button--icon" data-nv-calendar-step="1"
aria-label="Next month">&rsaquo;</button>
</div>
<div class="nv-calendar__grid" role="grid"></div>
</div>
<div class="picker-demo__column">
<div class="nv-timepicker nv-material-glaze" data-nv-timepicker data-nv-value="09:30"
data-nv-step="5" data-nv-format-toggle aria-label="Start time"></div>
<output class="nv-caption nv-code" id="picker-output">09:30</output>
<div class="nv-field">
<label class="nv-field__label" id="fruit-label">Fruit</label>
<button class="nv-input nv-picker nv-menu-trigger" data-empty popovertarget="fruit"
aria-labelledby="fruit-label" style="--nv-anchor: --fruit">
<span data-nv-picker-label>Choose a fruit</span>
</button>
<div class="nv-menu nv-menu--listbox" popover id="fruit" style="--nv-anchor: --fruit">
<input class="nv-input nv-input--sm nv-menu__search" placeholder="Search…" aria-label="Search fruit">
<button class="nv-menu__item" role="option" data-value="apple">Apple</button>
<button class="nv-menu__item" role="option" data-value="apricot">Apricot</button>
<button class="nv-menu__item" role="option" data-value="banana">Banana</button>
<button class="nv-menu__item" role="option" data-value="cherry">Cherry</button>
<button class="nv-menu__item" role="option" data-value="mango">Mango</button>
<button class="nv-menu__item" role="option" data-value="pear">Pear</button>
</div>
</div>
</div>
</div>
</section>
<!-- ================================================================= -->
@ -624,7 +768,7 @@
Weekly digest
</label>
<label class="nv-choice">
<input class="nv-checkbox" type="checkbox" id="f-indeterminate">
<input class="nv-checkbox" type="checkbox" id="f-indeterminate" data-nv-tristate="partial">
Partial selection
</label>
<label class="nv-choice">
@ -1315,6 +1459,10 @@
</dialog>
<script>
document.addEventListener("nv:time", function (event) {
var out = document.getElementById("picker-output");
if (out) out.textContent = event.detail.value;
});
/* --------------------------------------------------------------------
Page behaviour. Note what is *not* here: no style values, no colors,
no durations. Continuity comes from nv.transition(); this file only
@ -1338,21 +1486,19 @@
const brandInput = document.getElementById("brand-input");
const brandValue = document.getElementById("brand-value");
function setBrand(value, continuous) {
const apply = () => {
/* No page-wide transition here: surfaces fade their own colours, and a
snapshot of the page would flatten every frosted surface while it ran. */
function setBrand(value) {
document.documentElement.style.setProperty("--nv-brand", value);
brandInput.value = value;
brandValue.textContent = value;
};
if (continuous) nv.transition(apply, { kind: "theme" });
else apply();
}
brandInput.addEventListener("input", (event) => setBrand(event.target.value, false));
brandInput.addEventListener("input", (event) => setBrand(event.target.value));
document.getElementById("brand-presets").addEventListener("click", (event) => {
const preset = event.target.closest("[data-brand]");
if (preset) setBrand(preset.dataset.brand, true);
if (preset) setBrand(preset.dataset.brand);
});
/* A selection group whose emphasis travels: one marker, moved inside a
@ -1379,7 +1525,6 @@
addEventListener("scroll", onScroll, { passive: true });
onScroll();
document.getElementById("f-indeterminate").indeterminate = true;
/* Build the semantic color list from the tokens themselves, so the page
cannot show a role the system does not actually define. */

View file

@ -134,6 +134,21 @@ textarea {
border-radius: var(--nv-radius-xs);
}
/* A link to a place on the same page travels there instead of cutting. */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
/* Whatever the browser draws itself — a bare checkbox, a progress bar, the
text caret, a date field's glyphs — takes the brand, so no native colour
survives next to the system's own. */
:root {
accent-color: var(--nv-color-accent);
caret-color: var(--nv-color-accent);
}
::selection {
background-color: var(--nv-color-selection);
color: var(--nv-color-text-primary);

View file

@ -312,3 +312,13 @@
z-index: 1;
translate: none;
}
/* The chosen segment is a key held down: pressed into the surface, in the
brand colour, rather than a flat wash. This is what makes the appearance
toggle (and any segmented choice) show which option is in use. */
.nv-button-group > .nv-button[aria-pressed="true"] {
background-color: var(--nv-color-accent-subtle);
background-image: none;
color: var(--nv-color-accent-text);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
}

View file

@ -0,0 +1,146 @@
/* ==========================================================================
Niveus — Calendar
--------------------------------------------------------------------------
<div class="nv-calendar nv-surface" data-nv-calendar data-nv-value="2026-10-28">
<div class="nv-calendar__header">
<button class="nv-button nv-button--ghost nv-button--icon"
data-nv-calendar-step="-1" aria-label="Previous month">‹</button>
<h3 class="nv-calendar__title" aria-live="polite">October 2026</h3>
<button class="nv-button nv-button--ghost nv-button--icon"
data-nv-calendar-step="1" aria-label="Next month">›</button>
</div>
<div class="nv-calendar__grid" role="grid" aria-labelledby="…">
<div role="row"> <span class="nv-calendar__weekday" role="columnheader">Mo</span> … </div>
<div role="row">
<button class="nv-calendar__day" role="gridcell" data-date="2026-10-01">1</button> …
</div>
</div>
</div>
A calendar holds content, so it takes no material of its own: put it on
.nv-surface, or on .nv-material-glaze when it floats over something. The
grid and its states are plain markup and render without niveus.js; the
script's only jobs are to build the days for a month, step between months,
and keep the keyboard model (arrows, PageUp/PageDown, Home/End).
State lives in attributes the platform already has a meaning for:
aria-selected="true" the chosen day
aria-current="date" today
data-outside a day that belongs to the neighbouring month
disabled outside data-nv-min / data-nv-max
========================================================================== */
.nv-calendar {
--_cell: 2.5rem;
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
inline-size: fit-content;
max-inline-size: 100%;
padding: var(--nv-space-4);
border-radius: var(--nv-radius-xl);
color: var(--nv-color-text-primary);
}
.nv-calendar__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nv-space-2);
}
.nv-calendar__title {
margin: 0;
font: var(--nv-font-label);
font-size: var(--nv-font-size-md);
font-weight: var(--nv-font-weight-semibold);
}
.nv-calendar__grid {
display: grid;
grid-template-columns: repeat(7, var(--_cell));
gap: var(--nv-space-0-5) 0;
}
.nv-calendar__grid > [role="row"] {
display: contents;
}
.nv-calendar__weekday {
display: grid;
place-items: center;
block-size: var(--nv-space-8);
color: var(--nv-color-text-tertiary);
font: var(--nv-font-caption);
font-weight: var(--nv-font-weight-semibold);
letter-spacing: var(--nv-letter-spacing-wider);
text-transform: uppercase;
}
.nv-calendar__day {
inline-size: var(--_cell);
block-size: var(--_cell);
padding: 0;
background-color: transparent;
border: 0;
border-radius: var(--nv-radius-full);
color: var(--nv-color-text-primary);
font: var(--nv-font-label);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: var(--nv-transition-colors), var(--nv-transition-depth);
}
@media (hover: hover) {
/* Hover presses the day slightly into the surface; it is never a flat wash
of colour, because the chosen day is a raised key and the two must read as
the same material. */
.nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
background-color: var(--nv-color-surface-hover);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
}
}
.nv-calendar__day[data-outside] {
color: var(--nv-color-text-tertiary);
}
/* Today is ringed, not filled: filled means chosen. */
.nv-calendar__day[aria-current="date"] {
color: var(--nv-color-accent-text);
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent), var(--nv-elevation-inset-sm);
}
/* The chosen day is a small raised key, lit like the filled buttons. */
.nv-calendar__day[aria-selected="true"] {
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-1);
}
.nv-calendar__day:focus-visible {
outline: none;
box-shadow: var(--nv-focus-ring);
}
.nv-calendar__day[aria-selected="true"]:focus-visible {
box-shadow: var(--nv-bevel), var(--nv-focus-ring);
}
.nv-calendar__day:disabled {
color: var(--nv-color-text-disabled);
cursor: not-allowed;
}
/* A day pressed into place springs up to full size. Only when it was just
chosen: a selection that is merely *there* on load stays still. */
.nv-calendar__day[aria-selected="true"][data-nv-pop] {
animation: nv-day-pop var(--nv-duration-quick) var(--nv-ease-spring);
}
@keyframes nv-day-pop {
from { scale: 0.72; }
to { scale: 1; }
}

View file

@ -142,7 +142,8 @@
cursor: not-allowed;
}
.nv-menu__item[aria-checked="true"]::before {
.nv-menu__item[aria-checked="true"]::before,
.nv-menu__item[aria-selected="true"]::before {
content: "";
inline-size: 0.28em;
block-size: 0.55em;

View file

@ -56,6 +56,32 @@
position: static;
}
/* On a phone the bar keeps to one row. Wrapping put the controls on a second
line that sat over the links, and with glass behind it that read as a
collision. Instead the list gives way: it takes the room that is left and
scrolls sideways inside the bar, and everything else keeps its size. */
@media (max-width: 40rem) {
.nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) {
flex-wrap: nowrap;
gap: var(--nv-space-2);
padding-inline: var(--nv-space-3);
}
.nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > * {
flex-shrink: 0;
}
.nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > .nv-nav__list {
flex: 1 1 0;
flex-shrink: 1;
overflow-x: auto;
}
.nv-nav:not(.nv-nav--vertical):not(.nv-nav--inline) > .nv-nav__spacer {
display: none;
}
}
/* A navigation group used inside content rather than as page chrome: no bar,
no border, no blur — just the items and their travelling selection. */
.nv-nav--inline {

View file

@ -0,0 +1,69 @@
/* ==========================================================================
Niveus — Picker
--------------------------------------------------------------------------
<button class="nv-input nv-picker nv-menu-trigger" popovertarget="fruit"
style="--nv-anchor: --fruit">
<span data-nv-picker-label>Choose a fruit</span>
</button>
<div class="nv-menu nv-menu--listbox" popover id="fruit" role="listbox"
style="--nv-anchor: --fruit">
<input class="nv-input nv-input--sm nv-menu__search" placeholder="Search…">
<button class="nv-menu__item" role="option" data-value="apple">Apple</button>
<button class="nv-menu__item" role="option" data-value="pear" aria-selected="true">Pear</button>
</div>
A popup list is a menu whose items are options: the same frosted surface,
anchoring and light dismiss, with a selection that stays. A picker is its
trigger: a field-shaped button that shows the current choice and a chevron
that turns over while the list is open.
niveus.js adds what a native <select> would give for free and a styled
popover does not: arrow-key movement, type-ahead, Enter or Space to choose,
filtering when a .nv-menu__search is present, and an `nv:select` event on the
trigger carrying { value, label }.
========================================================================== */
.nv-picker {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nv-space-2);
text-align: start;
cursor: pointer;
}
.nv-picker[data-empty] {
color: var(--nv-color-text-tertiary);
}
.nv-picker::after {
content: "";
flex: none;
inline-size: 0.4em;
block-size: 0.4em;
translate: 0 -0.12em;
rotate: 45deg;
border-inline-end: 2px solid var(--nv-color-text-tertiary);
border-block-end: 2px solid var(--nv-color-text-tertiary);
transition: rotate var(--nv-duration-quick) var(--nv-ease-standard);
}
.nv-picker:has(+ .nv-menu:popover-open)::after {
rotate: 225deg;
translate: 0 0.12em;
}
.nv-menu--listbox {
min-inline-size: anchor-size(inline);
}
.nv-menu__search {
inline-size: 100%;
margin-block-end: var(--nv-space-1);
}
/* display on .nv-menu__item would otherwise win over the hidden attribute. */
.nv-menu__item[hidden] {
display: none;
}

View file

@ -102,6 +102,19 @@
--nv-button-offset: 0 -50%;
}
/* The arrows float over the cards, so they are glass: frosted, with the content
passing blurred beneath them, rather than an opaque key hiding what it sits
on. This is the system's rule for anything that floats over content. */
.nv-scroller-frame [data-nv-scroll] {
--_bg: var(--nv-material-frosted-bg);
--_bg-hover: var(--nv-material-frosted-bg);
--_bg-active: var(--nv-material-frosted-bg);
--_border: var(--nv-material-frosted-border);
--_sheen: none;
backdrop-filter: var(--nv-backdrop-frosted);
-webkit-backdrop-filter: var(--nv-backdrop-frosted);
}
/* Belt and braces: the frame knows these controls float, so their centring
survives even if the markup forgets --float. */
.nv-scroller-frame [data-nv-scroll],

View file

@ -0,0 +1,114 @@
/* ==========================================================================
Niveus — Slider
--------------------------------------------------------------------------
<input class="nv-slider" type="range" min="0" max="100" value="50">
A native range input dressed as the rest of the controls are: the track is a
well cut into the surface, the fill is the accent seen inside it, and the
thumb is a knob lit from above. Keyboard, touch, step and form behaviour are
the browser's.
Firefox draws the fill itself (::-moz-range-progress). Chromium and Safari
do not expose one, so the fill is a gradient sized by --nv-slider-pos, which
niveus.js keeps in step with the value. Without the script the fill is
simply absent in those two engines and the thumb still tells the truth.
To bind a slider to a design token, add data-nv-var="--nv-glass-frost" (and
optionally data-nv-var-target="#region" for a subtree instead of :root).
========================================================================== */
.nv-slider {
--_track: 0.5rem;
--_thumb: 1.5rem;
--_pos: var(--nv-slider-pos, 50%);
display: block;
inline-size: 100%;
block-size: var(--_thumb);
margin: 0;
background: transparent;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
}
.nv-slider:focus-visible {
outline: none;
}
/* --- Track ------------------------------------------------------------------ */
.nv-slider::-webkit-slider-runnable-track {
block-size: var(--_track);
background:
linear-gradient(var(--nv-color-accent) 0 0) 0 / var(--_pos) 100% no-repeat,
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-md), var(--nv-ledge);
}
.nv-slider::-moz-range-track {
block-size: var(--_track);
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-md), var(--nv-ledge);
}
.nv-slider::-moz-range-progress {
block-size: var(--_track);
background-color: var(--nv-color-accent);
border-radius: var(--nv-radius-full);
}
/* --- Thumb ------------------------------------------------------------------ */
.nv-slider::-webkit-slider-thumb {
inline-size: var(--_thumb);
block-size: var(--_thumb);
margin-block-start: calc((var(--_track) - var(--_thumb)) / 2);
background-color: var(--nv-color-knob);
background-image: var(--nv-sheen-knob);
border: 0;
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), var(--nv-elevation-2);
appearance: none;
-webkit-appearance: none;
transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
}
.nv-slider::-moz-range-thumb {
inline-size: var(--_thumb);
block-size: var(--_thumb);
box-sizing: border-box;
background-color: var(--nv-color-knob);
background-image: var(--nv-sheen-knob);
border: 0;
border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), var(--nv-elevation-2);
transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
}
/* Hover only where there is a pointer to hover with; touch would leave the
thumb enlarged after a tap. */
@media (hover: hover) {
.nv-slider:hover::-webkit-slider-thumb { scale: 1.06; }
.nv-slider:hover::-moz-range-thumb { scale: 1.06; }
}
.nv-slider:active::-webkit-slider-thumb { scale: 0.96; }
.nv-slider:active::-moz-range-thumb { scale: 0.96; }
.nv-slider:focus-visible::-webkit-slider-thumb {
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
}
.nv-slider:focus-visible::-moz-range-thumb {
box-shadow: var(--nv-focus-ring), var(--nv-elevation-2);
}
.nv-slider:disabled {
cursor: not-allowed;
opacity: 0.55;
}

View file

@ -61,6 +61,105 @@
-webkit-backdrop-filter: var(--nv-backdrop-glass);
}
/* --- Glaze ------------------------------------------------------------------
Glass with a dial, and a rim that catches the light.
.nv-material-glaze the material
--nv-glass-frost: 0 … 1 clear glass … heavy frost (set anywhere)
Frost is read here, where the surface is drawn, not copied into a token
upstream: that is what lets a subtree (or a slider the visitor holds) change
it and have every surface inside follow. It moves three things together —
how far the backdrop is blurred, how much of the tint is laid over it, and
how much color the blur gives back — because a pane that changed only one
of them would stop looking like the same pane.
The rim is a 1.5px gradient border made with a masked ::before: bright on
the side facing the light, faint on the far side. ::after is the specular
bloom, a soft glow under the pointer. niveus.js sets --nv-light-x, -y and
-angle on the surface; without it both sit at their defaults (light from the
top left), so the material still reads as lit, just not as following you.
Zero specificity on position: a menu or dialog using it keeps the positioning
its own component gave it. */
:where(.nv-material-glaze) {
position: relative;
}
.nv-material-glaze {
--_frost: var(--nv-glass-frost);
isolation: isolate;
background-color: color-mix(
in oklab,
var(--nv-color-material-glaze)
calc(var(--nv-glaze-tint-min) + (var(--nv-glaze-tint-max) - var(--nv-glaze-tint-min)) * var(--_frost)),
transparent
);
border: var(--nv-border-width) solid transparent;
box-shadow:
var(--nv-highlight-top-strong),
var(--nv-glaze-glow),
var(--nv-outline),
var(--nv-elevation-4);
backdrop-filter:
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
saturate(calc(140% + var(--_frost) * 60%));
-webkit-backdrop-filter:
blur(calc(4px + var(--_frost) * (var(--nv-glaze-blur-max) - 4px)))
saturate(calc(140% + var(--_frost) * 60%));
}
.nv-material-glaze::before,
.nv-material-glaze::after {
content: "";
position: absolute;
inset: calc(var(--nv-border-width) * -1);
border-radius: inherit;
pointer-events: none;
}
.nv-material-glaze::before {
padding: var(--nv-glaze-rim);
background: linear-gradient(
var(--nv-light-angle, 135deg),
var(--nv-color-glaze-rim-lit) 0%,
transparent 38%,
transparent 62%,
var(--nv-color-glaze-rim-far) 100%
);
mask:
linear-gradient(currentcolor 0 0) content-box,
linear-gradient(currentcolor 0 0);
mask-composite: exclude;
-webkit-mask-composite: xor;
}
.nv-material-glaze::after {
z-index: -1;
background: radial-gradient(
circle 16rem at var(--nv-light-x, 15%) var(--nv-light-y, 0%),
var(--nv-color-glaze-specular),
transparent 70%
);
}
/* Where translucency cannot be honoured, glaze falls back to the elevated
solid, as the other glass materials do. */
@media (prefers-reduced-transparency: reduce) {
.nv-material-glaze {
background-color: var(--nv-color-surface-elevated);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.nv-material-glaze::after { display: none; }
}
@supports not (backdrop-filter: blur(1px)) {
.nv-material-glaze { background-color: var(--nv-color-surface-elevated); }
.nv-material-glaze::after { display: none; }
}
/* --- Elevation -------------------------------------------------------------
Elevation utilities carry three things at once, because a lifted surface is
never only a shadow: the shadow itself, the highlight along its top edge,

View file

@ -0,0 +1,171 @@
/* ==========================================================================
Niveus — Time picker
--------------------------------------------------------------------------
<div class="nv-timepicker nv-surface" data-nv-timepicker
data-nv-value="09:30" data-nv-step="5" data-nv-format-toggle></div>
Wheels, as on a phone: each column scrolls and snaps, and whatever sits in
the lens across the middle is the value. Scrolling, snapping, momentum and
touch are the browser's; niveus.js builds the columns, reads which row is
centred, and exposes the value as data-nv-value and an `nv:time` event.
The lens is the selection colour laid over the centre row, so it follows the
brand. Rows fade towards the top and bottom with a mask rather than a
coloured gradient, which keeps the fade right on any surface — solid, or
Glaze with something moving behind it.
The format is 24-hour unless told otherwise. With data-nv-format-toggle a
third wheel reads 24H / AM / PM: swipe it to AM or PM and the hour wheel
becomes 1–12; swipe back to 24H and it returns to 0–23. The value stays
24-hour either way, so a form never has to know which the person used.
Without niveus.js the element is empty. For a no-script fallback, use a
native <input type="time" class="nv-input">.
========================================================================== */
.nv-timepicker {
--_row: 2.25rem;
--_rows: 5;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: var(--nv-space-2);
padding: var(--nv-space-2) var(--nv-space-3);
border-radius: var(--nv-radius-xl);
color: var(--nv-color-text-primary);
}
.nv-timepicker__wheels {
position: relative;
display: flex;
gap: var(--nv-space-1);
}
/* The lens: a bar of tinted glass lying across the centre row. It is raised
like the keys elsewhere in the system — lit on top, shaded below, with a
contact line — not a flat band of colour, so it reads as a thing the wheels
pass under. */
.nv-timepicker__wheels::before {
content: "";
position: absolute;
inset-inline: calc(var(--nv-space-2) * -1);
inset-block-start: 50%;
block-size: var(--_row);
translate: 0 -50%;
background-color: var(--nv-color-selection);
background-image: var(--nv-sheen-raised);
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
pointer-events: none;
}
.nv-timepicker__column {
position: relative;
inline-size: 3.5rem;
block-size: calc(var(--_row) * var(--_rows));
margin: 0;
padding: 0;
list-style: none;
cursor: grab;
user-select: none;
/* The wheel owns vertical gestures, whatever the pointer: niveus.js drags it
itself so a finger, a mouse and a desktop browser's phone mode behave
alike. Without the script the wheel still scrolls natively with a wheel
or keys; set touch-action back to pan-y if you ship it without. */
touch-action: none;
overflow-y: auto;
overscroll-behavior: contain;
scroll-snap-type: y mandatory;
scrollbar-width: none;
mask-image: linear-gradient(
transparent,
currentcolor 28%,
currentcolor 72%,
transparent
);
/* Depth for the drum effect below: rows turn away from the viewer as they
leave the centre. */
perspective: 14rem;
}
.nv-timepicker__column::-webkit-scrollbar {
display: none;
}
/* Spacers, not padding: they let the first and last rows reach the centre.
Firefox leaves the bottom padding of a scroll container out of its
scrollable area, which stranded the last rows (and made a three-row wheel
unscrollable); real boxes always count. */
.nv-timepicker__column::before,
.nv-timepicker__column::after {
content: "";
display: block;
block-size: calc(var(--_row) * (var(--_rows) - 1) / 2);
}
.nv-timepicker__column[data-nv-dragging] {
cursor: grabbing;
}
.nv-timepicker__column:focus-visible {
outline: none;
border-radius: var(--nv-radius-md);
box-shadow: var(--nv-focus-ring);
}
.nv-timepicker__option {
display: grid;
place-items: center;
block-size: var(--_row);
scroll-snap-align: center;
color: var(--nv-color-text-tertiary);
font: var(--nv-font-label);
font-size: var(--nv-font-size-lg);
font-variant-numeric: tabular-nums;
cursor: pointer;
/* --nv-pull is how far the wheel has been dragged past its end. Letting go
removes it, and the spring easing carries the rows back with a small
overshoot — the way a phone's list gives at its limit and settles. */
translate: 0 var(--nv-pull, 0);
transition:
var(--nv-transition-colors),
translate var(--nv-duration-slower) var(--nv-ease-spring);
}
.nv-timepicker__column[data-nv-pulling] .nv-timepicker__option {
transition: var(--nv-transition-colors);
}
/* A drum: each row shrinks, fades and tilts away in proportion to how far it
is from the lens. Driven by the scroll position itself, so it needs no
script and tracks a finger exactly. Where scroll-driven animation is missing
the rows are simply flat, which is still correct. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.nv-timepicker__option {
animation: nv-wheel-drum linear both;
animation-timeline: view(block);
}
}
}
@keyframes nv-wheel-drum {
0% { scale: 0.78; opacity: 0.25; rotate: x 52deg; }
50% { scale: 1; opacity: 1; rotate: x 0deg; }
100% { scale: 0.78; opacity: 0.25; rotate: x -52deg; }
}
.nv-timepicker__option[aria-selected="true"] {
color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-semibold);
}
.nv-timepicker__separator {
display: grid;
place-items: center;
align-self: center;
color: var(--nv-color-text-tertiary);
font: var(--nv-font-label);
font-size: var(--nv-font-size-lg);
}

View file

@ -48,10 +48,14 @@
@import url("components/surface.css") layer(niveus.components);
@import url("components/button.css") layer(niveus.components);
@import url("components/input.css") layer(niveus.components);
@import url("components/slider.css") layer(niveus.components);
@import url("components/card.css") layer(niveus.components);
@import url("components/dialog.css") layer(niveus.components);
@import url("components/navigation.css") layer(niveus.components);
@import url("components/menu.css") layer(niveus.components);
@import url("components/picker.css") layer(niveus.components);
@import url("components/calendar.css") layer(niveus.components);
@import url("components/timepicker.css") layer(niveus.components);
@import url("components/disclosure.css") layer(niveus.components);
@import url("components/scroller.css") layer(niveus.components);
@import url("components/bed.css") layer(niveus.components);

File diff suppressed because it is too large Load diff

View file

@ -21,10 +21,10 @@
:root,
[data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */
--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-background: color-mix(in oklab, #17171b, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-background-subtle: color-mix(in oklab, #1d1d21, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface: color-mix(in oklab, #232327, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-elevated: color-mix(in oklab, #2d2d32, 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));
@ -111,9 +111,9 @@
calc(c * 0.2) h);
/* --- Depth and material -------------------------------------------------- */
--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-ambient: rgb(0 0 0 / 0.6);
--nv-dark-shadow-penumbra: rgb(0 0 0 / 0.62);
--nv-dark-shadow-umbra: rgb(0 0 0 / 0.72);
--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);
@ -144,6 +144,11 @@
--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);
--nv-dark-material-glaze: color-mix(in oklab, rgb(30 30 38 / 1), var(--nv-brand) var(--nv-brand-tint));
--nv-dark-glaze-rim-lit: rgb(255 255 255 / 0.55);
--nv-dark-glaze-rim-far: rgb(255 255 255 / 0.14);
--nv-dark-glaze-specular: rgb(255 255 255 / 0.18);
/* --- Window controls --------------------------------------------------------- */
--nv-dark-window-close: var(--nv-palette-window-close);
--nv-dark-window-minimize: var(--nv-palette-window-minimize);

View file

@ -32,16 +32,18 @@
:root,
[data-nv-brand] {
/* --- Canvas and surfaces -----------------------------------------------
Paper stays paper: the two surfaces that hold content are left untinted so
text keeps its contrast. Everything the surfaces sit *in* picks up a trace
of the brand, which is what stops an accent from looking stuck on. */
--nv-light-background: color-mix(in oklab, #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));
Every neutral carries a trace of the brand, content surfaces included, the
way dark does: that is what stops an accent from looking stuck on, and what
makes the two appearances read as one material. The paper is a soft
grey-stone, not white — a bright field glares, and flattens the bevels that
are meant to shape it. Text keeps its contrast: the primary is neutral. */
--nv-light-background: color-mix(in oklab, #d8d5ce, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-background-subtle: color-mix(in oklab, #dedbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-surface: color-mix(in oklab, #eeede9, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-elevated: color-mix(in oklab, #f2f1ed, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-inset: color-mix(in oklab, #dddbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-surface-hover: color-mix(in oklab, #e6e4de, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-active: color-mix(in oklab, #dad8d1, var(--nv-brand) var(--nv-brand-tint));
--nv-light-scrim: rgb(28 24 18 / 0.34);
/* --- Text ---------------------------------------------------------------
@ -50,7 +52,7 @@
loss of legibility. */
--nv-light-text-primary: var(--nv-palette-neutral-900);
--nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%);
--nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%);
--nv-light-text-tertiary: color-mix(in oklab, color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-palette-neutral-600) 40%), var(--nv-brand) 8%);
--nv-light-text-disabled: var(--nv-palette-neutral-400);
--nv-light-text-inverted: var(--nv-palette-neutral-0);
@ -135,20 +137,20 @@
/* --- 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(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);
--nv-light-shadow-ambient: rgb(32 28 22 / 0.12);
--nv-light-shadow-penumbra: rgb(32 28 22 / 0.15);
--nv-light-shadow-umbra: rgb(32 28 22 / 0.24);
--nv-light-shadow-inset: rgb(32 28 22 / 0.2);
--nv-light-highlight: rgb(255 255 255 / 0.7);
--nv-light-highlight-strong: rgb(255 255 255 / 0.85);
/* 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-lowlight: rgb(32 28 22 / 0.14);
--nv-light-outline: rgb(32 28 22 / 0.08);
--nv-light-sheen-top: rgb(255 255 255 / 0.9);
--nv-light-sheen-bottom: rgb(40 32 20 / 0.07);
--nv-light-sheen-top: rgb(255 255 255 / 0.55);
--nv-light-sheen-bottom: rgb(32 28 22 / 0.11);
--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);
@ -164,9 +166,16 @@
--nv-light-elevation-tint-4: transparent;
--nv-light-elevation-tint-5: transparent;
--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);
--nv-light-material-frosted: color-mix(in oklab, rgb(228 227 222 / 0.6), var(--nv-brand) var(--nv-brand-tint));
--nv-light-material-glass: color-mix(in oklab, rgb(228 227 222 / 0.44), var(--nv-brand) var(--nv-brand-tint));
--nv-light-material-border: rgb(255 255 255 / 0.5);
/* Glaze: the tint it is made of, the rim that catches light on the side
facing the pointer, the rim on the far side, and the specular bloom. */
--nv-light-material-glaze: color-mix(in oklab, rgb(232 231 226 / 1), var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-glaze-rim-lit: rgb(255 255 255 / 0.9);
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.4);
--nv-light-glaze-specular: rgb(255 255 255 / 0.4);
/* --- Window controls --------------------------------------------------------- */
--nv-light-window-close: var(--nv-palette-window-close);

View file

@ -249,6 +249,10 @@
--nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted));
--nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass));
--nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border));
--nv-color-material-glaze: light-dark(var(--nv-light-material-glaze), var(--nv-dark-material-glaze));
--nv-color-glaze-rim-lit: light-dark(var(--nv-light-glaze-rim-lit), var(--nv-dark-glaze-rim-lit));
--nv-color-glaze-rim-far: light-dark(var(--nv-light-glaze-rim-far), var(--nv-dark-glaze-rim-far));
--nv-color-glaze-specular: light-dark(var(--nv-light-glaze-specular), var(--nv-dark-glaze-specular));
/* --- Window controls ------------------------------------------------------- */
--nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close));

View file

@ -15,6 +15,12 @@
consistent between appearances: an ambient contact shadow that keeps the
edge crisp, a soft penumbra for the spread, and an umbra that only appears
once a surface is genuinely far from the canvas.
Two things keep a shadow visible, and both have failed before. The soft
layers use only a mild negative spread: a deep one tucks the blur under the
element and leaves almost nothing outside it. And the canvas has to be
lighter than the shadow colour — black cannot darken black — which is why the
dark canvas is not the darkest value it could be.
========================================================================== */
:root {
@ -23,27 +29,27 @@
--nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient),
0 2px 4px -1px var(--nv-color-shadow-penumbra),
0 6px 14px -8px var(--nv-color-shadow-umbra);
0 6px 14px -5px var(--nv-color-shadow-umbra);
--nv-elevation-2:
0 1px 1px var(--nv-color-shadow-ambient),
0 3px 6px -2px var(--nv-color-shadow-penumbra),
0 10px 20px -10px var(--nv-color-shadow-umbra);
0 10px 20px -7px var(--nv-color-shadow-umbra);
--nv-elevation-3:
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);
0 18px 36px -9px var(--nv-color-shadow-umbra);
--nv-elevation-4:
0 1px 2px var(--nv-color-shadow-ambient),
0 10px 20px -6px var(--nv-color-shadow-penumbra),
0 30px 60px -16px var(--nv-color-shadow-umbra);
0 30px 60px -12px var(--nv-color-shadow-umbra);
--nv-elevation-5:
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);
0 44px 88px -16px var(--nv-color-shadow-umbra);
/* --- Inset ---------------------------------------------------------------
Recessed surfaces: fields, wells, tracks, pressed controls. */

View file

@ -12,6 +12,10 @@
over content: bars, docks, launchers, notifications, menus.
glass more translucent, lightly blurred. Contextual layers where the
content behind should stay legible: overlays, hover cards.
glaze glass with a dial. How frosted it is belongs to whoever is
looking at it, so it is one number (--nv-glass-frost) rather
than a fixed recipe, and it carries a rim that catches the
light and follows the pointer.
Niveus is a solid system with glass used sparingly. Windows, cards, dialogs
and everything that holds content are solid; translucency is reserved for
@ -39,6 +43,21 @@
--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));
/* --- Frost ------------------------------------------------------------------
The one input the glaze material derives from: 0 is clear glass, 1 is
nearly opaque frost. Set it on :root for the whole site, on a subtree to
change just that region, or bind a slider to it with data-nv-var and let
the person looking choose. The glaze recipe reads it where it is used,
so a subtree override always reaches the surfaces inside it. */
--nv-glass-frost: 0.5;
--nv-glaze-blur-max: 56px; /* blur at frost 1 */
--nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */
--nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */
--nv-glaze-rim: 1.5px;
/* The light a pane holds just inside its edge, so it reads as a thick lens
rather than a flat film. */
--nv-glaze-glow: inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular);
/* --- Sheen ------------------------------------------------------------------
raised neutral controls: buttons, the selected tab, a titlebar
filled saturated fills: primary and danger buttons, checked marks