Rework light theme, add format wheel and motion, fix Firefox paths

Theme: light is a dimmer brand-tinted stone with deeper shade, so it reads
as the same material as dark; every neutral now carries --nv-brand in both
appearances. Native controls (accent-color, caret-color) take the brand.

Time picker: 24-hour by default; data-nv-format-toggle adds a third wheel
of 24H / AM / PM. Swiping it rebuilds the hour wheel for the new format.
The selection lens is a raised glass bar, not a flat band.

Motion: drum effect on the wheels as a scroll-driven animation, with a
script fallback for engines without it (Firefox); rubber-band at both ends
of a wheel that springs back; calendar months slide locally instead of via
a page-wide transition; a chosen day springs into place.

Firefox: wheel notches arrive in lines, not pixels, which made the
rubber-band invisible; deltaMode is now normalised. The selected-day pop
no longer plays on page load.

Also: hover only inside @media (hover: hover) on the slider, the Glaze
inner glow is a token, and the focus note in GUIDELINES.md is corrected
(popovers do not trap focus). SPDX identifier added to niveus.js.

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:34:08 +01:00
commit 5d435c4f49
12 changed files with 410 additions and 157 deletions

View file

@ -146,6 +146,16 @@ freely.
### 4.1 Rules ### 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, 1. **Solid by default.** Anything that *holds content* — windows, cards, dialogs,
tables, forms — is solid. Translucency is reserved for what *floats over* tables, forms — is solid. Translucency is reserved for what *floats over*
content. content.
@ -265,7 +275,17 @@ place to define a colour.
attribute change (see 11). attribute change (see 11).
5. **Semantic colours mean one thing.** `success`, `warning`, `danger` carry 5. **Semantic colours mean one thing.** `success`, `warning`, `danger` carry
meaning; they are not accents. Use `accent` for emphasis and brand. meaning; they are not accents. Use `accent` for emphasis and brand.
6. **Dark mode is designed, not generated.** When a new surface is added, look at 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. it in dark. If it only works on white, it is not finished.
--- ---
@ -320,7 +340,18 @@ have watched it reach.**
away from the control that opened them. away from the control that opened them.
7. **No JavaScript, no breakage.** Continuity enhances; it MUST NOT be required 7. **No JavaScript, no breakage.** Continuity enhances; it MUST NOT be required
for function. for function.
8. **Restraint wins ties.** When continuity and restraint conflict, animate less. 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: appearance, route, a selection travelling between regions.
11. **Restraint wins ties.** When continuity and restraint conflict, animate less.
--- ---
@ -368,8 +399,10 @@ Niveus provides the base; the author is responsible for the rest.
visible focus order that follows reading order. visible focus order that follows reading order.
4. **Labels.** Every field has a programmatic label. Errors pair 4. **Labels.** Every field has a programmatic label. Errors pair
`aria-invalid="true"` with `aria-describedby` pointing at the message. `aria-invalid="true"` with `aria-describedby` pointing at the message.
5. **Dialogs and popovers** trap or restore focus (native `<dialog>` and 5. **Focus management.** A modal `<dialog>` opened with `showModal()` traps focus
`popover` do; custom ones MUST). 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 — 6. **Live regions** (`aria-live`) for content that changes without focus —
a calendar's month title is one. a calendar's month title is one.
7. **Test the extremes.** Verify text on translucent materials at the 7. **Test the extremes.** Verify text on translucent materials at the
@ -396,8 +429,8 @@ The complete list and API live in `REFERENCE.md`. These are the rules for
month so it never changes height. The grid and states are plain markup; month so it never changes height. The grid and states are plain markup;
`niveus.js` only builds days, steps months and provides keyboard movement. `niveus.js` only builds days, steps months and provides keyboard movement.
- **Time picker** (`.nv-timepicker`): scroll-snap wheels. **24-hour is the - **Time picker** (`.nv-timepicker`): scroll-snap wheels. **24-hour is the
default**; `data-nv-format-toggle` adds a 24H / AM/PM switch beneath, and default**; `data-nv-format-toggle` adds a third wheel of 24H / AM / PM: swiping it
AM/PM adds a third wheel. The value is always 24-hour (`HH:MM`), so a form 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 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 `<input type="time" class="nv-input">`; the wheels are an enhancement, not
the only path. the only path.

View file

@ -494,6 +494,7 @@ to the state change simply happening rather than travelling:
| `interpolate-size` | disclosure height | content appears at full height (`.nv-collapse` still animates) | | `interpolate-size` | disclosure height | content appears at full height (`.nv-collapse` still animates) |
| Anchor positioning | menus tied to their trigger | the menu centres itself | | Anchor positioning | menus tied to their trigger | the menu centres itself |
| `popover` | menus, light dismiss | needs the application's own toggle | | `popover` | menus, light dismiss | needs the application's own toggle |
| Scroll-driven animation | the time picker's drum effect | `niveus.js` applies the same curve from the scroll position (Firefox) |
--- ---

View file

