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

@ -134,6 +134,13 @@ 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. */

View file

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

View file

@ -96,17 +96,19 @@
/** "light" | "dark" | "system" — system means: follow the environment. */
function setTheme(theme) {
var done = transition(function () {
if (theme === "system") {
root.removeAttribute("data-theme");
} else {
root.setAttribute("data-theme", theme);
}
syncThemeControls(theme);
}, { kind: "theme" });
// 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") {
root.removeAttribute("data-theme");
} else {
root.setAttribute("data-theme", theme);
}
syncThemeControls(theme);
root.dispatchEvent(new CustomEvent("nv:theme", { detail: { theme: theme } }));
return done;
return Promise.resolve();
}
function syncThemeControls(theme) {
@ -798,18 +800,87 @@
var settle = 0;
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;
list.style.setProperty("--nv-pull", value.toFixed(1) + "px");
list.dataset.nvPulling = "";
global.clearTimeout(settle);
settle = global.setTimeout(function () {
pull = 0;
delete list.dataset.nvPulling;
list.style.removeProperty("--nv-pull");
}, 90);
if (!hold) settle = global.setTimeout(clearPull, 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) {
if (prefersReducedMotion()) return;
var atTop = list.scrollTop <= 0;
@ -820,6 +891,7 @@
var delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? list.clientHeight : 1);
var pushing = (atTop && delta < 0) || (atBottom && delta > 0);
if (!pushing && !pull) return;
if (drag && drag.moved) return;
event.preventDefault();
var next = pull - delta * 0.12 * (1 - Math.abs(pull) / limit);
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) {
scope = scope || document;
@ -1107,6 +1204,7 @@
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
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-timepicker]").forEach(initTimePicker);
scope.querySelectorAll(".nv-menu--listbox").forEach(initListbox);

View file

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

View file

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