From 5d435c4f4966d13c90e9f23bc91eb488aef0f9f9 Mon Sep 17 00:00:00 2001 From: Nexoniarz Date: Wed, 28 Oct 2026 09:34:08 +0100 Subject: [PATCH] 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 --- GUIDELINES.md | 45 +++++++- README.md | 1 + REFERENCE.md | 7 +- dist/niveus.css | 164 +++++++++++++++++--------- niveus/base.css | 8 ++ niveus/components/calendar.css | 17 ++- niveus/components/slider.css | 13 ++- niveus/components/surface.css | 2 +- niveus/components/timepicker.css | 63 ++++++---- niveus/niveus.js | 190 ++++++++++++++++++++++++------- niveus/themes/light.css | 58 +++++----- niveus/tokens/materials.css | 3 + 12 files changed, 412 insertions(+), 159 deletions(-) diff --git a/GUIDELINES.md b/GUIDELINES.md index 3521410..aaab68d 100644 --- a/GUIDELINES.md +++ b/GUIDELINES.md @@ -146,6 +146,16 @@ freely. ### 4.1 Rules +0. **One dimensionality per interface.** Niveus is skeuomorphic *all the way + through*. A raised key, a recessed well and a lit lens are physical objects; + a flat colour block beside them reads as a different system. Every + interactive or selected element MUST take its shape from the depth + vocabulary — bevel, sheen, inset, ledge, outline, elevation — and MUST NOT be + a flat fill. If a state seems to need a flat wash (a hover, a selection band, + a pressed toggle), give it depth instead: press it *into* the surface, or + lay a lit lens over it. Mixing flat and physical is the single most common + way a Niveus interface starts to look unfinished. + 1. **Solid by default.** Anything that *holds content* — windows, cards, dialogs, tables, forms — is solid. Translucency is reserved for what *floats over* content. @@ -265,7 +275,17 @@ place to define a colour. attribute change (see 11). 5. **Semantic colours mean one thing.** `success`, `warning`, `danger` carry meaning; they are not accents. Use `accent` for emphasis and brand. -6. **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. --- @@ -320,7 +340,18 @@ have watched it reach.** away from the control that opened them. 7. **No JavaScript, no breakage.** Continuity enhances; it MUST NOT be required 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. 4. **Labels.** Every field has a programmatic label. Errors pair `aria-invalid="true"` with `aria-describedby` pointing at the message. -5. **Dialogs and popovers** trap or restore focus (native `` and - `popover` do; custom ones MUST). +5. **Focus management.** A modal `` opened with `showModal()` traps focus + and restores it on close. A `popover` does **not** trap focus: move focus into + it on open where it holds controls, and return it to the trigger on close, as + the popup list does. Custom overlays MUST do both. 6. **Live regions** (`aria-live`) for content that changes without focus — a calendar's month title is one. 7. **Test the extremes.** Verify text on translucent materials at the @@ -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; `niveus.js` only builds days, steps months and provides keyboard movement. - **Time picker** (`.nv-timepicker`): scroll-snap wheels. **24-hour is the - default**; `data-nv-format-toggle` adds a 24H / AM/PM switch beneath, and - AM/PM adds a third wheel. The value is always 24-hour (`HH:MM`), so a form + default**; `data-nv-format-toggle` adds a third wheel of 24H / AM / PM: swiping it + changes the format, and the hour wheel follows. The value is always 24-hour (`HH:MM`), so a form never needs to know which the person used. For a no-script fallback use ``; the wheels are an enhancement, not the only path. diff --git a/README.md b/README.md index e95a9a5..c7af6f4 100644 --- a/README.md +++ b/README.md @@ -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) | | Anchor positioning | menus tied to their trigger | the menu centres itself | | `popover` | menus, light dismiss | needs the application's own toggle | +| Scroll-driven animation | the time picker's drum effect | `niveus.js` applies the same curve from the scroll position (Firefox) | --- diff --git a/REFERENCE.md b/REFERENCE.md index ac7496f..105bb81 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -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__weekday` | weekday column header | | | `.nv-calendar__day` | day cell | `aria-selected="true"` chosen · `aria-current="date"` today · `data-outside` neighbouring month · `disabled` out of range. | -| `.nv-timepicker` | time picker, wheel picker | Scroll-snap wheels built from `data-nv-timepicker` plus `data-nv-value="HH:MM"` / `-hours` (`24` default, `12` adds an AM/PM wheel) / `-step` / `-name` / `-format-toggle` (a 24H / AM/PM switch under the wheels). The value is always 24-hour. Fires `nv:time`. Empty without the script; use `` 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 `` as the fallback. | | `.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__option` | wheel row | `aria-selected="true"` is the row in the lens. | | `.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` · `--nv-glaze-tint-max` · `--nv-glaze-rim` · `--nv-light-x` / `--nv-light-y` / `--nv-light-angle` (set by `niveus.js` on each glaze surface) · -`--nv-slider-pos` (set by `niveus.js`) · `--nv-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-layout-max-width` · `--nv-selection-name` · `--nv-anchor` · `--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: ``. | | `data-nv-calendar` | `.nv-calendar` | Marks a calendar for `niveus.js`. Also `data-nv-value`, `-min`, `-max` (`YYYY-MM-DD`), `-locale`, `-week-start` (0 = Sunday). | | `data-nv-calendar-step` | button in a calendar | `-1` or `1`: months to move. | -| `data-nv-timepicker` | `.nv-timepicker` | Marks a time picker. Also `data-nv-value` (`HH:MM`), `-hours` (`24` default / `12`), `-step` (minutes), `-name` (hidden form field), `-format-toggle` (adds the 24H / AM/PM 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-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. | diff --git a/dist/niveus.css b/dist/niveus.css index 2536a84..2a76b82 100644 --- a/dist/niveus.css +++ b/dist/niveus.css @@ -811,6 +811,9 @@ --nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */ --nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */ --nv-glaze-rim: 1.5px; + /* The light a pane holds just inside its edge, so it reads as a thick lens + rather than a flat film. */ + --nv-glaze-glow: inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular); /* --- Sheen ------------------------------------------------------------------ raised neutral controls: buttons, the selected tab, a titlebar @@ -907,16 +910,18 @@ :root, [data-nv-brand] { /* --- Canvas and surfaces ----------------------------------------------- - Paper stays paper: the two surfaces that hold content are left untinted so - text keeps its contrast. Everything the surfaces sit *in* picks up a trace - of the brand, which is what stops an accent from looking stuck on. */ - --nv-light-background: color-mix(in oklab, #e8e5de, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-background-subtle: color-mix(in oklab, #eeece6, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface: #fbfbfa; - --nv-light-surface-elevated: #f8f8f6; - --nv-light-surface-inset: color-mix(in oklab, #eeede8, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface-hover: color-mix(in oklab, #f1f0ec, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface-active: color-mix(in oklab, #e6e5e0, var(--nv-brand) var(--nv-brand-tint)); + Every neutral carries a trace of the brand, content surfaces included, the + way dark does: that is what stops an accent from looking stuck on, and what + makes the two appearances read as one material. The paper is a soft + grey-stone, not white — a bright field glares, and flattens the bevels that + are meant to shape it. Text keeps its contrast: the primary is neutral. */ + --nv-light-background: color-mix(in oklab, #d8d5ce, var(--nv-brand) calc(var(--nv-brand-tint) * 2)); + --nv-light-background-subtle: color-mix(in oklab, #dedbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2)); + --nv-light-surface: color-mix(in oklab, #eeede9, var(--nv-brand) var(--nv-brand-tint)); + --nv-light-surface-elevated: color-mix(in oklab, #f2f1ed, var(--nv-brand) var(--nv-brand-tint)); + --nv-light-surface-inset: color-mix(in oklab, #dddbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2)); + --nv-light-surface-hover: color-mix(in oklab, #e6e4de, var(--nv-brand) var(--nv-brand-tint)); + --nv-light-surface-active: color-mix(in oklab, #dad8d1, var(--nv-brand) var(--nv-brand-tint)); --nv-light-scrim: rgb(28 24 18 / 0.34); /* --- Text --------------------------------------------------------------- @@ -925,7 +930,7 @@ loss of legibility. */ --nv-light-text-primary: var(--nv-palette-neutral-900); --nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%); - --nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%); + --nv-light-text-tertiary: color-mix(in oklab, color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-palette-neutral-600) 40%), var(--nv-brand) 8%); --nv-light-text-disabled: var(--nv-palette-neutral-400); --nv-light-text-inverted: var(--nv-palette-neutral-0); @@ -1010,20 +1015,20 @@ /* --- Depth and material ------------------------------------------------- Three shadow colors build every elevation level: an ambient contact shadow, a soft penumbra, and a deeper umbra for high elevations. */ - --nv-light-shadow-ambient: rgb(40 32 20 / 0.08); - --nv-light-shadow-penumbra: rgb(40 32 20 / 0.1); - --nv-light-shadow-umbra: rgb(40 32 20 / 0.16); - --nv-light-shadow-inset: rgb(40 32 20 / 0.14); - --nv-light-highlight: rgb(255 255 255 / 0.9); - --nv-light-highlight-strong: rgb(255 255 255 / 1); + --nv-light-shadow-ambient: rgb(32 28 22 / 0.12); + --nv-light-shadow-penumbra: rgb(32 28 22 / 0.15); + --nv-light-shadow-umbra: rgb(32 28 22 / 0.24); + --nv-light-shadow-inset: rgb(32 28 22 / 0.2); + --nv-light-highlight: rgb(255 255 255 / 0.7); + --nv-light-highlight-strong: rgb(255 255 255 / 0.85); /* Paper already has a border doing the contact work, so the outline only softens it; the bottom shade is faint for the same reason. */ - --nv-light-lowlight: rgb(40 32 20 / 0.09); - --nv-light-outline: rgb(40 32 20 / 0.05); + --nv-light-lowlight: rgb(32 28 22 / 0.14); + --nv-light-outline: rgb(32 28 22 / 0.08); - --nv-light-sheen-top: rgb(255 255 255 / 0.9); - --nv-light-sheen-bottom: rgb(40 32 20 / 0.07); + --nv-light-sheen-top: rgb(255 255 255 / 0.55); + --nv-light-sheen-bottom: rgb(32 28 22 / 0.11); --nv-light-sheen-strong-top: rgb(255 255 255 / 0.32); --nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16); --nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3); @@ -1039,16 +1044,16 @@ --nv-light-elevation-tint-4: transparent; --nv-light-elevation-tint-5: transparent; - --nv-light-material-frosted: rgb(252 252 250 / 0.56); - --nv-light-material-glass: rgb(252 252 250 / 0.4); - --nv-light-material-border: rgb(255 255 255 / 0.72); + --nv-light-material-frosted: color-mix(in oklab, rgb(228 227 222 / 0.6), var(--nv-brand) var(--nv-brand-tint)); + --nv-light-material-glass: color-mix(in oklab, rgb(228 227 222 / 0.44), var(--nv-brand) var(--nv-brand-tint)); + --nv-light-material-border: rgb(255 255 255 / 0.5); /* Glaze: the tint it is made of, the rim that catches light on the side facing the pointer, the rim on the far side, and the specular bloom. */ - --nv-light-material-glaze: rgb(255 255 255 / 1); - --nv-light-glaze-rim-lit: rgb(255 255 255 / 1); - --nv-light-glaze-rim-far: rgb(255 255 255 / 0.5); - --nv-light-glaze-specular: rgb(255 255 255 / 0.6); + --nv-light-material-glaze: color-mix(in oklab, rgb(232 231 226 / 1), var(--nv-brand) calc(var(--nv-brand-tint) * 2)); + --nv-light-glaze-rim-lit: rgb(255 255 255 / 0.9); + --nv-light-glaze-rim-far: rgb(255 255 255 / 0.4); + --nv-light-glaze-specular: rgb(255 255 255 / 0.4); /* --- Window controls --------------------------------------------------------- */ --nv-light-window-close: var(--nv-palette-window-close); @@ -1369,6 +1374,14 @@ 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 { background-color: var(--nv-color-selection); color: var(--nv-color-text-primary); @@ -1840,7 +1853,7 @@ border: var(--nv-border-width) solid transparent; box-shadow: 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-elevation-4); backdrop-filter: @@ -2772,7 +2785,7 @@ box-shadow: var(--nv-outline), var(--nv-elevation-2); 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 { @@ -2784,12 +2797,17 @@ border: 0; border-radius: var(--nv-radius-full); 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:hover::-moz-range-thumb { scale: 1.06; } .nv-slider:active::-moz-range-thumb { scale: 0.96; } .nv-slider:focus-visible::-webkit-slider-thumb { @@ -3971,8 +3989,12 @@ } @media (hover: hover) { + /* Hover presses the day slightly into the surface; it is never a flat wash + of colour, because the chosen day is a raised key and the two must read as + the same material. */ .nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) { background-color: var(--nv-color-surface-hover); + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); } } @@ -3983,7 +4005,7 @@ /* Today is ringed, not filled: filled means chosen. */ .nv-calendar__day[aria-current="date"] { color: var(--nv-color-accent-text); - box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent); + 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. */ @@ -4007,6 +4029,17 @@ color: var(--nv-color-text-disabled); cursor: not-allowed; } + + /* A day pressed into place springs up to full size. Only when it was just + chosen: a selection that is merely *there* on load stays still. */ + .nv-calendar__day[aria-selected="true"][data-nv-pop] { + animation: nv-day-pop var(--nv-duration-quick) var(--nv-ease-spring); + } + + @keyframes nv-day-pop { + from { scale: 0.72; } + to { scale: 1; } + } } @layer niveus.components { /* ========================================================================== @@ -4026,9 +4059,9 @@ Glaze with something moving behind it. 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; - the value stays 24-hour either way, so a form never has to know which the - person used. + third wheel reads 24H / AM / PM: swipe it to AM or PM and the hour wheel + becomes 1–12; swipe back to 24H and it returns to 0–23. The value stays + 24-hour either way, so a form never has to know which the person used. Without niveus.js the element is empty. For a no-script fallback, use a native . @@ -4042,7 +4075,7 @@ flex-direction: column; align-items: center; 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); color: var(--nv-color-text-primary); } @@ -4053,8 +4086,10 @@ gap: var(--nv-space-1); } - /* The lens. It belongs to the wheels, not the picker, so the switch beneath - them never moves the centre. */ + /* The lens: a bar of tinted glass lying across the centre row. It is raised + like the keys elsewhere in the system — lit on top, shaded below, with a + contact line — not a flat band of colour, so it reads as a thing the wheels + pass under. */ .nv-timepicker__wheels::before { content: ""; position: absolute; @@ -4063,8 +4098,9 @@ block-size: var(--_row); translate: 0 -50%; background-color: var(--nv-color-selection); + background-image: var(--nv-sheen-raised); border-radius: var(--nv-radius-md); - box-shadow: var(--nv-highlight-top); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); pointer-events: none; } @@ -4085,6 +4121,9 @@ currentcolor 72%, transparent ); + /* Depth for the drum effect below: rows turn away from the viewer as they + leave the centre. */ + perspective: 14rem; } .nv-timepicker__column::-webkit-scrollbar { @@ -4107,28 +4146,43 @@ font-size: var(--nv-font-size-lg); font-variant-numeric: tabular-nums; cursor: pointer; + /* --nv-pull is how far the wheel has been dragged past its end. Letting go + removes it, and the spring easing carries the rows back with a small + overshoot — the way a phone's list gives at its limit and settles. */ + translate: 0 var(--nv-pull, 0); + transition: + var(--nv-transition-colors), + translate var(--nv-duration-slower) var(--nv-ease-spring); + } + + .nv-timepicker__column[data-nv-pulling] .nv-timepicker__option { transition: var(--nv-transition-colors); } + /* A drum: each row shrinks, fades and tilts away in proportion to how far it + is from the lens. Driven by the scroll position itself, so it needs no + script and tracks a finger exactly. Where scroll-driven animation is missing + the rows are simply flat, which is still correct. */ + @supports (animation-timeline: view()) { + @media (prefers-reduced-motion: no-preference) { + .nv-timepicker__option { + animation: nv-wheel-drum linear both; + animation-timeline: view(block); + } + } + } + + @keyframes nv-wheel-drum { + 0% { scale: 0.78; opacity: 0.25; rotate: x 52deg; } + 50% { scale: 1; opacity: 1; rotate: x 0deg; } + 100% { scale: 0.78; opacity: 0.25; rotate: x -52deg; } + } + .nv-timepicker__option[aria-selected="true"] { color: var(--nv-color-text-primary); font-weight: var(--nv-font-weight-semibold); } - /* 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 { display: grid; place-items: center; diff --git a/niveus/base.css b/niveus/base.css index 5c752e3..8f60033 100644 --- a/niveus/base.css +++ b/niveus/base.css @@ -134,6 +134,14 @@ textarea { 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 { background-color: var(--nv-color-selection); color: var(--nv-color-text-primary); diff --git a/niveus/components/calendar.css b/niveus/components/calendar.css index 80c20a1..9360653 100644 --- a/niveus/components/calendar.css +++ b/niveus/components/calendar.css @@ -93,8 +93,12 @@ } @media (hover: hover) { + /* Hover presses the day slightly into the surface; it is never a flat wash + of colour, because the chosen day is a raised key and the two must read as + the same material. */ .nv-calendar__day:hover:not(:disabled, [aria-selected="true"]) { background-color: var(--nv-color-surface-hover); + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); } } @@ -105,7 +109,7 @@ /* Today is ringed, not filled: filled means chosen. */ .nv-calendar__day[aria-current="date"] { color: var(--nv-color-accent-text); - box-shadow: inset 0 0 0 var(--nv-border-width-strong) var(--nv-color-accent); + 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. */ @@ -129,3 +133,14 @@ color: var(--nv-color-text-disabled); cursor: not-allowed; } + +/* A day pressed into place springs up to full size. Only when it was just + chosen: a selection that is merely *there* on load stays still. */ +.nv-calendar__day[aria-selected="true"][data-nv-pop] { + animation: nv-day-pop var(--nv-duration-quick) var(--nv-ease-spring); +} + +@keyframes nv-day-pop { + from { scale: 0.72; } + to { scale: 1; } +} diff --git a/niveus/components/slider.css b/niveus/components/slider.css index a4d413a..83fec11 100644 --- a/niveus/components/slider.css +++ b/niveus/components/slider.css @@ -75,7 +75,7 @@ box-shadow: var(--nv-outline), var(--nv-elevation-2); 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 { @@ -87,12 +87,17 @@ border: 0; border-radius: var(--nv-radius-full); 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:hover::-moz-range-thumb { scale: 1.06; } .nv-slider:active::-moz-range-thumb { scale: 0.96; } .nv-slider:focus-visible::-webkit-slider-thumb { diff --git a/niveus/components/surface.css b/niveus/components/surface.css index 0ab98b4..6bebe5c 100644 --- a/niveus/components/surface.css +++ b/niveus/components/surface.css @@ -99,7 +99,7 @@ border: var(--nv-border-width) solid transparent; box-shadow: 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-elevation-4); backdrop-filter: diff --git a/niveus/components/timepicker.css b/niveus/components/timepicker.css index 6476f8d..126030e 100644 --- a/niveus/components/timepicker.css +++ b/niveus/components/timepicker.css @@ -15,9 +15,9 @@ Glaze with something moving behind it. 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; - the value stays 24-hour either way, so a form never has to know which the - person used. + third wheel reads 24H / AM / PM: swipe it to AM or PM and the hour wheel + becomes 1–12; swipe back to 24H and it returns to 0–23. The value stays + 24-hour either way, so a form never has to know which the person used. Without niveus.js the element is empty. For a no-script fallback, use a native . @@ -31,7 +31,7 @@ flex-direction: column; align-items: center; 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); color: var(--nv-color-text-primary); } @@ -42,8 +42,10 @@ gap: var(--nv-space-1); } -/* The lens. It belongs to the wheels, not the picker, so the switch beneath - them never moves the centre. */ +/* The lens: a bar of tinted glass lying across the centre row. It is raised + like the keys elsewhere in the system — lit on top, shaded below, with a + contact line — not a flat band of colour, so it reads as a thing the wheels + pass under. */ .nv-timepicker__wheels::before { content: ""; position: absolute; @@ -52,8 +54,9 @@ block-size: var(--_row); translate: 0 -50%; background-color: var(--nv-color-selection); + background-image: var(--nv-sheen-raised); border-radius: var(--nv-radius-md); - box-shadow: var(--nv-highlight-top); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); pointer-events: none; } @@ -74,6 +77,9 @@ currentcolor 72%, transparent ); + /* Depth for the drum effect below: rows turn away from the viewer as they + leave the centre. */ + perspective: 14rem; } .nv-timepicker__column::-webkit-scrollbar { @@ -96,28 +102,43 @@ font-size: var(--nv-font-size-lg); font-variant-numeric: tabular-nums; cursor: pointer; + /* --nv-pull is how far the wheel has been dragged past its end. Letting go + removes it, and the spring easing carries the rows back with a small + overshoot — the way a phone's list gives at its limit and settles. */ + translate: 0 var(--nv-pull, 0); + transition: + var(--nv-transition-colors), + translate var(--nv-duration-slower) var(--nv-ease-spring); +} + +.nv-timepicker__column[data-nv-pulling] .nv-timepicker__option { transition: var(--nv-transition-colors); } +/* A drum: each row shrinks, fades and tilts away in proportion to how far it + is from the lens. Driven by the scroll position itself, so it needs no + script and tracks a finger exactly. Where scroll-driven animation is missing + the rows are simply flat, which is still correct. */ +@supports (animation-timeline: view()) { + @media (prefers-reduced-motion: no-preference) { + .nv-timepicker__option { + animation: nv-wheel-drum linear both; + animation-timeline: view(block); + } + } +} + +@keyframes nv-wheel-drum { + 0% { scale: 0.78; opacity: 0.25; rotate: x 52deg; } + 50% { scale: 1; opacity: 1; rotate: x 0deg; } + 100% { scale: 0.78; opacity: 0.25; rotate: x -52deg; } +} + .nv-timepicker__option[aria-selected="true"] { color: var(--nv-color-text-primary); font-weight: var(--nv-font-weight-semibold); } -/* 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 { display: grid; place-items: center; diff --git a/niveus/niveus.js b/niveus/niveus.js index af7166e..7e40e05 100644 --- a/niveus/niveus.js +++ b/niveus/niveus.js @@ -1,3 +1,4 @@ +/* SPDX-License-Identifier: Apache-2.0 */ /* ========================================================================== Niveus — continuity helpers -------------------------------------------------------------------------- @@ -492,6 +493,8 @@ grid.setAttribute("aria-labelledby", title.id); } + var pop = false; + function render() { if (title) title.textContent = view.toLocaleDateString(locale, { month: "long", year: "numeric" }); grid.textContent = ""; @@ -525,6 +528,7 @@ } var iso = isoDate(cursor); var day = document.createElement("button"); + if (pop && iso === selectedIso) day.dataset.nvPop = ""; day.type = "button"; day.className = "nv-calendar__day"; day.setAttribute("role", "gridcell"); @@ -558,10 +562,36 @@ var update = function () { view = new Date(date.getFullYear(), date.getMonth(), 1); render(); + pop = false; if (focus) focusActive(); }; - if (monthChanged) transition(update, { direction: direction }); - else update(); + 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) { @@ -574,14 +604,18 @@ function choose(button) { selected = parseDate(button.dataset.date); active = selected; + pop = true; calendar.dataset.nvValue = button.dataset.date; if (button.dataset.outside !== undefined) { show(selected, selected > view ? "forward" : "backward", true); } else { grid.querySelectorAll('[aria-selected="true"]').forEach(function (other) { other.removeAttribute("aria-selected"); + delete other.dataset.nvPop; }); button.setAttribute("aria-selected", "true"); + button.dataset.nvPop = ""; + pop = false; } calendar.dispatchEvent(new CustomEvent("nv:date", { bubbles: true, @@ -632,7 +666,8 @@ * 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 * 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 * settle on a new row. */ @@ -652,9 +687,10 @@ var wheels = document.createElement("div"); wheels.className = "nv-timepicker__wheels"; - var hourColumn, minuteColumn, periodColumn; + var hourColumn, minuteColumn, periodColumn, formatColumn; var field = null; - var formatButtons = []; + var modeTimer = 0; + var withFormat = picker.hasAttribute("data-nv-format-toggle"); var placed = false; var last = ""; var frame = 0; @@ -680,10 +716,22 @@ var hi = indexOf(hourColumn); var mi = indexOf(minuteColumn); 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(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 value = pad(hour) + ":" + minuteItems[mi]; @@ -708,10 +756,24 @@ var mi = Math.round(minute / step) % minuteItems.length; scrollTo(hourColumn, hi, 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(); } + // 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) { var list = document.createElement("ul"); list.className = "nv-timepicker__column"; @@ -726,9 +788,51 @@ 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 () { if (frame) return; - frame = global.requestAnimationFrame(function () { frame = 0; read(); }); + frame = global.requestAnimationFrame(function () { frame = 0; read(); drum(list); }); }, { passive: true }); list.addEventListener("click", function (event) { @@ -750,11 +854,41 @@ default: return; } 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; } + // 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() { var mark = document.createElement("span"); mark.className = "nv-timepicker__separator"; @@ -773,17 +907,19 @@ wheels.textContent = ""; hourColumn = buildColumn("Hours", hourItems); 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(colon()); 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; place(); + [hourColumn, minuteColumn, formatColumn || periodColumn].forEach(function (list) { + if (list) drum(list); + }); } picker.textContent = ""; @@ -793,30 +929,6 @@ } 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) { field = document.createElement("input"); field.type = "hidden"; diff --git a/niveus/themes/light.css b/niveus/themes/light.css index b8bf762..a2ed60f 100644 --- a/niveus/themes/light.css +++ b/niveus/themes/light.css @@ -32,16 +32,18 @@ :root, [data-nv-brand] { /* --- Canvas and surfaces ----------------------------------------------- - Paper stays paper: the two surfaces that hold content are left untinted so - text keeps its contrast. Everything the surfaces sit *in* picks up a trace - of the brand, which is what stops an accent from looking stuck on. */ - --nv-light-background: color-mix(in oklab, #e8e5de, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-background-subtle: color-mix(in oklab, #eeece6, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface: #fbfbfa; - --nv-light-surface-elevated: #f8f8f6; - --nv-light-surface-inset: color-mix(in oklab, #eeede8, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface-hover: color-mix(in oklab, #f1f0ec, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface-active: color-mix(in oklab, #e6e5e0, var(--nv-brand) var(--nv-brand-tint)); + Every neutral carries a trace of the brand, content surfaces included, the + way dark does: that is what stops an accent from looking stuck on, and what + makes the two appearances read as one material. The paper is a soft + grey-stone, not white — a bright field glares, and flattens the bevels that + are meant to shape it. Text keeps its contrast: the primary is neutral. */ + --nv-light-background: color-mix(in oklab, #d8d5ce, var(--nv-brand) calc(var(--nv-brand-tint) * 2)); + --nv-light-background-subtle: color-mix(in oklab, #dedbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2)); + --nv-light-surface: color-mix(in oklab, #eeede9, var(--nv-brand) var(--nv-brand-tint)); + --nv-light-surface-elevated: color-mix(in oklab, #f2f1ed, var(--nv-brand) var(--nv-brand-tint)); + --nv-light-surface-inset: color-mix(in oklab, #dddbd4, var(--nv-brand) calc(var(--nv-brand-tint) * 2)); + --nv-light-surface-hover: color-mix(in oklab, #e6e4de, var(--nv-brand) var(--nv-brand-tint)); + --nv-light-surface-active: color-mix(in oklab, #dad8d1, var(--nv-brand) var(--nv-brand-tint)); --nv-light-scrim: rgb(28 24 18 / 0.34); /* --- Text --------------------------------------------------------------- @@ -50,7 +52,7 @@ loss of legibility. */ --nv-light-text-primary: var(--nv-palette-neutral-900); --nv-light-text-secondary: color-mix(in oklab, var(--nv-palette-neutral-600), var(--nv-brand) 8%); - --nv-light-text-tertiary: color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-brand) 8%); + --nv-light-text-tertiary: color-mix(in oklab, color-mix(in oklab, var(--nv-palette-neutral-500), var(--nv-palette-neutral-600) 40%), var(--nv-brand) 8%); --nv-light-text-disabled: var(--nv-palette-neutral-400); --nv-light-text-inverted: var(--nv-palette-neutral-0); @@ -135,20 +137,20 @@ /* --- Depth and material ------------------------------------------------- Three shadow colors build every elevation level: an ambient contact shadow, a soft penumbra, and a deeper umbra for high elevations. */ - --nv-light-shadow-ambient: rgb(40 32 20 / 0.08); - --nv-light-shadow-penumbra: rgb(40 32 20 / 0.1); - --nv-light-shadow-umbra: rgb(40 32 20 / 0.16); - --nv-light-shadow-inset: rgb(40 32 20 / 0.14); - --nv-light-highlight: rgb(255 255 255 / 0.9); - --nv-light-highlight-strong: rgb(255 255 255 / 1); + --nv-light-shadow-ambient: rgb(32 28 22 / 0.12); + --nv-light-shadow-penumbra: rgb(32 28 22 / 0.15); + --nv-light-shadow-umbra: rgb(32 28 22 / 0.24); + --nv-light-shadow-inset: rgb(32 28 22 / 0.2); + --nv-light-highlight: rgb(255 255 255 / 0.7); + --nv-light-highlight-strong: rgb(255 255 255 / 0.85); /* Paper already has a border doing the contact work, so the outline only softens it; the bottom shade is faint for the same reason. */ - --nv-light-lowlight: rgb(40 32 20 / 0.09); - --nv-light-outline: rgb(40 32 20 / 0.05); + --nv-light-lowlight: rgb(32 28 22 / 0.14); + --nv-light-outline: rgb(32 28 22 / 0.08); - --nv-light-sheen-top: rgb(255 255 255 / 0.9); - --nv-light-sheen-bottom: rgb(40 32 20 / 0.07); + --nv-light-sheen-top: rgb(255 255 255 / 0.55); + --nv-light-sheen-bottom: rgb(32 28 22 / 0.11); --nv-light-sheen-strong-top: rgb(255 255 255 / 0.32); --nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.16); --nv-light-glow: oklch(from var(--nv-light-accent) l c h / 0.3); @@ -164,16 +166,16 @@ --nv-light-elevation-tint-4: transparent; --nv-light-elevation-tint-5: transparent; - --nv-light-material-frosted: rgb(252 252 250 / 0.56); - --nv-light-material-glass: rgb(252 252 250 / 0.4); - --nv-light-material-border: rgb(255 255 255 / 0.72); + --nv-light-material-frosted: color-mix(in oklab, rgb(228 227 222 / 0.6), var(--nv-brand) var(--nv-brand-tint)); + --nv-light-material-glass: color-mix(in oklab, rgb(228 227 222 / 0.44), var(--nv-brand) var(--nv-brand-tint)); + --nv-light-material-border: rgb(255 255 255 / 0.5); /* Glaze: the tint it is made of, the rim that catches light on the side facing the pointer, the rim on the far side, and the specular bloom. */ - --nv-light-material-glaze: rgb(255 255 255 / 1); - --nv-light-glaze-rim-lit: rgb(255 255 255 / 1); - --nv-light-glaze-rim-far: rgb(255 255 255 / 0.5); - --nv-light-glaze-specular: rgb(255 255 255 / 0.6); + --nv-light-material-glaze: color-mix(in oklab, rgb(232 231 226 / 1), var(--nv-brand) calc(var(--nv-brand-tint) * 2)); + --nv-light-glaze-rim-lit: rgb(255 255 255 / 0.9); + --nv-light-glaze-rim-far: rgb(255 255 255 / 0.4); + --nv-light-glaze-specular: rgb(255 255 255 / 0.4); /* --- Window controls --------------------------------------------------------- */ --nv-light-window-close: var(--nv-palette-window-close); diff --git a/niveus/tokens/materials.css b/niveus/tokens/materials.css index f6f5118..6fecae9 100644 --- a/niveus/tokens/materials.css +++ b/niveus/tokens/materials.css @@ -54,6 +54,9 @@ --nv-glaze-tint-min: 4%; /* tint opacity at frost 0 */ --nv-glaze-tint-max: 78%; /* tint opacity at frost 1 */ --nv-glaze-rim: 1.5px; + /* The light a pane holds just inside its edge, so it reads as a thick lens + rather than a flat film. */ + --nv-glaze-glow: inset 0 0 1.5rem -0.5rem var(--nv-color-glaze-specular); /* --- Sheen ------------------------------------------------------------------ raised neutral controls: buttons, the selected tab, a titlebar