Fix Firefox: wheels, dark shadows, theme glass, smooth jumps, tri-state

Found by driving Firefox 158 with real pointer, wheel and key input.

- Time picker wheels used padding to reach the last rows. Firefox leaves
  bottom padding out of the scrollable area, so the last rows were
  unreachable and the 24H/AM/PM wheel could not scroll at all. Spacer
  pseudo-elements replace the padding.
- Wheels can now be dragged with a mouse or pen, with inertia, a rubber-band
  at the ends and a spring back. Touch is left to the platform.
- Theme changes no longer use a page-wide view transition. The snapshot
  could not blur what sits behind frosted glass, so every frosted surface
  went clear for the length of the cross-fade.
- Dark shadows were invisible: black on a near-black canvas, with a deep
  negative spread tucking the blur under the element. The dark canvas is
  lifted, the shadows are stronger and the spreads milder.
- Same-page links now travel (scroll-behavior: smooth, motion permitting).
- data-nv-tristate: a checkbox that cycles unchecked, checked, partial; the
  browser can display a partial state but a click can never choose it.
- Button groups show their pressed segment.

GUIDELINES.md gains the cross-engine rules these defects taught, and makes
Firefox the first browser to verify in.

check.py, build.py and build.py --check pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-28 09:50:40 +01:00
commit 65d81481bd
9 changed files with 253 additions and 48 deletions

View file

@ -533,6 +533,46 @@ tune. This is a first-class goal, bounded by sections 3.1 and 11.
5. **One listener, one write per frame** for pointer-driven effects. 5. **One listener, one write per frame** for pointer-driven effects.
6. **ES5-compatible style** in `niveus.js`, to match the file. 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 an appearance 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. Let surfaces transition their own colours. Selection
markers still use a short view transition; keep it brief, and prefer a
per-element animation for anything inside frosted chrome.
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. **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.
Treat those as limits of the rig, not of the system, and confirm them on a
real display.
### 14.3 Documentation ### 14.3 Documentation
Every new class, token, attribute, function or event is added to `REFERENCE.md` Every new class, token, attribute, function or event is added to `REFERENCE.md`
@ -604,9 +644,13 @@ Niveus code, follow it literally.
python3 tools/build.py --check # dist/ matches sources python3 tools/build.py --check # dist/ matches sources
``` ```
7. **Look at it.** Render both appearances in a real browser at a realistic size 7. **Look at it, in Firefox first.** Chromium implements nearly everything
and inspect the result. Passing checks prove the rules; they do not prove it Niveus uses, so passing there proves little; Firefox is the stricter test and
looks right or works. Exercise interactive components, not just their markup. 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 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. failed. A test that failed because of the test harness is still reported.
@ -651,6 +695,7 @@ A change is done when:
- [ ] It is documented in `REFERENCE.md`; `README.md` and this document are - [ ] It is documented in `REFERENCE.md`; `README.md` and this document are
updated if a principle or the component list changed. updated if a principle or the component list changed.
- [ ] It renders correctly in **light and dark**, with and without `niveus.js`. - [ ] 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. - [ ] Every interaction state exists and is reachable by keyboard.
- [ ] Translucent surfaces have a solid fallback. - [ ] Translucent surfaces have a solid fallback.
- [ ] It respects reduced motion and reduced transparency. - [ ] It respects reduced motion and reduced transparency.
@ -714,7 +759,7 @@ Use this for review, human or automated. Every line is a yes/no.
- [ ] No `!important`; application CSS is unlayered. - [ ] No `!important`; application CSS is unlayered.
- [ ] `REFERENCE.md` updated; `dist/` rebuilt, not hand-edited. - [ ] `REFERENCE.md` updated; `dist/` rebuilt, not hand-edited.
- [ ] `build.py`, `check.py`, `build.py --check` pass. - [ ] `build.py`, `check.py`, `build.py --check` pass.
- [ ] Seen in a browser; interactive parts exercised. - [ ] Seen and exercised in Firefox with real input, then Chromium.
--- ---

View file

@ -527,6 +527,7 @@ everything still renders — state changes simply arrive instead of travelling.
| `data-nv-calendar-step` | button in a calendar | `-1` or `1`: months to move. | | `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-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-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-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-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-direction` | `<html>` | Set during a transition by `nv.transition`. |

