mirror of
https://github.com/Nexoniarz/Niveus.git
synced 2026-11-03 04:34:26 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a04d110ed |
||
|
|
2e956cec42 | ||
|
|
65d81481bd | ||
|
|
06fca1c385 | ||
|
|
5d435c4f49 | ||
|
|
369d1cd8f7 |
24 changed files with 3485 additions and 173 deletions
783
GUIDELINES.md
Normal file
783
GUIDELINES.md
Normal 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.*
|
||||
61
README.md
61
README.md
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
67
REFERENCE.md
67
REFERENCE.md
|
|
@ -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
789
dist/niveus.css
vendored
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
163
index.html
163
index.html
|
|
@ -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">‹</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">›</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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
146
niveus/components/calendar.css
Normal file
146
niveus/components/calendar.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
69
niveus/components/picker.css
Normal file
69
niveus/components/picker.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
114
niveus/components/slider.css
Normal file
114
niveus/components/slider.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
171
niveus/components/timepicker.css
Normal file
171
niveus/components/timepicker.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
980
niveus/niveus.js
980
niveus/niveus.js
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue