diff --git a/GUIDELINES.md b/GUIDELINES.md
index aaab68d..942b4dc 100644
--- a/GUIDELINES.md
+++ b/GUIDELINES.md
@@ -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.
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
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
```
-7. **Look at it.** Render both appearances in a real browser at a realistic size
- and inspect the result. Passing checks prove the rules; they do not prove it
- looks right or works. Exercise interactive components, not just their markup.
+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.
@@ -651,6 +695,7 @@ A change is done when:
- [ ] 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.
@@ -714,7 +759,7 @@ Use this for review, human or automated. Every line is a yes/no.
- [ ] No `!important`; application CSS is unlayered.
- [ ] `REFERENCE.md` updated; `dist/` rebuilt, not hand-edited.
- [ ] `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.
---
diff --git a/REFERENCE.md b/REFERENCE.md
index 28ae7b4..89da370 100644
--- a/REFERENCE.md
+++ b/REFERENCE.md
@@ -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-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` | `` | Set during a transition by `nv.transition`. |
diff --git a/dist/niveus.css b/dist/niveus.css
index e18d6e0..b85cacc 100644
--- a/dist/niveus.css
+++ b/dist/niveus.css
@@ -527,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 {
@@ -535,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. */
@@ -1093,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));
@@ -1183,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);
@@ -1374,6 +1380,13 @@
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. */
@@ -4119,8 +4132,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;
@@ -4140,6 +4156,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);
diff --git a/index.html b/index.html
index 757cec9..db92b1d 100644
--- a/index.html
+++ b/index.html
@@ -768,7 +768,7 @@
Weekly digest