@ -76,9 +76,8 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
| `.nv-calendar__grid` | day grid | `role="grid"`; built by `niveus.js` or written by hand. | | `.nv-calendar__grid` | day grid | `role="grid"`; built by `niveus.js` or written by hand. |
| `.nv-calendar__weekday` | weekday column header | | | `.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-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 24H / AM/PM switch under the wheels). 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` | 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__wheels` | wheel row, picker body | Holds the columns and the selection lens. |
| `.nv-timepicker__format` | 24H / AM/PM switch, time format toggle | Button group under the wheels; added by `data-nv-format-toggle`. |
| `.nv-timepicker__column` | wheel, picker column | One scrolling list (`role="listbox"`). | | `.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__option` | wheel row | `aria-selected="true"` is the row in the lens. |
| `.nv-timepicker__separator` | colon between wheels | | | `.nv-timepicker__separator` | colon between wheels | |
@ -472,7 +471,7 @@ level. Components never opt in individually.
subtree) · `--nv-glaze-blur-max` · `--nv-glaze-tint-min` · subtree) · `--nv-glaze-blur-max` · `--nv-glaze-tint-min` ·
`--nv-glaze-tint-max` · `--nv-glaze-rim` · `--nv-light-x` / `--nv-light-y` / `--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-light-angle` (set by `niveus.js` on each glaze surface) ·
`--nv-slider-pos` (set by `niveus.js`) · `--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` · `--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-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` · `--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` · `--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` ·
@ -526,7 +525,7 @@ everything still renders — state changes simply arrive instead of travelling.
| `data-nv-var-target` | `.nv-slider` | Selector of the element that receives the property. Default: `<html>`. | | `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` | `.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-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 switch). | | `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-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. |

162
dist/niveus.css vendored
View file

@ -811,6 +811,9 @@
--nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */ --nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */
--nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */ --nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */
--nv-glaze-rim: 1.5px; --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 ------------------------------------------------------------------ /* --- Sheen ------------------------------------------------------------------
raised neutral controls: buttons, the selected tab, a titlebar raised neutral controls: buttons, the selected tab, a titlebar
@ -907,16 +910,18 @@
:root, :root,
[data-nv-brand] { [data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- /* --- Canvas and surfaces -----------------------------------------------
Paper stays paper: the two surfaces that hold content are left untinted so Every neutral carries a trace of the brand, content surfaces included, the
text keeps its contrast. Everything the surfaces sit *in* picks up a trace way dark does: that is what stops an accent from looking stuck on, and what
of the brand, which is what stops an accent from looking stuck on. */ makes the two appearances read as one material. The paper is a soft
--nv-light-background: color-mix(in oklab, #e8e5de, var(--nv-brand) var(--nv-brand-tint)); grey-stone, not white — a bright field glares, and flattens the bevels that
--nv-light-background-subtle: color-mix(in oklab, #eeece6, var(--nv-brand) var(--nv-brand-tint)); are meant to shape it. Text keeps its contrast: the primary is neutral. */
--nv-light-surface: #fbfbfa; --nv-light-background: color-mix(in oklab, #d8d5ce, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-surface-elevated: #f8f8f6; --nv-light-background-subtle: color-mix(in oklab, #dedbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-surface-inset: color-mix(in oklab, #eeede8, var(--nv-brand) var(--nv-brand-tint)); --nv-light-surface: color-mix(in oklab, #eeede9, 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-elevated: color-mix(in oklab, #f2f1ed, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-active: color-mix(in oklab, #e6e5e0, 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); --nv-light-scrim: rgb(28 24 18 / 0.34);
/* --- Text --------------------------------------------------------------- /* --- Text ---------------------------------------------------------------
@ -925,7 +930,7 @@
loss of legibility. */ loss of legibility. */
--nv-light-text-primary: var(--nv-palette-neutral-900); --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-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-disabled: var(--nv-palette-neutral-400);
--nv-light-text-inverted: var(--nv-palette-neutral-0); --nv-light-text-inverted: var(--nv-palette-neutral-0);
@ -1010,20 +1015,20 @@
/* --- Depth and material ------------------------------------------------- /* --- Depth and material -------------------------------------------------
Three shadow colors build every elevation level: an ambient contact Three shadow colors build every elevation level: an ambient contact
shadow, a soft penumbra, and a deeper umbra for high elevations. */ shadow, a soft penumbra, and a deeper umbra for high elevations. */
--nv-light-shadow-ambient: rgb(40 32 20 / 0.08); --nv-light-shadow-ambient: rgb(32 28 22 / 0.12);
--nv-light-shadow-penumbra: rgb(40 32 20 / 0.1); --nv-light-shadow-penumbra: rgb(32 28 22 / 0.15);
--nv-light-shadow-umbra: rgb(40 32 20 / 0.16); --nv-light-shadow-umbra: rgb(32 28 22 / 0.24);
--nv-light-shadow-inset: rgb(40 32 20 / 0.14); --nv-light-shadow-inset: rgb(32 28 22 / 0.2);
--nv-light-highlight: rgb(255 255 255 / 0.9); --nv-light-highlight: rgb(255 255 255 / 0.7);
--nv-light-highlight-strong: rgb(255 255 255 / 1); --nv-light-highlight-strong: rgb(255 255 255 / 0.85);
/* Paper already has a border doing the contact work, so the outline only /* Paper already has a border doing the contact work, so the outline only
softens it; the bottom shade is faint for the same reason. */ softens it; the bottom shade is faint for the same reason. */
--nv-light-lowlight: rgb(40 32 20 / 0.09); --nv-light-lowlight: rgb(32 28 22 / 0.14);
--nv-light-outline: rgb(40 32 20 / 0.05); --nv-light-outline: rgb(32 28 22 / 0.08);
--nv-light-sheen-top: rgb(255 255 255 / 0.9); --nv-light-sheen-top: rgb(255 255 255 / 0.55);
--nv-light-sheen-bottom: rgb(40 32 20 / 0.07); --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-top: rgb(255 255 255 / 0.32);
--nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16); --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); --nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3);
@ -1039,16 +1044,16 @@
--nv-light-elevation-tint-4: transparent; --nv-light-elevation-tint-4: transparent;
--nv-light-elevation-tint-5: transparent; --nv-light-elevation-tint-5: transparent;
--nv-light-material-frosted: rgb(252 252 250 / 0.56); --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: rgb(252 252 250 / 0.4); --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.72); --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 /* 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. */ facing the pointer, the rim on the far side, and the specular bloom. */
--nv-light-material-glaze: rgb(255 255 255 / 1); --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 / 1); --nv-light-glaze-rim-lit: rgb(255 255 255 / 0.9);
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.5); --nv-light-glaze-rim-far: rgb(255 255 255 / 0.4);
--nv-light-glaze-specular: rgb(255 255 255 / 0.6); --nv-light-glaze-specular: rgb(255 255 255 / 0.4);
/* --- Window controls --------------------------------------------------------- */ /* --- Window controls --------------------------------------------------------- */
--nv-light-window-close: var(--nv-palette-window-close); --nv-light-window-close: var(--nv-palette-window-close);
@ -1369,6 +1374,14 @@
border-radius: var(--nv-radius-xs); border-radius: var(--nv-radius-xs);
} }
/* 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 { ::selection {
background-color: var(--nv-color-selection); background-color: var(--nv-color-selection);
color: var(--nv-color-text-primary); color: var(--nv-color-text-primary);
@ -1840,7 +1853,7 @@
border: var(--nv-border-width) solid transparent; border: var(--nv-border-width) solid transparent;
box-shadow: box-shadow:
var(--nv-highlight-top-strong), var(--nv-highlight-top-strong),
inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular), var(--nv-glaze-glow),
var(--nv-outline), var(--nv-outline),
var(--nv-elevation-4); var(--nv-elevation-4);
backdrop-filter: backdrop-filter:
@ -2772,7 +2785,7 @@
box-shadow: var(--nv-outline), var(--nv-elevation-2); box-shadow: var(--nv-outline), var(--nv-elevation-2);
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
transition: scale var(--nv-duration-fast) var(--nv-ease-standard); transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
} }
.nv-slider::-moz-range-thumb { .nv-slider::-moz-range-thumb {
@ -2784,12 +2797,17 @@
border: 0; border: 0;
border-radius: var(--nv-radius-full); border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), var(--nv-elevation-2); box-shadow: var(--nv-outline), var(--nv-elevation-2);
transition: scale var(--nv-duration-fast) var(--nv-ease-standard); 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::-webkit-slider-thumb { scale: 1.06; }
.nv-slider:active::-webkit-slider-thumb { scale: 0.96; }
.nv-slider:hover::-moz-range-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:active::-moz-range-thumb { scale: 0.96; }
.nv-slider:focus-visible::-webkit-slider-thumb { .nv-slider:focus-visible::-webkit-slider-thumb {
@ -3971,8 +3989,12 @@
} }
@media (hover: hover) { @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"]) { .nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
background-color: var(--nv-color-surface-hover); background-color: var(--nv-color-surface-hover);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
} }
} }
@ -3983,7 +4005,7 @@
/* Today is ringed, not filled: filled means chosen. */ /* Today is ringed, not filled: filled means chosen. */
.nv-calendar__day[aria-current="date"] { .nv-calendar__day[aria-current="date"] {
color: var(--nv-color-accent-text); color: var(--nv-color-accent-text);
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent); 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. */ /* The chosen day is a small raised key, lit like the filled buttons. */
@ -4007,6 +4029,17 @@
color: var(--nv-color-text-disabled); color: var(--nv-color-text-disabled);
cursor: not-allowed; 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 { @layer niveus.components {
/* ========================================================================== /* ==========================================================================
@ -4026,9 +4059,9 @@
Glaze with something moving behind it. Glaze with something moving behind it.
The format is 24-hour unless told otherwise. With data-nv-format-toggle a The format is 24-hour unless told otherwise. With data-nv-format-toggle a
24H / AM/PM switch sits under the wheels and adds an AM/PM wheel when chosen; third wheel reads 24H / AM / PM: swipe it to AM or PM and the hour wheel
the value stays 24-hour either way, so a form never has to know which the becomes 1–12; swipe back to 24H and it returns to 0–23. The value stays
person used. 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 Without niveus.js the element is empty. For a no-script fallback, use a
native <input type="time" class="nv-input">. native <input type="time" class="nv-input">.
@ -4042,7 +4075,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: var(--nv-space-2); gap: var(--nv-space-2);
padding: var(--nv-space-2) var(--nv-space-3) var(--nv-space-3); padding: var(--nv-space-2) var(--nv-space-3);
border-radius: var(--nv-radius-xl); border-radius: var(--nv-radius-xl);
color: var(--nv-color-text-primary); color: var(--nv-color-text-primary);
} }
@ -4053,8 +4086,10 @@
gap: var(--nv-space-1); gap: var(--nv-space-1);
} }
/* The lens. It belongs to the wheels, not the picker, so the switch beneath /* The lens: a bar of tinted glass lying across the centre row. It is raised
them never moves the centre. */ 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 { .nv-timepicker__wheels::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -4063,8 +4098,9 @@
block-size: var(--_row); block-size: var(--_row);
translate: 0 -50%; translate: 0 -50%;
background-color: var(--nv-color-selection); background-color: var(--nv-color-selection);
background-image: var(--nv-sheen-raised);
border-radius: var(--nv-radius-md); border-radius: var(--nv-radius-md);
box-shadow: var(--nv-highlight-top); box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
pointer-events: none; pointer-events: none;
} }
@ -4085,6 +4121,9 @@
currentcolor 72%, currentcolor 72%,
transparent 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 { .nv-timepicker__column::-webkit-scrollbar {
@ -4107,28 +4146,43 @@
font-size: var(--nv-font-size-lg); font-size: var(--nv-font-size-lg);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
cursor: pointer; 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); 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"] { .nv-timepicker__option[aria-selected="true"] {
color: var(--nv-color-text-primary); color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-semibold); font-weight: var(--nv-font-weight-semibold);
} }
/* 24H / AM/PM. A button group under the wheels; the pressed one is the format
in use. 24-hour is the default, so it starts pressed. */
.nv-timepicker__format {
align-self: center;
}
/* Pressed reads as pushed in: the format in use is a key held down. */
.nv-timepicker__format .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);
}
.nv-timepicker__separator { .nv-timepicker__separator {
display: grid; display: grid;
place-items: center; place-items: center;

View file

@ -134,6 +134,14 @@ textarea {
border-radius: var(--nv-radius-xs); border-radius: var(--nv-radius-xs);
} }
/* 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 { ::selection {
background-color: var(--nv-color-selection); background-color: var(--nv-color-selection);
color: var(--nv-color-text-primary); color: var(--nv-color-text-primary);

View file

@ -93,8 +93,12 @@
} }
@media (hover: hover) { @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"]) { .nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) {
background-color: var(--nv-color-surface-hover); background-color: var(--nv-color-surface-hover);
box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge);
} }
} }
@ -105,7 +109,7 @@
/* Today is ringed, not filled: filled means chosen. */ /* Today is ringed, not filled: filled means chosen. */
.nv-calendar__day[aria-current="date"] { .nv-calendar__day[aria-current="date"] {
color: var(--nv-color-accent-text); color: var(--nv-color-accent-text);
box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent); 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. */ /* The chosen day is a small raised key, lit like the filled buttons. */
@ -129,3 +133,14 @@
color: var(--nv-color-text-disabled); color: var(--nv-color-text-disabled);
cursor: not-allowed; cursor: not-allowed;
} }
/* A day pressed into place springs up to full size. Only when it was just
chosen: a selection that is merely *there* on load stays still. */
.nv-calendar__day[aria-selected="true"][data-nv-pop] {
animation: nv-day-pop var(--nv-duration-quick) var(--nv-ease-spring);
}
@keyframes nv-day-pop {
from { scale: 0.72; }
to { scale: 1; }
}

View file

@ -75,7 +75,7 @@
box-shadow: var(--nv-outline), var(--nv-elevation-2); box-shadow: var(--nv-outline), var(--nv-elevation-2);
appearance: none; appearance: none;
-webkit-appearance: none; -webkit-appearance: none;
transition: scale var(--nv-duration-fast) var(--nv-ease-standard); transition: scale var(--nv-duration-quick) var(--nv-ease-spring);
} }
.nv-slider::-moz-range-thumb { .nv-slider::-moz-range-thumb {
@ -87,12 +87,17 @@
border: 0; border: 0;
border-radius: var(--nv-radius-full); border-radius: var(--nv-radius-full);
box-shadow: var(--nv-outline), var(--nv-elevation-2); box-shadow: var(--nv-outline), var(--nv-elevation-2);
transition: scale var(--nv-duration-fast) var(--nv-ease-standard); 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:hover::-webkit-slider-thumb { scale: 1.06; }
.nv-slider:active::-webkit-slider-thumb { scale: 0.96; } .nv-slider:active::-webkit-slider-thumb { scale: 0.96; }
.nv-slider:hover::-moz-range-thumb { scale: 1.06; }
.nv-slider:active::-moz-range-thumb { scale: 0.96; } .nv-slider:active::-moz-range-thumb { scale: 0.96; }
.nv-slider:focus-visible::-webkit-slider-thumb { .nv-slider:focus-visible::-webkit-slider-thumb {

View file

@ -99,7 +99,7 @@
border: var(--nv-border-width) solid transparent; border: var(--nv-border-width) solid transparent;
box-shadow: box-shadow:
var(--nv-highlight-top-strong), var(--nv-highlight-top-strong),
inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular), var(--nv-glaze-glow),
var(--nv-outline), var(--nv-outline),
var(--nv-elevation-4); var(--nv-elevation-4);
backdrop-filter: backdrop-filter:

View file

@ -15,9 +15,9 @@
Glaze with something moving behind it. Glaze with something moving behind it.
The format is 24-hour unless told otherwise. With data-nv-format-toggle a The format is 24-hour unless told otherwise. With data-nv-format-toggle a
24H / AM/PM switch sits under the wheels and adds an AM/PM wheel when chosen; third wheel reads 24H / AM / PM: swipe it to AM or PM and the hour wheel
the value stays 24-hour either way, so a form never has to know which the becomes 1–12; swipe back to 24H and it returns to 0–23. The value stays
person used. 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 Without niveus.js the element is empty. For a no-script fallback, use a
native <input type="time" class="nv-input">. native <input type="time" class="nv-input">.
@ -31,7 +31,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: var(--nv-space-2); gap: var(--nv-space-2);
padding: var(--nv-space-2) var(--nv-space-3) var(--nv-space-3); padding: var(--nv-space-2) var(--nv-space-3);
border-radius: var(--nv-radius-xl); border-radius: var(--nv-radius-xl);
color: var(--nv-color-text-primary); color: var(--nv-color-text-primary);
} }
@ -42,8 +42,10 @@
gap: var(--nv-space-1); gap: var(--nv-space-1);
} }
/* The lens. It belongs to the wheels, not the picker, so the switch beneath /* The lens: a bar of tinted glass lying across the centre row. It is raised
them never moves the centre. */ 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 { .nv-timepicker__wheels::before {
content: ""; content: "";
position: absolute; position: absolute;
@ -52,8 +54,9 @@
block-size: var(--_row); block-size: var(--_row);
translate: 0 -50%; translate: 0 -50%;
background-color: var(--nv-color-selection); background-color: var(--nv-color-selection);
background-image: var(--nv-sheen-raised);
border-radius: var(--nv-radius-md); border-radius: var(--nv-radius-md);
box-shadow: var(--nv-highlight-top); box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
pointer-events: none; pointer-events: none;
} }
@ -74,6 +77,9 @@
currentcolor 72%, currentcolor 72%,
transparent 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 { .nv-timepicker__column::-webkit-scrollbar {
@ -96,28 +102,43 @@
font-size: var(--nv-font-size-lg); font-size: var(--nv-font-size-lg);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
cursor: pointer; 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); 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"] { .nv-timepicker__option[aria-selected="true"] {
color: var(--nv-color-text-primary); color: var(--nv-color-text-primary);
font-weight: var(--nv-font-weight-semibold); font-weight: var(--nv-font-weight-semibold);
} }
/* 24H / AM/PM. A button group under the wheels; the pressed one is the format
in use. 24-hour is the default, so it starts pressed. */
.nv-timepicker__format {
align-self: center;
}
/* Pressed reads as pushed in: the format in use is a key held down. */
.nv-timepicker__format .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);
}
.nv-timepicker__separator { .nv-timepicker__separator {
display: grid; display: grid;
place-items: center; place-items: center;

View file

@ -1,3 +1,4 @@
/* SPDX-License-Identifier: Apache-2.0 */
/* ========================================================================== /* ==========================================================================
Niveus — continuity helpers Niveus — continuity helpers
-------------------------------------------------------------------------- --------------------------------------------------------------------------
@ -492,6 +493,8 @@
grid.setAttribute("aria-labelledby", title.id); grid.setAttribute("aria-labelledby", title.id);
} }
var pop = false;
function render() { function render() {
if (title) title.textContent = view.toLocaleDateString(locale, { month: "long", year: "numeric" }); if (title) title.textContent = view.toLocaleDateString(locale, { month: "long", year: "numeric" });
grid.textContent = ""; grid.textContent = "";
@ -525,6 +528,7 @@
} }
var iso = isoDate(cursor); var iso = isoDate(cursor);
var day = document.createElement("button"); var day = document.createElement("button");
if (pop && iso === selectedIso) day.dataset.nvPop = "";
day.type = "button"; day.type = "button";
day.className = "nv-calendar__day"; day.className = "nv-calendar__day";
day.setAttribute("role", "gridcell"); day.setAttribute("role", "gridcell");
@ -558,10 +562,36 @@
var update = function () { var update = function () {
view = new Date(date.getFullYear(), date.getMonth(), 1); view = new Date(date.getFullYear(), date.getMonth(), 1);
render(); render();
pop = false;
if (focus) focusActive(); if (focus) focusActive();
}; };
if (monthChanged) transition(update, { direction: direction }); update();
else update(); if (monthChanged) slide(direction);
}
// The month slides in from the side it lies on, with the easing and
// duration the rest of the system uses, so it collapses with reduced
// motion like everything else. Only the grid moves; the page does not.
function slide(direction) {
if (prefersReducedMotion() || !grid.animate) return;
var root = getComputedStyle(document.documentElement);
var duration = parseFloat(root.getPropertyValue("--nv-duration-expand"));
if (!duration) return;
var easing = root.getPropertyValue("--nv-ease-emphasized").trim() || "ease-out";
var shift = (direction === "forward" ? 1 : -1) * 28;
grid.animate(
[
{ opacity: 0, translate: shift + "px 0", filter: "blur(3px)" },
{ opacity: 1, translate: "0 0", filter: "blur(0)" },
],
{ duration: duration, easing: easing }
);
if (title) {
title.animate(
[{ opacity: 0, translate: shift / 2 + "px 0" }, { opacity: 1, translate: "0 0" }],
{ duration: duration, easing: easing }
);
}
} }
function stepMonth(count) { function stepMonth(count) {
@ -574,14 +604,18 @@
function choose(button) { function choose(button) {
selected = parseDate(button.dataset.date); selected = parseDate(button.dataset.date);
active = selected; active = selected;
pop = true;
calendar.dataset.nvValue = button.dataset.date; calendar.dataset.nvValue = button.dataset.date;
if (button.dataset.outside !== undefined) { if (button.dataset.outside !== undefined) {
show(selected, selected > view ? "forward" : "backward", true); show(selected, selected > view ? "forward" : "backward", true);
} else { } else {
grid.querySelectorAll('[aria-selected="true"]').forEach(function (other) { grid.querySelectorAll('[aria-selected="true"]').forEach(function (other) {
other.removeAttribute("aria-selected"); other.removeAttribute("aria-selected");
delete other.dataset.nvPop;
}); });
button.setAttribute("aria-selected", "true"); button.setAttribute("aria-selected", "true");
button.dataset.nvPop = "";
pop = false;
} }
calendar.dispatchEvent(new CustomEvent("nv:date", { calendar.dispatchEvent(new CustomEvent("nv:date", {
bubbles: true, bubbles: true,
@ -632,7 +666,8 @@
* Builds and drives one .nv-timepicker from data-*: nv-value (HH:MM, 24h), * Builds and drives one .nv-timepicker from data-*: nv-value (HH:MM, 24h),
* nv-hours ("24", the default, or "12" for an AM/PM wheel), nv-step (minute * nv-hours ("24", the default, or "12" for an AM/PM wheel), nv-step (minute
* interval), nv-name (adds a hidden input for forms) and nv-format-toggle * interval), nv-name (adds a hidden input for forms) and nv-format-toggle
* (adds a 24H / AM/PM switch under the wheels). The value is always 24-hour, * (adds a third wheel of 24H / AM / PM: swiping it changes the format). The
* value is always 24-hour,
* whatever the wheels show. Fires `nv:time` with { value } as the wheels * whatever the wheels show. Fires `nv:time` with { value } as the wheels
* settle on a new row. * settle on a new row.
*/ */
@ -652,9 +687,10 @@
var wheels = document.createElement("div"); var wheels = document.createElement("div");
wheels.className = "nv-timepicker__wheels"; wheels.className = "nv-timepicker__wheels";
var hourColumn, minuteColumn, periodColumn; var hourColumn, minuteColumn, periodColumn, formatColumn;
var field = null; var field = null;
var formatButtons = []; var modeTimer = 0;
var withFormat = picker.hasAttribute("data-nv-format-toggle");
var placed = false; var placed = false;
var last = ""; var last = "";
var frame = 0; var frame = 0;
@ -680,10 +716,22 @@
var hi = indexOf(hourColumn); var hi = indexOf(hourColumn);
var mi = indexOf(minuteColumn); var mi = indexOf(minuteColumn);
if (hi < 0 || mi < 0) return; if (hi < 0 || mi < 0) return;
var pm = periodColumn ? indexOf(periodColumn) === 1 : false; var fi = formatColumn ? indexOf(formatColumn) : -1;
if (formatColumn && fi < 0) return;
// The format wheel disagrees with the hour wheel: a new format was
// swiped to. Wait for it to settle, then rebuild the hours for it.
if (formatColumn && (fi > 0) !== twelve) {
global.clearTimeout(modeTimer);
modeTimer = global.setTimeout(switchFormat, 160);
return;
}
var pm = formatColumn ? fi === 2 : periodColumn ? indexOf(periodColumn) === 1 : false;
mark(hourColumn, hi); mark(hourColumn, hi);
mark(minuteColumn, mi); mark(minuteColumn, mi);
if (periodColumn) mark(periodColumn, pm ? 1 : 0); if (formatColumn) mark(formatColumn, fi);
else if (periodColumn) mark(periodColumn, pm ? 1 : 0);
var hour = twelve ? ((hi + 1) % 12) + (pm ? 12 : 0) : hi; var hour = twelve ? ((hi + 1) % 12) + (pm ? 12 : 0) : hi;
var value = pad(hour) + ":" + minuteItems[mi]; var value = pad(hour) + ":" + minuteItems[mi];
@ -708,10 +756,24 @@
var mi = Math.round(minute / step) % minuteItems.length; var mi = Math.round(minute / step) % minuteItems.length;
scrollTo(hourColumn, hi, false); scrollTo(hourColumn, hi, false);
scrollTo(minuteColumn, mi, false); scrollTo(minuteColumn, mi, false);
if (periodColumn) scrollTo(periodColumn, h24 >= 12 ? 1 : 0, false); if (formatColumn) scrollTo(formatColumn, twelve ? (h24 >= 12 ? 2 : 1) : 0, false);
else if (periodColumn) scrollTo(periodColumn, h24 >= 12 ? 1 : 0, false);
read(); read();
} }
// Going to 12-hour takes the half of the day that was swiped to; going back
// to 24-hour keeps the moment, which the hour wheel has been tracking.
function switchFormat() {
var fi = indexOf(formatColumn);
if (fi < 0) return;
var wants = fi > 0;
if (wants === twelve) { read(); return; }
if (wants) h24 = (h24 % 12) + (fi === 2 ? 12 : 0);
twelve = wants;
picker.dataset.nvHours = twelve ? "12" : "24";
build();
}
function buildColumn(label, items) { function buildColumn(label, items) {
var list = document.createElement("ul"); var list = document.createElement("ul");
list.className = "nv-timepicker__column"; list.className = "nv-timepicker__column";
@ -726,9 +788,51 @@
list.appendChild(option); list.appendChild(option);
}); });
// Past either end the wheel gives, as a phone's list does, then springs
// back. Touch is left to the platform (iOS bounces, Android stretches);
// overscroll-behavior keeps that inside the wheel. Mouse wheels and
// trackpads, which have no such effect on a nested scroller, are handled
// here. The distance has resistance: the further it is pulled, the less
// each notch moves it.
var pull = 0;
var settle = 0;
var limit = 36;
function setPull(value) {
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);
}
list.addEventListener("wheel", function (event) {
if (prefersReducedMotion()) return;
var atTop = list.scrollTop <= 0;
var atBottom = list.scrollTop >= list.scrollHeight - list.clientHeight - 1;
// Chromium reports a wheel notch in pixels (~100); Firefox reports it
// in lines (~3). Both are brought to pixels, or the pull would be
// invisible in one of them.
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;
event.preventDefault();
var next = pull - delta * 0.12 * (1 - Math.abs(pull) / limit);
setPull(Math.max(-limit, Math.min(limit, next)));
}, { passive: false });
// A key pressed at the end nudges the wheel instead of doing nothing.
list.nvNudge = function (direction) {
if (!prefersReducedMotion()) setPull(direction * 12);
};
list.addEventListener("scroll", function () { list.addEventListener("scroll", function () {
if (frame) return; if (frame) return;
frame = global.requestAnimationFrame(function () { frame = 0; read(); }); frame = global.requestAnimationFrame(function () { frame = 0; read(); drum(list); });
}, { passive: true }); }, { passive: true });
list.addEventListener("click", function (event) { list.addEventListener("click", function (event) {
@ -750,11 +854,41 @@
default: return; default: return;
} }
event.preventDefault(); event.preventDefault();
scrollTo(list, Math.max(0, Math.min(end, to)), true); var clamped = Math.max(0, Math.min(end, to));
if (clamped === index) list.nvNudge(to < index ? 1 : -1);
scrollTo(list, clamped, true);
}); });
return list; return list;
} }
// The drum effect is a CSS scroll-driven animation (see timepicker.css).
// Where that does not exist — Firefox — the same curve is applied from the
// scroll position here, so the wheels look the same in both. Only the few
// rows near the lens are touched.
var scrollDriven = global.CSS && global.CSS.supports && global.CSS.supports("animation-timeline", "view()");
function drum(list) {
if (scrollDriven || prefersReducedMotion()) return;
var first = list.firstElementChild;
var row = first ? first.offsetHeight : 0;
if (!row) return;
var centre = list.scrollTop / row;
var reach = list.clientHeight + row;
for (var i = 0; i < list.children.length; i++) {
var away = i - centre;
var option = list.children[i];
if (Math.abs(away) > 3) {
option.style.opacity = "0.25";
continue;
}
// 1 at the lens, 0 at the edge of the wheel; the CSS keyframes, spelt out.
var near = 1 - Math.min(1, Math.abs(away) * row / reach * 2);
option.style.scale = String(0.78 + 0.22 * near);
option.style.opacity = String(0.25 + 0.75 * near);
option.style.rotate = "x " + ((away < 0 ? 1 : -1) * 52 * (1 - near)).toFixed(1) + "deg";
}
}
function colon() { function colon() {
var mark = document.createElement("span"); var mark = document.createElement("span");
mark.className = "nv-timepicker__separator"; mark.className = "nv-timepicker__separator";
@ -773,17 +907,19 @@
wheels.textContent = ""; wheels.textContent = "";
hourColumn = buildColumn("Hours", hourItems); hourColumn = buildColumn("Hours", hourItems);
minuteColumn = buildColumn("Minutes", minuteItems); minuteColumn = buildColumn("Minutes", minuteItems);
periodColumn = twelve ? buildColumn("AM or PM", ["AM", "PM"]) : null; formatColumn = withFormat ? buildColumn("Time format", ["24H", "AM", "PM"]) : null;
periodColumn = twelve && !withFormat ? buildColumn("AM or PM", ["AM", "PM"]) : null;
wheels.appendChild(hourColumn); wheels.appendChild(hourColumn);
wheels.appendChild(colon()); wheels.appendChild(colon());
wheels.appendChild(minuteColumn); wheels.appendChild(minuteColumn);
if (periodColumn) wheels.appendChild(periodColumn); if (formatColumn) wheels.appendChild(formatColumn);
else if (periodColumn) wheels.appendChild(periodColumn);
formatButtons.forEach(function (button) {
button.setAttribute("aria-pressed", String((button.dataset.hours === "12") === twelve));
});
placed = false; placed = false;
place(); place();
[hourColumn, minuteColumn, formatColumn || periodColumn].forEach(function (list) {
if (list) drum(list);
});
} }
picker.textContent = ""; picker.textContent = "";
@ -793,30 +929,6 @@
} }
picker.appendChild(wheels); picker.appendChild(wheels);
if (picker.hasAttribute("data-nv-format-toggle")) {
var format = document.createElement("div");
format.className = "nv-timepicker__format nv-button-group";
format.setAttribute("role", "group");
format.setAttribute("aria-label", "Time format");
[["24", "24H"], ["12", "AM/PM"]].forEach(function (option) {
var button = document.createElement("button");
button.type = "button";
button.className = "nv-button nv-button--sm";
button.dataset.hours = option[0];
button.textContent = option[1];
button.addEventListener("click", function () {
var wants = option[0] === "12";
if (wants === twelve) return;
twelve = wants;
picker.dataset.nvHours = option[0];
build();
});
formatButtons.push(button);
format.appendChild(button);
});
picker.appendChild(format);
}
if (picker.dataset.nvName) { if (picker.dataset.nvName) {
field = document.createElement("input"); field = document.createElement("input");
field.type = "hidden"; field.type = "hidden";

View file

@ -32,16 +32,18 @@
:root, :root,
[data-nv-brand] { [data-nv-brand] {
/* --- Canvas and surfaces ----------------------------------------------- /* --- Canvas and surfaces -----------------------------------------------
Paper stays paper: the two surfaces that hold content are left untinted so Every neutral carries a trace of the brand, content surfaces included, the
text keeps its contrast. Everything the surfaces sit *in* picks up a trace way dark does: that is what stops an accent from looking stuck on, and what
of the brand, which is what stops an accent from looking stuck on. */ makes the two appearances read as one material. The paper is a soft
--nv-light-background: color-mix(in oklab, #e8e5de, var(--nv-brand) var(--nv-brand-tint)); grey-stone, not white — a bright field glares, and flattens the bevels that
--nv-light-background-subtle: color-mix(in oklab, #eeece6, var(--nv-brand) var(--nv-brand-tint)); are meant to shape it. Text keeps its contrast: the primary is neutral. */
--nv-light-surface: #fbfbfa; --nv-light-background: color-mix(in oklab, #d8d5ce, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-surface-elevated: #f8f8f6; --nv-light-background-subtle: color-mix(in oklab, #dedbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2));
--nv-light-surface-inset: color-mix(in oklab, #eeede8, var(--nv-brand) var(--nv-brand-tint)); --nv-light-surface: color-mix(in oklab, #eeede9, 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-elevated: color-mix(in oklab, #f2f1ed, var(--nv-brand) var(--nv-brand-tint));
--nv-light-surface-active: color-mix(in oklab, #e6e5e0, 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); --nv-light-scrim: rgb(28 24 18 / 0.34);
/* --- Text --------------------------------------------------------------- /* --- Text ---------------------------------------------------------------
@ -50,7 +52,7 @@
loss of legibility. */ loss of legibility. */
--nv-light-text-primary: var(--nv-palette-neutral-900); --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-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-disabled: var(--nv-palette-neutral-400);
--nv-light-text-inverted: var(--nv-palette-neutral-0); --nv-light-text-inverted: var(--nv-palette-neutral-0);
@ -135,20 +137,20 @@
/* --- Depth and material ------------------------------------------------- /* --- Depth and material -------------------------------------------------
Three shadow colors build every elevation level: an ambient contact Three shadow colors build every elevation level: an ambient contact
shadow, a soft penumbra, and a deeper umbra for high elevations. */ shadow, a soft penumbra, and a deeper umbra for high elevations. */
--nv-light-shadow-ambient: rgb(40 32 20 / 0.08); --nv-light-shadow-ambient: rgb(32 28 22 / 0.12);
--nv-light-shadow-penumbra: rgb(40 32 20 / 0.1); --nv-light-shadow-penumbra: rgb(32 28 22 / 0.15);
--nv-light-shadow-umbra: rgb(40 32 20 / 0.16); --nv-light-shadow-umbra: rgb(32 28 22 / 0.24);
--nv-light-shadow-inset: rgb(40 32 20 / 0.14); --nv-light-shadow-inset: rgb(32 28 22 / 0.2);
--nv-light-highlight: rgb(255 255 255 / 0.9); --nv-light-highlight: rgb(255 255 255 / 0.7);
--nv-light-highlight-strong: rgb(255 255 255 / 1); --nv-light-highlight-strong: rgb(255 255 255 / 0.85);
/* Paper already has a border doing the contact work, so the outline only /* Paper already has a border doing the contact work, so the outline only
softens it; the bottom shade is faint for the same reason. */ softens it; the bottom shade is faint for the same reason. */
--nv-light-lowlight: rgb(40 32 20 / 0.09); --nv-light-lowlight: rgb(32 28 22 / 0.14);
--nv-light-outline: rgb(40 32 20 / 0.05); --nv-light-outline: rgb(32 28 22 / 0.08);
--nv-light-sheen-top: rgb(255 255 255 / 0.9); --nv-light-sheen-top: rgb(255 255 255 / 0.55);
--nv-light-sheen-bottom: rgb(40 32 20 / 0.07); --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-top: rgb(255 255 255 / 0.32);
--nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16); --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); --nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3);
@ -164,16 +166,16 @@
--nv-light-elevation-tint-4: transparent; --nv-light-elevation-tint-4: transparent;
--nv-light-elevation-tint-5: transparent; --nv-light-elevation-tint-5: transparent;
--nv-light-material-frosted: rgb(252 252 250 / 0.56); --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: rgb(252 252 250 / 0.4); --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.72); --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 /* 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. */ facing the pointer, the rim on the far side, and the specular bloom. */
--nv-light-material-glaze: rgb(255 255 255 / 1); --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 / 1); --nv-light-glaze-rim-lit: rgb(255 255 255 / 0.9);
--nv-light-glaze-rim-far: rgb(255 255 255 / 0.5); --nv-light-glaze-rim-far: rgb(255 255 255 / 0.4);
--nv-light-glaze-specular: rgb(255 255 255 / 0.6); --nv-light-glaze-specular: rgb(255 255 255 / 0.4);
/* --- Window controls --------------------------------------------------------- */ /* --- Window controls --------------------------------------------------------- */
--nv-light-window-close: var(--nv-palette-window-close); --nv-light-window-close: var(--nv-palette-window-close);

View file

@ -54,6 +54,9 @@
--nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */ --nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */
--nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */ --nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */
--nv-glaze-rim: 1.5px; --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 ------------------------------------------------------------------ /* --- Sheen ------------------------------------------------------------------
raised neutral controls: buttons, the selected tab, a titlebar raised neutral controls: buttons, the selected tab, a titlebar