57
dist/niveus.css vendored
View file

@ -527,6 +527,12 @@
consistent between appearances: an ambient contact shadow that keeps the consistent between appearances: an ambient contact shadow that keeps the
edge crisp, a soft penumbra for the spread, and an umbra that only appears edge crisp, a soft penumbra for the spread, and an umbra that only appears
once a surface is genuinely far from the canvas. 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 { :root {
@ -535,27 +541,27 @@
--nv-elevation-1: --nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient), 0 1px 1px var(--nv-color-shadow-ambient),
0 2px 4px -1px var(--nv-color-shadow-penumbra), 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: --nv-elevation-2:
0 1px 1px var(--nv-color-shadow-ambient), 0 1px 1px var(--nv-color-shadow-ambient),
0 3px 6px -2px var(--nv-color-shadow-penumbra), 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: --nv-elevation-3:
0 1px 2px var(--nv-color-shadow-ambient), 0 1px 2px var(--nv-color-shadow-ambient),
0 6px 12px -4px var(--nv-color-shadow-penumbra), 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: --nv-elevation-4:
0 1px 2px var(--nv-color-shadow-ambient), 0 1px 2px var(--nv-color-shadow-ambient),
0 10px 20px -6px var(--nv-color-shadow-penumbra), 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: --nv-elevation-5:
0 2px 3px var(--nv-color-shadow-ambient), 0 2px 3px var(--nv-color-shadow-ambient),
0 16px 32px -8px var(--nv-color-shadow-penumbra), 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 --------------------------------------------------------------- /* --- Inset ---------------------------------------------------------------
Recessed surfaces: fields, wells, tracks, pressed controls. */ Recessed surfaces: fields, wells, tracks, pressed controls. */
@ -1093,10 +1099,10 @@
:root, :root,
[data-nv-brand] { [data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */ /* --- Canvas and surfaces ----------------------------------------------- */
--nv-dark-background: color-mix(in oklab, #0f0f12, 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, #18181b, 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, #1f1f22, 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, #2a2a2e, 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-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-hover: color-mix(in oklab, #313136, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-active: color-mix(in oklab, #39393f, var(--nv-brand) var(--nv-brand-tint)); --nv-dark-surface-active: color-mix(in oklab, #39393f, var(--nv-brand) var(--nv-brand-tint));
@ -1183,9 +1189,9 @@
calc(c * 0.2) h); calc(c * 0.2) h);
/* --- Depth and material -------------------------------------------------- */ /* --- Depth and material -------------------------------------------------- */
--nv-dark-shadow-ambient: rgb(0 0 0 / 0.5); --nv-dark-shadow-ambient: rgb(0 0 0 / 0.6);
--nv-dark-shadow-penumbra: rgb(0 0 0 / 0.5); --nv-dark-shadow-penumbra: rgb(0 0 0 / 0.62);
--nv-dark-shadow-umbra: rgb(0 0 0 / 0.55); --nv-dark-shadow-umbra: rgb(0 0 0 / 0.72);
--nv-dark-shadow-inset: rgb(0 0 0 / 0.65); --nv-dark-shadow-inset: rgb(0 0 0 / 0.65);
--nv-dark-highlight: rgb(255 255 255 / 0.15); --nv-dark-highlight: rgb(255 255 255 / 0.15);
--nv-dark-highlight-strong: rgb(255 255 255 / 0.24); --nv-dark-highlight-strong: rgb(255 255 255 / 0.24);
@ -1374,6 +1380,13 @@
border-radius: var(--nv-radius-xs); 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 /* 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 text caret, a date field's glyphs — takes the brand, so no native colour
survives next to the system's own. */ survives next to the system's own. */
@ -4119,8 +4132,11 @@
inline-size: 3.5rem; inline-size: 3.5rem;
block-size: calc(var(--_row) * var(--_rows)); block-size: calc(var(--_row) * var(--_rows));
margin: 0; margin: 0;
padding: calc(var(--_row) * (var(--_rows) - 1) / 2) 0; padding: 0;
list-style: none; list-style: none;
cursor: grab;
user-select: none;
touch-action: pan-y;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
scroll-snap-type: y mandatory; scroll-snap-type: y mandatory;
@ -4140,6 +4156,21 @@
display: none; 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 { .nv-timepicker__column:focus-visible {
outline: none; outline: none;
border-radius: var(--nv-radius-md); border-radius: var(--nv-radius-md);

View file

@ -768,7 +768,7 @@
Weekly digest Weekly digest
</label> </label>
<label class="nv-choice"> <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 Partial selection
</label> </label>
<label class="nv-choice"> <label class="nv-choice">
@ -1527,7 +1527,6 @@
addEventListener("scroll", onScroll, { passive: true }); addEventListener("scroll", onScroll, { passive: true });
onScroll(); onScroll();
document.getElementById("f-indeterminate").indeterminate = true;
/* Build the semantic color list from the tokens themselves, so the page /* Build the semantic color list from the tokens themselves, so the page
cannot show a role the system does not actually define. */ cannot show a role the system does not actually define. */

View file

@ -134,6 +134,13 @@ textarea {
border-radius: var(--nv-radius-xs); 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 /* 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 text caret, a date field's glyphs — takes the brand, so no native colour
survives next to the system's own. */ survives next to the system's own. */

View file

@ -65,8 +65,11 @@
inline-size: 3.5rem; inline-size: 3.5rem;
block-size: calc(var(--_row) * var(--_rows)); block-size: calc(var(--_row) * var(--_rows));
margin: 0; margin: 0;
padding: calc(var(--_row) * (var(--_rows) - 1) / 2) 0; padding: 0;
list-style: none; list-style: none;
cursor: grab;
user-select: none;
touch-action: pan-y;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
scroll-snap-type: y mandatory; scroll-snap-type: y mandatory;
@ -86,6 +89,21 @@
display: none; 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 { .nv-timepicker__column:focus-visible {
outline: none; outline: none;
border-radius: var(--nv-radius-md); border-radius: var(--nv-radius-md);

View file

@ -96,17 +96,19 @@
/** "light" | "dark" | "system" — system means: follow the environment. */ /** "light" | "dark" | "system" — system means: follow the environment. */
function setTheme(theme) { function setTheme(theme) {
var done = transition(function () { // Deliberately not a view transition. A page-wide snapshot cannot blur what
// sits behind frosted glass, so for the length of the cross-fade every
// frosted bar, menu and glaze pane went clear and showed the page sharply
// through itself. Surfaces transition their own colours instead (see
// --nv-transition-surface), and the glass stays live throughout.
if (theme === "system") { if (theme === "system") {
root.removeAttribute("data-theme"); root.removeAttribute("data-theme");
} else { } else {
root.setAttribute("data-theme", theme); root.setAttribute("data-theme", theme);
} }
syncThemeControls(theme); syncThemeControls(theme);
}, { kind: "theme" });
root.dispatchEvent(new CustomEvent("nv:theme", { detail: { theme: theme } })); root.dispatchEvent(new CustomEvent("nv:theme", { detail: { theme: theme } }));
return done; return Promise.resolve();
} }
function syncThemeControls(theme) { function syncThemeControls(theme) {
@ -798,18 +800,87 @@
var settle = 0; var settle = 0;
var limit = 36; var limit = 36;
function setPull(value) { function clearPull() {
pull = 0;
delete list.dataset.nvPulling;
list.style.removeProperty("--nv-pull");
}
// `hold` keeps the pull until clearPull() — a finger or button is still
// down — instead of releasing it after the wheel goes quiet.
function setPull(value, hold) {
pull = value; pull = value;
list.style.setProperty("--nv-pull", value.toFixed(1) + "px"); list.style.setProperty("--nv-pull", value.toFixed(1) + "px");
list.dataset.nvPulling = ""; list.dataset.nvPulling = "";
global.clearTimeout(settle); global.clearTimeout(settle);
settle = global.setTimeout(function () { if (!hold) settle = global.setTimeout(clearPull, 90);
pull = 0;
delete list.dataset.nvPulling;
list.style.removeProperty("--nv-pull");
}, 90);
} }
// Dragging with a mouse or pen. Touch scrolls natively; a desktop
// pointer has no such thing, so without this a wheel cannot be grabbed.
// Snapping is off while the pointer is down so the list follows it, the
// end gives with resistance, and on release the list coasts to the row
// the motion was heading for.
var drag = null;
list.addEventListener("pointerdown", function (event) {
if (event.pointerType === "touch" || event.button !== 0) return;
drag = {
id: event.pointerId, y: event.clientY, top: list.scrollTop,
lastY: event.clientY, lastT: event.timeStamp, velocity: 0, moved: false,
};
});
list.addEventListener("pointermove", function (event) {
if (!drag || event.pointerId !== drag.id) return;
var dy = event.clientY - drag.y;
if (!drag.moved) {
if (Math.abs(dy) < 4) return;
// Only now is it a drag and not a click, so only now is the pointer
// captured; capturing earlier would redirect the click to the list.
drag.moved = true;
list.setPointerCapture(event.pointerId);
list.style.scrollSnapType = "none";
list.dataset.nvDragging = "";
}
var elapsed = event.timeStamp - drag.lastT;
if (elapsed > 0) drag.velocity = (event.clientY - drag.lastY) / elapsed;
drag.lastY = event.clientY;
drag.lastT = event.timeStamp;
var max = list.scrollHeight - list.clientHeight;
var target = drag.top - dy;
if (target < 0 || target > max) {
list.scrollTop = target < 0 ? 0 : max;
var over = target < 0 ? -target : target - max;
if (!prefersReducedMotion()) {
setPull((target < 0 ? 1 : -1) * Math.min(limit, over * 0.35), true);
}
} else {
list.scrollTop = target;
if (pull) clearPull();
}
});
function endDrag(event) {
if (!drag || event.pointerId !== drag.id) return;
var moved = drag.moved;
var velocity = drag.velocity;
drag = null;
if (!moved) return;
delete list.dataset.nvDragging;
list.style.scrollSnapType = "";
clearPull();
// Coast: carry the release speed a little way on, then settle on a row.
var row = list.firstElementChild.offsetHeight;
var ahead = list.scrollTop - velocity * 160;
var index = Math.max(0, Math.min(list.children.length - 1, Math.round(ahead / row)));
scrollTo(list, index, true);
}
list.addEventListener("pointerup", endDrag);
list.addEventListener("pointercancel", endDrag);
list.addEventListener("wheel", function (event) { list.addEventListener("wheel", function (event) {
if (prefersReducedMotion()) return; if (prefersReducedMotion()) return;
var atTop = list.scrollTop <= 0; var atTop = list.scrollTop <= 0;
@ -820,6 +891,7 @@
var delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? list.clientHeight : 1); var delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? list.clientHeight : 1);
var pushing = (atTop && delta < 0) || (atBottom && delta > 0); var pushing = (atTop && delta < 0) || (atBottom && delta > 0);
if (!pushing && !pull) return; if (!pushing && !pull) return;
if (drag && drag.moved) return;
event.preventDefault(); event.preventDefault();
var next = pull - delta * 0.12 * (1 - Math.abs(pull) / limit); var next = pull - delta * 0.12 * (1 - Math.abs(pull) / limit);
setPull(Math.max(-limit, Math.min(limit, next))); setPull(Math.max(-limit, Math.min(limit, next)));
@ -1089,6 +1161,31 @@
}); });
} }
/**
* A checkbox with data-nv-tristate cycles unchecked → checked → partial →
* unchecked. The browser only offers the first two by clicking, and the third
* only from script, so a partial state could be shown but never chosen.
* data-nv-tristate="partial" starts it partial.
*/
function initTristate(box) {
if (box.dataset.nvTristateWired) return;
box.dataset.nvTristateWired = "";
var states = ["off", "on", "partial"];
var state = box.dataset.nvTristate === "partial" ? 2 : box.checked ? 1 : 0;
function apply() {
box.checked = state === 1;
box.indeterminate = state === 2;
box.dataset.nvState = states[state];
}
box.addEventListener("click", function () {
state = (state + 1) % 3;
apply();
});
apply();
}
function wire(scope) { function wire(scope) {
scope = scope || document; scope = scope || document;
@ -1107,6 +1204,7 @@
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs); scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller); scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
scope.querySelectorAll(".nv-slider").forEach(initSlider); scope.querySelectorAll(".nv-slider").forEach(initSlider);
scope.querySelectorAll("input[data-nv-tristate]").forEach(initTristate);
scope.querySelectorAll("[data-nv-calendar]").forEach(initCalendar); scope.querySelectorAll("[data-nv-calendar]").forEach(initCalendar);
scope.querySelectorAll("[data-nv-timepicker]").forEach(initTimePicker); scope.querySelectorAll("[data-nv-timepicker]").forEach(initTimePicker);
scope.querySelectorAll(".nv-menu--listbox").forEach(initListbox); scope.querySelectorAll(".nv-menu--listbox").forEach(initListbox);

View file

@ -21,10 +21,10 @@
:root, :root,
[data-nv-brand] { [data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- */ /* --- Canvas and surfaces ----------------------------------------------- */
--nv-dark-background: color-mix(in oklab, #0f0f12, 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, #18181b, 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, #1f1f22, 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, #2a2a2e, 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-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-hover: color-mix(in oklab, #313136, var(--nv-brand) var(--nv-brand-tint));
--nv-dark-surface-active: color-mix(in oklab, #39393f, var(--nv-brand) var(--nv-brand-tint)); --nv-dark-surface-active: color-mix(in oklab, #39393f, var(--nv-brand) var(--nv-brand-tint));
@ -111,9 +111,9 @@
calc(c * 0.2) h); calc(c * 0.2) h);
/* --- Depth and material -------------------------------------------------- */ /* --- Depth and material -------------------------------------------------- */
--nv-dark-shadow-ambient: rgb(0 0 0 / 0.5); --nv-dark-shadow-ambient: rgb(0 0 0 / 0.6);
--nv-dark-shadow-penumbra: rgb(0 0 0 / 0.5); --nv-dark-shadow-penumbra: rgb(0 0 0 / 0.62);
--nv-dark-shadow-umbra: rgb(0 0 0 / 0.55); --nv-dark-shadow-umbra: rgb(0 0 0 / 0.72);
--nv-dark-shadow-inset: rgb(0 0 0 / 0.65); --nv-dark-shadow-inset: rgb(0 0 0 / 0.65);
--nv-dark-highlight: rgb(255 255 255 / 0.15); --nv-dark-highlight: rgb(255 255 255 / 0.15);
--nv-dark-highlight-strong: rgb(255 255 255 / 0.24); --nv-dark-highlight-strong: rgb(255 255 255 / 0.24);

View file

@ -15,6 +15,12 @@
consistent between appearances: an ambient contact shadow that keeps the consistent between appearances: an ambient contact shadow that keeps the
edge crisp, a soft penumbra for the spread, and an umbra that only appears edge crisp, a soft penumbra for the spread, and an umbra that only appears
once a surface is genuinely far from the canvas. 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 { :root {
@ -23,27 +29,27 @@
--nv-elevation-1: --nv-elevation-1:
0 1px 1px var(--nv-color-shadow-ambient), 0 1px 1px var(--nv-color-shadow-ambient),
0 2px 4px -1px var(--nv-color-shadow-penumbra), 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: --nv-elevation-2:
0 1px 1px var(--nv-color-shadow-ambient), 0 1px 1px var(--nv-color-shadow-ambient),
0 3px 6px -2px var(--nv-color-shadow-penumbra), 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: --nv-elevation-3:
0 1px 2px var(--nv-color-shadow-ambient), 0 1px 2px var(--nv-color-shadow-ambient),
0 6px 12px -4px var(--nv-color-shadow-penumbra), 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: --nv-elevation-4:
0 1px 2px var(--nv-color-shadow-ambient), 0 1px 2px var(--nv-color-shadow-ambient),
0 10px 20px -6px var(--nv-color-shadow-penumbra), 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: --nv-elevation-5:
0 2px 3px var(--nv-color-shadow-ambient), 0 2px 3px var(--nv-color-shadow-ambient),
0 16px 32px -8px var(--nv-color-shadow-penumbra), 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 --------------------------------------------------------------- /* --- Inset ---------------------------------------------------------------
Recessed surfaces: fields, wells, tracks, pressed controls. */ Recessed surfaces: fields, wells, tracks, pressed controls. */