diff --git a/.gitattributes b/.gitattributes index e5b6a7f..1cca36e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -16,3 +16,14 @@ *.tgz binary *.zip binary *.wad binary +# Fonts and the rest of what the website ships. `* text` above applies to +# anything not listed, and a compressed font with a 0x0D 0x0A in it had that +# pair "fixed" to 0x0A on commit -- a woff2 that no longer decoded. +*.woff binary +*.woff2 binary +*.otf binary +*.ico binary +*.gif binary +*.webp binary +*.pdf binary +*.wasm binary diff --git a/docs/Website/niveus/.gitignore b/docs/Website/niveus/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/docs/Website/niveus/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/docs/Website/niveus/LICENSE b/docs/Website/niveus/LICENSE new file mode 100644 index 0000000..b34d044 --- /dev/null +++ b/docs/Website/niveus/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the + purposes of this License, Derivative Works shall not include works + that remain separable from, or merely link (or bind by name) to the + interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the + original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing + the origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/docs/Website/niveus/README.md b/docs/Website/niveus/README.md index 9bbfe49..f89a8e5 100644 --- a/docs/Website/niveus/README.md +++ b/docs/Website/niveus/README.md @@ -1,16 +1,28 @@ # Niveus -A calm, adaptive interface language built around clarity, depth, material and -consistency. +A tactile, adaptive interface language. Controls are bevelled keys with light +falling across them, fields are wells cut into the surface, windows are solid, +and glass is kept for the chrome that floats over everything else. Warm +graphite in dark, soft paper in light, one brand color driving both. Niveus is a **design-system layer**, not a component library bolted onto an application. Applications consume it; they do not redefine it. The visual rules live here and can change without touching a line of application logic. ```html - + ``` +Niveus ships its own faces in `fonts/`: Instrument Sans for the interface and +JetBrains Mono for code and engraved labels, both under the SIL Open Font +License. Copy `fonts/` alongside `dist/` (or `niveus/`) and they load by +themselves. + +The stylesheet uses the variable `.woff2` files. `fonts/static/` holds the +same faces as plain TrueType files at the four weights Niveus uses (400, 500, +600, 700, plus Instrument Sans italics), for native toolkits and font +configurations that pick a weight by name rather than by variable axis. + ```html ``` @@ -137,6 +149,27 @@ answers how far that surface is from the canvas. They are independent. Applied as classes: `.nv-material-frosted`, `.nv-elevation-3`. A shadow that is on neither scale is a shadow that means nothing. +### Light + +Elevation is the shade a surface casts. **Glow** is the light it gives off — +and it is reserved for things that are *on*: a live status, the plan you are +on, a finished step, the selected item in a showcase. Glow never replaces an +elevation; a glowing surface still rests on something. + +| Where | How | +| ----- | --- | +| A card that belongs to something with a color | `.nv-card--lit` — a lit top edge in `--nv-card-tint` | +| The one card that is on | `.nv-card--glow` | +| A showcase card | `.nv-card--spotlight` — light follows the pointer | +| A feature icon or product mark | `.nv-tile`, `--glow` to light it | +| A person | `.nv-avatar--glow` | +| A display line | `.nv-text-lit` — type cut from the same lit material | + +Glows come in the same steps as everything else (`--nv-glow-sm/md/lg`), and a +tinted surface glows in its own tint, mixed down to `--nv-glow-strength`. Two +glowing things side by side cancel each other out: if everything is lit, +nothing is. + ## Continuity Every interaction should read as a continuation of the state before it. When @@ -170,6 +203,11 @@ works; changes simply arrive rather than travel. It also wires `[data-nv-theme]`, `[data-nv-dialog]`, `[data-nv-tabs]` and `[data-nv-close]` so the common cases need no code at all. +Long pages can arrive in the order they were written: `.nv-reveal` rises into +place once as it scrolls into view, and `.nv-reveal-group` brings its children +in one after another, `--nv-stagger` apart. Only the script marks anything as +waiting, so without it — and under reduced motion — nothing is ever hidden. + ### One rule worth knowing A `view-transition-name` must be unique among all rendered elements, or the @@ -379,7 +417,7 @@ probably not the problem the system has. | -------------- | --------------------------------------------------------- | | `.nv-button` | `--primary` `--subtle` `--ghost` `--danger` `--frosted` `--float`, `--sm`/`--lg`, `--icon`, `--pill`, `--block`, `.nv-button-group` | | `.nv-input` | with `.nv-field`, `.nv-input-group`, `.nv-select`, `.nv-checkbox`, `.nv-radio`, `.nv-switch`, `.nv-choice` | -| `.nv-card` | `--elevated` `--floating` `--inset` `--frosted` `--interactive`, `.nv-card-grid` | +| `.nv-card` | `--elevated` `--floating` `--inset` `--frosted` `--interactive`, light: `--lit` `--glow` `--spotlight`, `.nv-card-grid` | | `.nv-dialog` | native ``; `--sheet` `--compact` `--wide` | | `.nv-nav` | `--vertical` `--plain` `--inline`, plus `.nv-nav-tabs`, `.nv-tab-panel` and `.nv-breadcrumbs` | | `.nv-menu` | anchored popover with `.nv-menu-trigger`, `__item`, `__separator`, `__label`, `__shortcut` | @@ -388,6 +426,12 @@ probably not the problem the system has. | `.nv-bed` | ground that is visibly not ordinary background; `--quiet` `--vivid` `--single` `--flush` | | `.nv-badge` | state, not an action; `--accent` `--success` `--warning` `--danger`, `--solid` `--count` `--square` | | `.nv-table` | inside `.nv-table-frame`; `__number` `__actions`, `--compact` `--interactive` | +| `.nv-tile` | a small raised key holding one glyph; `--solid` `--glow`, `--sm` `--lg` `--xl` | +| `.nv-avatar` | round raised disc; `--ring` `--glow`, sizes, `__status`, `.nv-avatar-group` | +| `.nv-stat` | readings set into one recessed well with `.nv-stat-group`; `__label` `__value` `__delta` | +| `.nv-steps` | markers set into a rail; `data-state="done"` lights the way, `--vertical` | +| `.nv-deck` | one surface in front of others of its kind, with `__layer` | +| `.nv-skeleton` | a waiting well with a slow sweep of light; `--text` `--circle` | | `.nv-surface` | materials and elevation as utilities | Variants are defined by reassigning private `--_*` properties. Interaction — @@ -413,6 +457,14 @@ python3 tools/build.py --check # fail if dist/ is stale (for CI) python3 tools/check.py # enforce the system's own rules ``` +`tools/niveus_css.py` is not a command: it is an evaluator for the subset of +CSS the token layer is written in (`oklch(from …)`, `color-mix()`, +`light-dark()`, `calc()`), with CSS Color 4 gamut mapping, plus `read_rules()` +for the component layer (which role a button's background is in each state). +Every port that cannot read CSS — Android, Qt — imports it from here to +generate its tokens and control recipes, so no platform restates a value by +hand. + `check.py` catches the failures that do not look broken: a role defined for one appearance but not the other, a token referenced but never defined, and a hard-coded color in a component. @@ -435,9 +487,16 @@ to the state change simply happening rather than travelling: --- -> Niveus is not about making interfaces look physical. It is about making -> digital surfaces feel understandable. +> Niveus borrows from physical things only what makes a surface easier to +> understand: a key looks pressable, a well looks like a place to put +> something, and glass means something is floating. --- +## License + +Niveus is licensed under the [Apache License 2.0](LICENSE). The fonts in +`fonts/` keep their own license, the SIL Open Font License 1.1 +(`fonts/OFL-*.txt`), which allows bundling them with anything. + Made by **Nexoniarz**. diff --git a/docs/Website/niveus/REFERENCE.md b/docs/Website/niveus/REFERENCE.md index 56cb1f1..3b8f139 100644 --- a/docs/Website/niveus/REFERENCE.md +++ b/docs/Website/niveus/REFERENCE.md @@ -17,7 +17,7 @@ Everything ships inside the `niveus` cascade layer, so unlayered application styles win without `!important`. ```html - + @@ -76,6 +76,9 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"` | `.nv-card--frosted` | glass card | Translucent, for use over imagery or a bed. | | `.nv-card--interactive` | clickable card | Lifts on hover, sinks on press. | | `.nv-card--flush` | edge-to-edge card | Removes part padding, for media or tables. | +| `.nv-card--lit` | accent-edge card, colored top border | A strip of light along the top edge in the card's tint, spilling a little down the face. For a card that belongs to something with a color. | +| `.nv-card--glow` | glowing card, highlighted card | Gives off its tint. For the one card that is *on* — the current plan, the live item. | +| `.nv-card--spotlight` | spotlight card, cursor glow | A soft light follows the pointer across the face (needs `niveus.js`). Showcases only. | | `.nv-card__header` | card header | | | `.nv-card__title` | card title | | | `.nv-card__subtitle` | card subtitle, supporting text | | @@ -86,6 +89,19 @@ States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"` | `.nv-card__link` | stretched link | Expands one link's hit area over the whole card. | | `.nv-card-grid` | responsive card grid | Auto-fitting, equal-height columns. | +`--lit`, `--glow` and `--spotlight` combine with every elevation and with each +other. They take `--nv-card-tint` (the accent unless set on the card). + +## Deck + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-deck` | card stack, stacked cards | One surface in front of others of its kind. Every child shares one grid cell. | +| `.nv-deck__layer` | stack layer | A decorative card behind the front one, stepping back and dimming with depth. Up to two; mark them `aria-hidden="true"`. | + +Hidden `.nv-tab-panel`s can live in a deck together: the front surface is +whichever one is showing. + ## Dialog Built on native ``; open with `showModal()` or `nv.openDialog()`. @@ -136,7 +152,7 @@ the same anchor through `--nv-anchor`. | `.nv-nav__badge` | count badge, notification badge | | | `.nv-nav__spacer` | flexible spacer | Pushes what follows to the end. | | `.nv-nav-tabs` | tab list, segmented tabs | Inset track; the selected tab lifts out of it. | -| `.nv-tab-panel` | tab panel | Carries the shared content name so panels cross-fade. | +| `.nv-tab-panel` | tab panel | Hands over to the next panel inside the same box when `nv.selectTab` switches tabs. | | `.nv-breadcrumbs` | breadcrumbs | | ## Disclosure @@ -173,6 +189,61 @@ Set `--nv-scroller-item` for item width, `--nv-scroller-fade` for the edge fade. | `.nv-badge--square` | square badge | | | `.nv-badge__dot` | status dot | | +## Tile + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-tile` | icon tile, icon badge, feature icon | A small raised key holding one glyph: tinted face, glyph in its tint. Not interactive. | +| `.nv-tile--solid` | filled icon tile | Saturated face with a light glyph. | +| `.nv-tile--glow` | glowing icon tile | Lit from inside. | +| `.nv-tile--sm` `.nv-tile--lg` `.nv-tile--xl` | tile sizes | 32 / 56 / 72px; default 44px. | + +`--nv-tile-tint` picks the color. An `` inside fills the key, an `` +takes half of it. + +## Avatar + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-avatar` | avatar, profile picture | Round raised disc holding an `` or initials. | +| `.nv-avatar--ring` | avatar ring, story ring | A ring in `--nv-avatar-tint`, set off by a gap of surface. | +| `.nv-avatar--glow` | glowing avatar | The ring, giving off its tint. | +| `.nv-avatar--sm` `.nv-avatar--lg` `.nv-avatar--xl` | avatar sizes | 28 / 64 / 96px; default 40px. | +| `.nv-avatar__status` | presence dot | Grey at rest. | +| `.nv-avatar__status--online` | online indicator | Lit green. | +| `.nv-avatar-group` | avatar stack, facepile | Overlapping row. | + +## Stat + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-stat-group` | stats row, KPI strip, readout | One recessed well holding several readings, split by hairlines. Wraps on its own. Use a `
`. | +| `.nv-stat` | stat, metric, KPI | One reading: label, value, optional delta, in that order. | +| `.nv-stat__label` | metric label | `
` inside a group. | +| `.nv-stat__value` | metric value, big number | `
`; tabular numerals. | +| `.nv-stat__delta` | change, trend | | +| `.nv-stat__delta--up` `.nv-stat__delta--down` | positive / negative trend | Success and danger text. | + +## Steps + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-steps` | stepper, timeline, process, progress steps | Markers set into a rail cut into the surface. Horizontal, vertical below 40rem. Use an `
    `. | +| `.nv-steps--vertical` | vertical stepper, timeline | Always vertical. | +| `.nv-steps__item` | step | `data-state="done"` lights it and its stretch of rail; `aria-current="step"` lights the marker only. | +| `.nv-steps__marker` | step number, step knob | | +| `.nv-steps__body` | step content | | +| `.nv-steps__title` | step title | | +| `.nv-steps__text` | step description | | + +## Skeleton + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-skeleton` | skeleton, loading placeholder, shimmer | A shallow well where content will be, with a slow band of light crossing it. Still under reduced motion. | +| `.nv-skeleton--text` | text line placeholder | The last of several lines ends short. | +| `.nv-skeleton--circle` | avatar placeholder | | + ## Table | Class | Known as | Does | @@ -197,6 +268,25 @@ Set `--nv-scroller-item` for item width, `--nv-scroller-fade` for the edge fade. Set `--nv-bed-tint` to lead with a color other than the accent. +## Window + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-window` | window, app frame | A framed, solid window. Content never sits on glass. | +| `.nv-window--inactive` | unfocused window | Greys the lights and the title, drops one elevation. Hovering the lights restores their color. | +| `.nv-window__titlebar` | titlebar, header bar | Raised strip: controls · title · actions, with the title on the window's center line. | +| `.nv-window__controls` | traffic lights, window buttons | Group at the start of the bar. Its glyphs show while the pointer is over the group. | +| `.nv-window__control` | window button | One light. Needs `aria-label`, plus one of the three below. | +| `.nv-window__control--close` | close button | Red, ×. Always first. | +| `.nv-window__control--minimize` | minimize button | Yellow, −. Always second. | +| `.nv-window__control--zoom` | zoom, maximize button | Green, +. Always third. | +| `.nv-window__title` | window title | Truncates. | +| `.nv-window__actions` | titlebar actions | Trailing slot; put `.nv-button--ghost.nv-button--sm` here. | +| `.nv-window__body` | window content | Fills the rest and scrolls. | + +The three lights keep fixed colors on purpose: they are found by position and +color before they are read, so they never take the brand. + ## Surface, material and elevation | Class | Known as | Does | @@ -205,7 +295,7 @@ Set `--nv-bed-tint` to lead with a color other than the accent. | `.nv-material-solid` | opaque surface | Content lives here. The default. | | `.nv-material-elevated` | raised surface | Separated from its context. | | `.nv-material-inset` | recessed surface | Fields, wells, tracks. | -| `.nv-material-frosted` | frosted glass, blur backdrop | Chrome that content passes under. | +| `.nv-material-frosted` | frosted glass, blur backdrop | Chrome that floats over content: bars, docks, launchers, notifications, menus. Moderate blur, bevelled rim. | | `.nv-material-glass` | glass | Contextual layers where content behind must stay legible. | | `.nv-elevation-0` `.nv-elevation-1` `.nv-elevation-2` | elevation / z-depth | flush · resting · raised | | `.nv-elevation-3` `.nv-elevation-4` `.nv-elevation-5` | elevation / z-depth | floating · lifted · overlay | @@ -215,6 +305,14 @@ Set `--nv-bed-tint` to lead with a color other than the accent. Material and elevation are independent: combine freely. +Niveus is solid, with glass used sparingly: anything that holds content +(windows, cards, dialogs) is solid, and only chrome floating over it is +frosted. Raised things are bevelled — lit along the top edge, shaded along the +bottom, with a contact outline — and carry a sheen across their face. When +composing your own raised element, use `box-shadow: var(--nv-bevel), +var(--nv-outline), var(--nv-elevation-2)` with `background-image: +var(--nv-sheen-raised)` over its role color. + ## Type roles | Class | Known as | Size | @@ -225,10 +323,19 @@ Material and elevation are independent: combine freely. | `.nv-body` | body copy | 15px | | `.nv-label` | label, UI text | 13px | | `.nv-caption` | caption, secondary text | 12px | -| `.nv-overline` | overline, eyebrow, kicker | 12px uppercase | +| `.nv-overline` | overline, eyebrow, engraved label | 11px mono, uppercase, wide tracking | | `.nv-code` | code, monospace | 13px | | `.nv-text-primary` `.nv-text-secondary` `.nv-text-tertiary` | text color roles | Emphasis, not size. | | `.nv-text-accent` `.nv-text-success` `.nv-text-warning` `.nv-text-danger` | semantic text colors | | +| `.nv-text-lit` | gradient text, metallic text | Lit from above, settling into the accent. One display line per page. | +| `.nv-text-lit--accent` | accent gradient text | The accent alone, lit from above — for a phrase inside a display line. | + +## Reveal + +| Class | Known as | Does | +| --- | --- | --- | +| `.nv-reveal` | scroll reveal, animate on scroll | Rises and fades in once, as it scrolls into view. Needs `niveus.js`; without it, or under reduced motion, nothing is hidden. | +| `.nv-reveal-group` | staggered reveal | Its children arrive one after another, `--nv-stagger` apart. | ## Layout utilities @@ -243,7 +350,8 @@ Material and elevation are independent: combine freely. | `.nv-truncate` | ellipsis, text truncation | One line with an ellipsis. | | `.nv-prose` | prose, measure | Readable line length and leading. | | `.nv-visually-hidden` | screen-reader-only, sr-only | Available to assistive tech, invisible on screen. | -| `.nv-continuous` | cross-fading region | Content that should cross-fade in place when it changes. | +| `.nv-continuous` | content region | Content that hands over in place (out, then in) when it changes. | +| `.nv-content` | view-transition class | Not an HTML class: the `view-transition-class` that gives a named element the hand-over animation. Set `view-transition-class: nv-content` on your own named element to get it. | | `.nv-carried` | (view-transition class) | Applied to nav items so they ride above a travelling marker. | | `.nv-travelling` | (view-transition class) | Applied to a selection marker so it travels below the labels. | @@ -274,9 +382,14 @@ danger danger-hover danger-active danger-subtle danger-border danger-text on-danger shadow-ambient shadow-penumbra shadow-umbra shadow-inset -highlight highlight-strong elevation-tint-1 … -5 +highlight highlight-strong lowlight outline elevation-tint-1 … -5 +sheen-top sheen-bottom sheen-strong-top sheen-strong-bottom +knob knob-shade material-frosted material-glass material-border +window-close window-minimize window-zoom window-inactive +window-control-ring window-glyph focus-ring focus-ring-contrast selection +glow ``` `on-*` is the text that goes on top of that color, and is derived — never set @@ -292,8 +405,9 @@ it by hand. | `--nv-contrast-pivot` | `0.7` | Lightness where a color can no longer carry a label. | | `--nv-contrast-dark` / `-light` | `0.53` / `0.78` | How far either side a color is pushed. | -`--nv-palette-{neutral|accent|success|warning|danger}-{0…1000}` are raw values. -Components must never reference them. +`--nv-palette-{neutral|accent|success|warning|danger}-{0…1000}` and +`--nv-palette-window-{close|minimize|zoom}` are raw values. Components must +never reference them. ### Scales @@ -304,24 +418,28 @@ Components must never reference them. | `--nv-font-size-` | `2xs xs sm md lg xl 2xl 3xl 4xl 5xl` | | `--nv-font-weight-` | `regular medium semibold bold` | | `--nv-line-height-` | `tight snug normal relaxed` | -| `--nv-letter-spacing-` | `tighter tight normal wide wider` | -| `--nv-font-` | `display heading title body label caption code` (composite shorthands) | -| `--nv-font-family-` | `sans mono` | +| `--nv-letter-spacing-` | `tighter tight normal wide wider engraved` | +| `--nv-font-` | `display heading title body label caption code engraved` (composite shorthands) | +| `--nv-font-family-` | `sans` (Instrument Sans) `mono` (JetBrains Mono), both shipped in `fonts/` | | `--nv-measure` | `-narrow` · `--nv-measure` · `-wide` | | `--nv-control-height-` | `sm md lg` — also `control-padding-` and `control-gap-` | | `--nv-elevation-` | `0 1 2 3 4 5`, `inset-sm` `inset-md` `inset-lg` | | `--nv-highlight-` | `top` `top-strong` `edge` | +| bevel | `--nv-bevel` (lit top + shaded bottom) · `--nv-lowlight-bottom` · `--nv-outline` (contact line) · `--nv-ledge` (lit lower lip of a recess) | +| `--nv-sheen-` | `raised filled surface knob` — gradients laid over a role color as `background-image` | | `--nv-blur-` | `sm md lg xl` — also `backdrop-frosted` `backdrop-glass` `backdrop-scrim` | | `--nv-material-` | `{solid,elevated,inset,frosted,glass}-{bg,border,shadow}` | +| `--nv-glow-` | `sm md lg` (accent halos) · `blur-sm/md/lg` `spread-sm/md/lg` (geometry for a tinted glow) · `strength` (how much tint a glow carries) | ### Motion | Family | Steps | | --- | --- | -| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme` | +| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme shimmer` | | `--nv-ease-` | `standard entrance exit spring emphasized linear` | | `--nv-transition-` | `colors depth control surface expand` (composites) | | displacement | `--nv-lift-hover` `--nv-press-depth` `--nv-travel-sm/md/lg` | +| sequence | `--nv-stagger` — the gap between siblings arriving one after another | `prefers-reduced-motion` collapses every duration and displacement at the token level. Components never opt in individually. @@ -332,7 +450,9 @@ level. Components never opt in individually. `--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` · -`--nv-scroller-fade` · `--nv-content-name` +`--nv-scroller-fade` · `--nv-content-name` · `--nv-card-tint` · +`--nv-tile-tint` · `--nv-avatar-tint` · `--nv-pointer-x` / `--nv-pointer-y` +(set by `niveus.js`) · `--nv-reveal-index` (set by `niveus.js`) --- @@ -343,14 +463,14 @@ everything still renders — state changes simply arrive instead of travelling. | Function | Signature | Does | | --- | --- | --- | -| `nv.transition` | `(update, { direction, kind }) => Promise` | Runs `update` inside a view transition so the change can be followed. `direction` is `"forward"`/`"backward"`; `kind: "theme"` slows the cross-fade. Falls back to running `update` directly under reduced motion or without API support. | +| `nv.transition` | `(update, { direction, kind, scope }) => Promise` | Runs `update` inside a view transition so the change can be followed. `direction` is `"forward"`/`"backward"`; `kind: "theme"` slows the cross-fade; `scope` is the selection group (or list of groups) whose marker should travel — pass the group whose `aria-current`/`aria-selected` the update moves. Without `scope` nothing is lifted and the change cross-fades in place. Falls back to running `update` directly under reduced motion or without API support. | | `nv.setTheme` | `("light" \| "dark" \| "system") => Promise` | Cross-fades the whole surface into the new appearance and fires `nv:theme` on ``. | | `nv.syncThemeControls` | `(theme) => void` | Sets `aria-pressed` on every `[data-nv-theme]`. | | `nv.openDialog` | `(dialog \| id, trigger?) => dialog` | `showModal()`, plus a transform origin so the dialog expands out of its trigger. | | `nv.selectTab` | `(tablist, tab) => Promise` | Moves selection and its panel, with direction. | | `nv.initScroller` | `(frame) => void` | Wires one scroller's controls. | -| `nv.liftLabels` | `(scope) => void` | Names navigation items so a travelling marker passes behind them. | -| `nv.wire` | `(scope = document) => void` | Applies all of the below. Runs once on load; call again after injecting markup. | +| `nv.liftLabels` | `(scope) => Element[]` | Names the navigation items in `scope` so a travelling marker passes behind them, and returns them. `nv.transition` does this for its `scope` and removes the names afterwards. | +| `nv.wire` | `(scope = document) => void` | Applies all of the below, and starts watching `.nv-reveal` / `.nv-reveal-group`. Runs once on load; call again after injecting markup. | | `nv.prefersReducedMotion` | `() => boolean` | | ### Data attributes @@ -367,19 +487,22 @@ everything still renders — state changes simply arrive instead of travelling. | `data-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. | | `data-nv-direction` | `` | Set during a transition by `nv.transition`. | | `data-nv-transition` | `` | `"theme"` during an appearance change. | +| `data-nv-reveal` | revealed element | `pending` while waiting to scroll into view, `shown` while arriving; removed once it has. Set by `niveus.js`. | +| `data-nv-travelling` | selection group | Set by `nv.transition` on each `scope` for the length of the change; names its marker. | --- ## Rules that bite 1. **`view-transition-name` must be unique per document.** A duplicate makes - the browser skip the transition entirely. Each selection group needs its own - `--nv-selection-name`; `.nv-nav-tabs` defaults to `nv-tabs-selection` and - everything else to `nv-selection`, which is enough for one of each per page. + the browser skip the transition entirely. Markers are only named while their + group is in `scope`, so this only bites when one transition moves two groups + at once: give each its own `--nv-selection-name`. `.nv-nav-tabs` defaults + to `nv-tabs-selection` and everything else to `nv-selection`. 2. **A menu and its trigger must name the same anchor** through `--nv-anchor`, and the value must be unique per pair. -3. **Only one `.nv-tab-panel` may be visible at a time** — hide the rest with - `hidden`. They share one content name. +3. **Hide inactive `.nv-tab-panel`s with `hidden`.** `nv.selectTab` relies + on it to know which panel is leaving. 4. **Never reference `--nv-palette-*` from application code.** It is raw, and does not adapt to the appearance. 5. **A floating control positioned with `translate`** needs diff --git a/docs/Website/niveus/dist/niveus.css b/docs/Website/niveus/dist/niveus.css new file mode 100644 index 0000000..526faca --- /dev/null +++ b/docs/Website/niveus/dist/niveus.css @@ -0,0 +1,4950 @@ +/* Niveus — generated bundle. Do not edit. + Source: niveus/niveus.css and the files it imports. + Rebuild: python3 tools/build.py +*/ + +/* ========================================================================== + Niveus — a tactile, adaptive interface language. + Made by Nexoniarz. + -------------------------------------------------------------------------- + The single entry point. An application links this file and nothing else: + + + — optional, see below + — or omit data-theme to follow the system + + niveus.js is not required for anything to render. It exists only so state + changes can be *continuous*: it wraps an update in a view transition so the + browser can animate between the two states. Without it, every component + still works — changes simply arrive rather than travel. + + Everything ships inside the `niveus` cascade layer, so unlayered application + styles always win without needing !important or selector escalation. The + sub-layers are ordered below; that statement is what fixes their precedence, + independently of the order the files happen to load in. + ========================================================================== */ + +@layer niveus.tokens, niveus.theme, niveus.base, niveus.continuity, + niveus.components, niveus.utilities; + +/* --- Fonts: the faces Niveus ships, so it reads the same everywhere ---------- */ +/* ========================================================================== + Niveus — Fonts + -------------------------------------------------------------------------- + Niveus ships its own faces, so it reads the same wherever it is used + instead of depending on what a machine happens to have installed. Both are + variable fonts under the SIL Open Font License (fonts/OFL-*.txt), free to + bundle with anything. + + Instrument Sans the interface. Crisp, slightly narrow, warm at small + sizes — the voice of every label, control and heading. + JetBrains Mono code, figures, and the engraved labels (.nv-overline) + that mark sections the way markings mark an instrument. + + The paths are relative to this file and to dist/niveus.css alike: both sit + one level below the root, where fonts/ lives. + ========================================================================== */ + +@font-face { + font-family: "Instrument Sans"; + src: url("../fonts/InstrumentSans.woff2") format("woff2"); + font-weight: 400 700; + font-stretch: 75% 100%; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Instrument Sans"; + src: url("../fonts/InstrumentSans-Italic.woff2") format("woff2"); + font-weight: 400 700; + font-stretch: 75% 100%; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("../fonts/JetBrainsMono.woff2") format("woff2"); + font-weight: 100 800; + font-style: normal; + font-display: swap; +} + +/* --- Tokens: the values, appearance-agnostic ------------------------------- */ +@layer niveus.tokens { + /* ========================================================================== + Niveus — Color + -------------------------------------------------------------------------- + Two levels of tokens live here: + + 1. Palette Raw, appearance-agnostic values. Never referenced by + components. Prefixed --nv-palette-*. + + 2. Semantic The roles components actually consume (background, surface, + text-primary, accent, danger, …). Each role resolves with + light-dark(), pulling its value from the matching appearance + file: themes/light.css (--nv-light-*) or themes/dark.css + (--nv-dark-*). + + The appearance is chosen by `color-scheme`, which themes/*.css bind to the + [data-theme] attribute. A component therefore never knows — and must never + ask — which appearance is active. + ========================================================================== */ + + /* ========================================================================== + Brand + -------------------------------------------------------------------------- + The whole color system has one input. Set --nv-brand and the accent, its + hover and active states, the text that lands on it, the focus ring, the + selection highlight, the tint in the greys and the lean of the status colors + are all re-derived from it — in both appearances, with no second value to + keep in sync. + + :root { --nv-brand: #d92e3f; } + + Set it on any element with [data-nv-brand] to re-brand a subtree. + ========================================================================== */ + + :root { + --nv-brand: #4f6cff; + + /* How much of the brand bleeds into the greys. Enough that the neutrals + belong to the same family as the accent; not enough to read as colored. + 0% gives strictly neutral greys. */ + --nv-brand-tint: 4%; + + /* How far the status colors lean towards the brand. Red still reads as + danger and green still reads as success — they just stop looking borrowed + from another system. 0% keeps them literal. */ + --nv-brand-harmony: 12%; + + /* Where "too mid-toned to carry a label" begins, and how far either side a + color is pushed to escape it. Raising the pivot sends more brands dark + (white labels); lowering it sends more of them light (dark labels). */ + --nv-contrast-pivot: 0.7; + --nv-contrast-dark: 0.53; + --nv-contrast-light: 0.78; + } + + :root { + /* --- Neutral ---------------------------------------------------------- + Warm graphite to warm paper. A grey that leans very slightly towards + yellow reads as a material (metal, enamel, paper); a grey that leans blue + reads as a screen. The lean is small enough that the brand tint still + decides the family. */ + --nv-palette-neutral-0: #ffffff; + --nv-palette-neutral-25: #fcfcfb; + --nv-palette-neutral-50: #f8f8f6; + --nv-palette-neutral-100: #f2f1ee; + --nv-palette-neutral-150: #eae9e5; + --nv-palette-neutral-200: #e1e0db; + --nv-palette-neutral-300: #cfcec8; + --nv-palette-neutral-400: #aeada7; + --nv-palette-neutral-500: #87867f; + --nv-palette-neutral-600: #63625d; + --nv-palette-neutral-700: #4a4945; + --nv-palette-neutral-800: #343337; + --nv-palette-neutral-850: #2a2a2d; + --nv-palette-neutral-900: #1d1d20; + --nv-palette-neutral-925: #18181a; + --nv-palette-neutral-950: #131315; + --nv-palette-neutral-975: #0e0e10; + --nv-palette-neutral-1000: #08080a; + + /* --- Window controls ---------------------------------------------------- + The three lights at the start of a titlebar. They are the one place the + system uses fixed colors instead of the brand: their meaning is learned + from position and color together, and re-coloring them would unteach it. */ + --nv-palette-window-close: #ff5f57; + --nv-palette-window-minimize: #febc2e; + --nv-palette-window-zoom: #28c840; + + /* --- Accent (indigo) --------------------------------------------------- */ + --nv-palette-accent-50: #eef2ff; + --nv-palette-accent-100: #dfe6ff; + --nv-palette-accent-200: #c4d0ff; + --nv-palette-accent-300: #9fb3ff; + --nv-palette-accent-400: #7691ff; + --nv-palette-accent-500: #4f6cff; + --nv-palette-accent-600: #3f5cf0; + --nv-palette-accent-700: #3450d6; + --nv-palette-accent-800: #2a3fa8; + --nv-palette-accent-900: #22347f; + + /* --- Success (green) --------------------------------------------------- */ + --nv-palette-success-50: #e8f9ef; + --nv-palette-success-100: #ccf0dc; + --nv-palette-success-200: #9ee2bc; + --nv-palette-success-300: #62cd96; + --nv-palette-success-400: #33b478; + --nv-palette-success-500: #189a60; + --nv-palette-success-600: #0f7d4d; + --nv-palette-success-700: #0c6440; + --nv-palette-success-800: #0a5034; + --nv-palette-success-900: #08412b; + + /* --- Warning (amber) --------------------------------------------------- */ + --nv-palette-warning-50: #fff6e6; + --nv-palette-warning-100: #ffe9c2; + --nv-palette-warning-200: #ffd68d; + --nv-palette-warning-300: #fbbe52; + --nv-palette-warning-400: #eda520; + --nv-palette-warning-500: #cf8809; + --nv-palette-warning-600: #a96d05; + --nv-palette-warning-700: #855505; + --nv-palette-warning-800: #6a4406; + --nv-palette-warning-900: #563806; + + /* --- Danger (red) ------------------------------------------------------ */ + --nv-palette-danger-50: #ffeff1; + --nv-palette-danger-100: #ffdbde; + --nv-palette-danger-200: #ffb9bf; + --nv-palette-danger-300: #ff8b96; + --nv-palette-danger-400: #f95b6c; + --nv-palette-danger-500: #e2334a; + --nv-palette-danger-600: #c01f36; + --nv-palette-danger-700: #9c172b; + --nv-palette-danger-800: #7e1524; + --nv-palette-danger-900: #67141f; + } + + /* ========================================================================== + Semantic roles + ========================================================================== */ + + /* The subtree selector has to be repeated here, not just on the theme files: + var() inside a custom property is substituted where that property is + *declared*, so a --nv-color-* declared only on :root would bake in the root + brand and inherit the result unchanged. Re-declaring on [data-nv-brand] + re-runs the substitution against the subtree's own brand. */ + :root, + [data-nv-brand] { + /* --- Canvas and surfaces ----------------------------------------------- */ + --nv-color-background: light-dark(var(--nv-light-background), var(--nv-dark-background)); + --nv-color-background-subtle: light-dark(var(--nv-light-background-subtle), var(--nv-dark-background-subtle)); + --nv-color-surface: light-dark(var(--nv-light-surface), var(--nv-dark-surface)); + --nv-color-surface-elevated: light-dark(var(--nv-light-surface-elevated), var(--nv-dark-surface-elevated)); + --nv-color-surface-inset: light-dark(var(--nv-light-surface-inset), var(--nv-dark-surface-inset)); + --nv-color-surface-hover: light-dark(var(--nv-light-surface-hover), var(--nv-dark-surface-hover)); + --nv-color-surface-active: light-dark(var(--nv-light-surface-active), var(--nv-dark-surface-active)); + --nv-color-scrim: light-dark(var(--nv-light-scrim), var(--nv-dark-scrim)); + + /* --- Text --------------------------------------------------------------- */ + --nv-color-text-primary: light-dark(var(--nv-light-text-primary), var(--nv-dark-text-primary)); + --nv-color-text-secondary: light-dark(var(--nv-light-text-secondary), var(--nv-dark-text-secondary)); + --nv-color-text-tertiary: light-dark(var(--nv-light-text-tertiary), var(--nv-dark-text-tertiary)); + --nv-color-text-disabled: light-dark(var(--nv-light-text-disabled), var(--nv-dark-text-disabled)); + --nv-color-text-inverted: light-dark(var(--nv-light-text-inverted), var(--nv-dark-text-inverted)); + + /* --- Borders ------------------------------------------------------------ */ + --nv-color-border-subtle: light-dark(var(--nv-light-border-subtle), var(--nv-dark-border-subtle)); + --nv-color-border: light-dark(var(--nv-light-border), var(--nv-dark-border)); + --nv-color-border-strong: light-dark(var(--nv-light-border-strong), var(--nv-dark-border-strong)); + + /* --- Accent ------------------------------------------------------------- */ + --nv-color-accent: light-dark(var(--nv-light-accent), var(--nv-dark-accent)); + --nv-color-accent-hover: light-dark(var(--nv-light-accent-hover), var(--nv-dark-accent-hover)); + --nv-color-accent-active: light-dark(var(--nv-light-accent-active), var(--nv-dark-accent-active)); + --nv-color-accent-subtle: light-dark(var(--nv-light-accent-subtle), var(--nv-dark-accent-subtle)); + --nv-color-accent-subtle-hover: light-dark(var(--nv-light-accent-subtle-hover), var(--nv-dark-accent-subtle-hover)); + --nv-color-accent-border: light-dark(var(--nv-light-accent-border), var(--nv-dark-accent-border)); + --nv-color-accent-text: light-dark(var(--nv-light-accent-text), var(--nv-dark-accent-text)); + --nv-color-on-accent: light-dark(var(--nv-light-on-accent), var(--nv-dark-on-accent)); + + /* --- Success ------------------------------------------------------------ */ + --nv-color-success: light-dark(var(--nv-light-success), var(--nv-dark-success)); + --nv-color-success-hover: light-dark(var(--nv-light-success-hover), var(--nv-dark-success-hover)); + --nv-color-success-subtle: light-dark(var(--nv-light-success-subtle), var(--nv-dark-success-subtle)); + --nv-color-success-border: light-dark(var(--nv-light-success-border), var(--nv-dark-success-border)); + --nv-color-success-text: light-dark(var(--nv-light-success-text), var(--nv-dark-success-text)); + --nv-color-on-success: light-dark(var(--nv-light-on-success), var(--nv-dark-on-success)); + + /* --- Warning ------------------------------------------------------------ */ + --nv-color-warning: light-dark(var(--nv-light-warning), var(--nv-dark-warning)); + --nv-color-warning-hover: light-dark(var(--nv-light-warning-hover), var(--nv-dark-warning-hover)); + --nv-color-warning-subtle: light-dark(var(--nv-light-warning-subtle), var(--nv-dark-warning-subtle)); + --nv-color-warning-border: light-dark(var(--nv-light-warning-border), var(--nv-dark-warning-border)); + --nv-color-warning-text: light-dark(var(--nv-light-warning-text), var(--nv-dark-warning-text)); + --nv-color-on-warning: light-dark(var(--nv-light-on-warning), var(--nv-dark-on-warning)); + + /* --- Danger ------------------------------------------------------------- */ + --nv-color-danger: light-dark(var(--nv-light-danger), var(--nv-dark-danger)); + --nv-color-danger-hover: light-dark(var(--nv-light-danger-hover), var(--nv-dark-danger-hover)); + --nv-color-danger-active: light-dark(var(--nv-light-danger-active), var(--nv-dark-danger-active)); + --nv-color-danger-subtle: light-dark(var(--nv-light-danger-subtle), var(--nv-dark-danger-subtle)); + --nv-color-danger-border: light-dark(var(--nv-light-danger-border), var(--nv-dark-danger-border)); + --nv-color-danger-text: light-dark(var(--nv-light-danger-text), var(--nv-dark-danger-text)); + --nv-color-on-danger: light-dark(var(--nv-light-on-danger), var(--nv-dark-on-danger)); + + /* --- Depth and material ------------------------------------------------- */ + --nv-color-shadow-ambient: light-dark(var(--nv-light-shadow-ambient), var(--nv-dark-shadow-ambient)); + --nv-color-shadow-penumbra: light-dark(var(--nv-light-shadow-penumbra), var(--nv-dark-shadow-penumbra)); + --nv-color-shadow-umbra: light-dark(var(--nv-light-shadow-umbra), var(--nv-dark-shadow-umbra)); + --nv-color-shadow-inset: light-dark(var(--nv-light-shadow-inset), var(--nv-dark-shadow-inset)); + --nv-color-highlight: light-dark(var(--nv-light-highlight), var(--nv-dark-highlight)); + --nv-color-highlight-strong: light-dark(var(--nv-light-highlight-strong), var(--nv-dark-highlight-strong)); + + /* The bevel's other half: the shade along a raised surface's bottom edge, + and the contact line around anything that sits on another surface. Light + along the top and shade along the bottom is what makes a control read as + something pressable instead of a painted rectangle. */ + --nv-color-lowlight: light-dark(var(--nv-light-lowlight), var(--nv-dark-lowlight)); + --nv-color-outline: light-dark(var(--nv-light-outline), var(--nv-dark-outline)); + + /* Sheen: the light falling across a raised face, top to bottom. The plain + pair is for neutral controls and surfaces; the strong pair is for filled + color (primary buttons, checked marks), where it has to read through a + saturated fill. Both are laid over a surface's own color, never instead + of it. */ + --nv-color-sheen-top: light-dark(var(--nv-light-sheen-top), var(--nv-dark-sheen-top)); + --nv-color-sheen-bottom: light-dark(var(--nv-light-sheen-bottom), var(--nv-dark-sheen-bottom)); + --nv-color-sheen-strong-top: light-dark(var(--nv-light-sheen-strong-top), var(--nv-dark-sheen-strong-top)); + --nv-color-sheen-strong-bottom: light-dark(var(--nv-light-sheen-strong-bottom), var(--nv-dark-sheen-strong-bottom)); + + /* Glow: light a surface gives off, as opposed to the shade it casts. It is + the accent seen through air — strong enough in dark to read as a lit + edge, faint in light, where paper does not emit. */ + --nv-color-glow: light-dark(var(--nv-light-glow), var(--nv-dark-glow)); + + /* A knob: the thumb of a switch or a slider. It stays light in both + appearances, the way a physical knob does not change color with the room. */ + --nv-color-knob: light-dark(var(--nv-light-knob), var(--nv-dark-knob)); + --nv-color-knob-shade: light-dark(var(--nv-light-knob-shade), var(--nv-dark-knob-shade)); + + /* Elevation tint: on a dark canvas a shadow has nothing to darken, so a + raised surface also has to gain light. Transparent in light, where the + shadow does all the work. */ + --nv-color-elevation-tint-1: light-dark(var(--nv-light-elevation-tint-1), var(--nv-dark-elevation-tint-1)); + --nv-color-elevation-tint-2: light-dark(var(--nv-light-elevation-tint-2), var(--nv-dark-elevation-tint-2)); + --nv-color-elevation-tint-3: light-dark(var(--nv-light-elevation-tint-3), var(--nv-dark-elevation-tint-3)); + --nv-color-elevation-tint-4: light-dark(var(--nv-light-elevation-tint-4), var(--nv-dark-elevation-tint-4)); + --nv-color-elevation-tint-5: light-dark(var(--nv-light-elevation-tint-5), var(--nv-dark-elevation-tint-5)); + + --nv-color-material-frosted: light-dark(var(--nv-light-material-frosted), var(--nv-dark-material-frosted)); + --nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass)); + --nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border)); + + /* --- Window controls ------------------------------------------------------- */ + --nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close)); + --nv-color-window-minimize: light-dark(var(--nv-light-window-minimize), var(--nv-dark-window-minimize)); + --nv-color-window-zoom: light-dark(var(--nv-light-window-zoom), var(--nv-dark-window-zoom)); + --nv-color-window-inactive: light-dark(var(--nv-light-window-inactive), var(--nv-dark-window-inactive)); + --nv-color-window-control-ring: light-dark(var(--nv-light-window-control-ring), var(--nv-dark-window-control-ring)); + --nv-color-window-glyph: light-dark(var(--nv-light-window-glyph), var(--nv-dark-window-glyph)); + + /* --- Interaction --------------------------------------------------------- */ + --nv-color-focus-ring: light-dark(var(--nv-light-focus-ring), var(--nv-dark-focus-ring)); + --nv-color-focus-ring-contrast: light-dark(var(--nv-light-focus-ring-contrast), var(--nv-dark-focus-ring-contrast)); + --nv-color-selection: light-dark(var(--nv-light-selection), var(--nv-dark-selection)); + } +} +@layer niveus.tokens { + /* ========================================================================== + Niveus — Typography + -------------------------------------------------------------------------- + One type scale, used everywhere. Sizes are in rem so they respect the user's + browser font size; line heights are unitless so they inherit correctly. + ========================================================================== */ + + :root { + /* --- Families ----------------------------------------------------------- */ + /* Both ship with Niveus (fonts.css); the rest of each stack only covers the + moment before they load, or a context that cannot load them. */ + --nv-font-family-sans: + "Instrument Sans", "Inter var", "Inter", -apple-system, BlinkMacSystemFont, + "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --nv-font-family-mono: + "JetBrains Mono", "SF Mono", "IBM Plex Mono", ui-monospace, "Menlo", + "Consolas", monospace; + + /* --- Size scale ---------------------------------------------------------- */ + --nv-font-size-2xs: 0.6875rem; /* 11px — dense metadata, never body text */ + --nv-font-size-xs: 0.75rem; /* 12px */ + --nv-font-size-sm: 0.8125rem; /* 13px */ + --nv-font-size-md: 0.9375rem; /* 15px — default body size */ + --nv-font-size-lg: 1.0625rem; /* 17px */ + --nv-font-size-xl: 1.25rem; /* 20px */ + --nv-font-size-2xl: 1.5rem; /* 24px */ + --nv-font-size-3xl: 1.875rem; /* 30px */ + --nv-font-size-4xl: 2.375rem; /* 38px */ + --nv-font-size-5xl: 3rem; /* 48px */ + + /* --- Weight -------------------------------------------------------------- */ + --nv-font-weight-regular: 400; + --nv-font-weight-medium: 500; + --nv-font-weight-semibold: 600; + --nv-font-weight-bold: 700; + + /* --- Line height --------------------------------------------------------- */ + --nv-line-height-tight: 1.18; /* display and large headings */ + --nv-line-height-snug: 1.32; /* headings, single-line controls */ + --nv-line-height-normal: 1.5; /* body copy */ + --nv-line-height-relaxed: 1.68; /* long-form reading */ + + /* --- Letter spacing ------------------------------------------------------- + Large text needs negative tracking to stay optically even; small caps-ish + labels need positive tracking to stay readable. */ + --nv-letter-spacing-tighter: -0.022em; + --nv-letter-spacing-tight: -0.012em; + --nv-letter-spacing-normal: 0em; + --nv-letter-spacing-wide: 0.02em; + --nv-letter-spacing-wider: 0.06em; + /* Engraved labels: small mono capitals, spaced like markings on a panel. */ + --nv-letter-spacing-engraved: 0.1em; + + /* --- Composite roles ------------------------------------------------------ + Applied as classes in base.css (.nv-display, .nv-heading, …). Kept as + tokens so applications can compose their own elements from the same + values instead of guessing. */ + --nv-font-display: var(--nv-font-weight-semibold) var(--nv-font-size-4xl) / + var(--nv-line-height-tight) var(--nv-font-family-sans); + --nv-font-heading: var(--nv-font-weight-semibold) var(--nv-font-size-2xl) / + var(--nv-line-height-snug) var(--nv-font-family-sans); + --nv-font-title: var(--nv-font-weight-semibold) var(--nv-font-size-lg) / + var(--nv-line-height-snug) var(--nv-font-family-sans); + --nv-font-body: var(--nv-font-weight-regular) var(--nv-font-size-md) / + var(--nv-line-height-normal) var(--nv-font-family-sans); + --nv-font-label: var(--nv-font-weight-medium) var(--nv-font-size-sm) / + var(--nv-line-height-snug) var(--nv-font-family-sans); + --nv-font-caption: var(--nv-font-weight-regular) var(--nv-font-size-xs) / + var(--nv-line-height-normal) var(--nv-font-family-sans); + --nv-font-code: var(--nv-font-weight-regular) var(--nv-font-size-sm) / + var(--nv-line-height-normal) var(--nv-font-family-mono); + /* The engraved label: section markers, the clock, anything that names a + region rather than being read. Paired with uppercase and the engraved + tracking (see .nv-overline in base.css). */ + --nv-font-engraved: var(--nv-font-weight-medium) var(--nv-font-size-2xs) / + var(--nv-line-height-snug) var(--nv-font-family-mono); + + /* --- Measure --------------------------------------------------------------- */ + --nv-measure-narrow: 44ch; + --nv-measure: 68ch; + --nv-measure-wide: 82ch; + } +} +@layer niveus.tokens { + /* ========================================================================== + Niveus — Spacing and size + -------------------------------------------------------------------------- + A 4px rhythm. The numeric suffix is the value in quarter-steps, so + --nv-space-4 is 16px and --nv-space-6 is 24px. Anything not on the scale is + application layout, not design-system spacing. + ========================================================================== */ + + :root { + --nv-space-0: 0; + --nv-space-px: 1px; + --nv-space-0-5: 0.125rem; /* 2px */ + --nv-space-1: 0.25rem; /* 4px */ + --nv-space-1-5: 0.375rem; /* 6px */ + --nv-space-2: 0.5rem; /* 8px */ + --nv-space-2-5: 0.625rem; /* 10px */ + --nv-space-3: 0.75rem; /* 12px */ + --nv-space-4: 1rem; /* 16px */ + --nv-space-5: 1.25rem; /* 20px */ + --nv-space-6: 1.5rem; /* 24px */ + --nv-space-7: 1.75rem; /* 28px */ + --nv-space-8: 2rem; /* 32px */ + --nv-space-10: 2.5rem; /* 40px */ + --nv-space-12: 3rem; /* 48px */ + --nv-space-14: 3.5rem; /* 56px */ + --nv-space-16: 4rem; /* 64px */ + --nv-space-20: 5rem; /* 80px */ + --nv-space-24: 6rem; /* 96px */ + --nv-space-32: 8rem; /* 128px */ + + /* --- Control sizing ------------------------------------------------------- + Shared by every interactive control so a button, an input and a select + line up on the same row without per-component tuning. */ + --nv-control-height-sm: 1.75rem; /* 28px */ + --nv-control-height-md: 2.25rem; /* 36px */ + --nv-control-height-lg: 2.75rem; /* 44px */ + + --nv-control-padding-sm: var(--nv-space-2-5); + --nv-control-padding-md: var(--nv-space-3); + --nv-control-padding-lg: var(--nv-space-4); + + --nv-control-gap-sm: var(--nv-space-1-5); + --nv-control-gap-md: var(--nv-space-2); + --nv-control-gap-lg: var(--nv-space-2-5); + + /* Minimum pointer target, independent of visual control height. */ + --nv-target-min: 2.75rem; /* 44px */ + + /* --- Layout ---------------------------------------------------------------- */ + --nv-layout-gutter: var(--nv-space-6); + --nv-layout-max-width: 72rem; + --nv-border-width: 1px; + --nv-border-width-strong: 2px; + } +} +@layer niveus.tokens { + /* ========================================================================== + Niveus — Radius + -------------------------------------------------------------------------- + Corner softness carries as much of the Niveus character as shadow does. + Radii grow with the surface: controls stay modest and machined, containers + get noticeably rounder — a window or a popover is a soft object, a button + is a key — and only pills, knobs and avatars go fully round. + ========================================================================== */ + + :root { + --nv-radius-none: 0; + --nv-radius-xs: 0.25rem; /* 4px — tags, swatches, nested chips */ + --nv-radius-sm: 0.5rem; /* 8px — small controls, menu items */ + --nv-radius-md: 0.625rem; /* 10px — buttons, inputs, default control radius */ + --nv-radius-lg: 0.875rem; /* 14px — cards, panels, menus */ + --nv-radius-xl: 1.25rem; /* 20px — windows, dialogs, sheets, popovers */ + --nv-radius-2xl: 1.5rem; /* 24px — docks, hero surfaces */ + --nv-radius-full: 999rem; /* — pills, avatars, switches */ + + /* Nested radius: an inner surface inset by N should be N smaller than its + parent, or the corners read as off-center. */ + --nv-radius-nested-md: calc(var(--nv-radius-lg) - var(--nv-space-2)); + --nv-radius-nested-lg: calc(var(--nv-radius-xl) - var(--nv-space-3)); + } +} +@layer niveus.tokens { + /* ========================================================================== + Niveus — Elevation + -------------------------------------------------------------------------- + Depth is a scale, not a free choice. Each level describes one relationship + between a surface and what sits behind it: + + 0 flush painted on the canvas, no separation + 1 resting cards, list rows, static panels + 2 raised buttons, controls, anything that reacts to a pointer + 3 floating popovers, menus, dropdowns, tooltips + 4 lifted dialogs, sheets, detached panels + 5 overlay the topmost layer, used sparingly + + Every level is built from the same three shadow colors so depth stays + consistent between appearances: an ambient contact shadow that keeps the + edge crisp, a soft penumbra for the spread, and an umbra that only appears + once a surface is genuinely far from the canvas. + ========================================================================== */ + + :root { + --nv-elevation-0: none; + + --nv-elevation-1: + 0 1px 1px var(--nv-color-shadow-ambient), + 0 2px 4px -1px var(--nv-color-shadow-penumbra), + 0 6px 14px -8px var(--nv-color-shadow-umbra); + + --nv-elevation-2: + 0 1px 1px var(--nv-color-shadow-ambient), + 0 3px 6px -2px var(--nv-color-shadow-penumbra), + 0 10px 20px -10px var(--nv-color-shadow-umbra); + + --nv-elevation-3: + 0 1px 2px var(--nv-color-shadow-ambient), + 0 6px 12px -4px var(--nv-color-shadow-penumbra), + 0 18px 36px -12px var(--nv-color-shadow-umbra); + + --nv-elevation-4: + 0 1px 2px var(--nv-color-shadow-ambient), + 0 10px 20px -6px var(--nv-color-shadow-penumbra), + 0 30px 60px -16px var(--nv-color-shadow-umbra); + + --nv-elevation-5: + 0 2px 3px var(--nv-color-shadow-ambient), + 0 16px 32px -8px var(--nv-color-shadow-penumbra), + 0 44px 88px -20px var(--nv-color-shadow-umbra); + + /* --- Inset --------------------------------------------------------------- + Recessed surfaces: fields, wells, tracks, pressed controls. */ + --nv-elevation-inset-sm: inset 0 1px 2px var(--nv-color-shadow-inset); + --nv-elevation-inset-md: + inset 0 1px 1px var(--nv-color-shadow-inset), + inset 0 3px 6px -2px var(--nv-color-shadow-inset); + --nv-elevation-inset-lg: + inset 0 2px 3px var(--nv-color-shadow-inset), + inset 0 6px 12px -3px var(--nv-color-shadow-inset); + + /* --- Highlight ----------------------------------------------------------- + The thin catch of light along the top edge of a raised surface. This is + what makes Niveus read as material rather than as a rectangle with a + shadow. Always paired with an elevation, never used alone. */ + --nv-highlight-top: inset 0 1px 0 var(--nv-color-highlight); + --nv-highlight-top-strong: inset 0 1px 0 var(--nv-color-highlight-strong); + --nv-highlight-edge: + inset 0 1px 0 var(--nv-color-highlight), + inset 0 0 0 1px var(--nv-color-border-subtle); + + /* --- Bevel ----------------------------------------------------------------- + The highlight's other half. A raised face is lit along its top edge and + shaded along its bottom one; together they are the bevel, and the bevel is + what makes a Niveus control look like something a finger could press. + Raised controls and surfaces take the full bevel; the highlight alone is + for things that sit flush. */ + --nv-lowlight-bottom: inset 0 -1px 0 var(--nv-color-lowlight); + --nv-bevel: + inset 0 1px 0 var(--nv-color-highlight), + inset 0 -1px 0 var(--nv-color-lowlight); + + /* --- Outline --------------------------------------------------------------- + The contact line: a hairline just outside a raised surface, where it meets + whatever it rests on. In dark it is near-black and does most of the + separating; in light it only softens the border beside it. */ + --nv-outline: 0 0 0 1px var(--nv-color-outline); + + /* --- Ledge ----------------------------------------------------------------- + A recess catches light on its *lower* lip, the edge facing the viewer. + Paired with an inset shadow, it turns a field from a dark rectangle into + a well cut into the surface. */ + --nv-ledge: 0 1px 0 var(--nv-color-highlight); + + /* --- Focus --------------------------------------------------------------- + A contrast ring sits under the accent ring so focus stays visible on both + light and dark surfaces, and on accent-filled controls. */ + --nv-focus-ring-width: 3px; + --nv-focus-ring: + 0 0 0 1px var(--nv-color-focus-ring-contrast), + 0 0 0 calc(1px + var(--nv-focus-ring-width)) var(--nv-color-focus-ring); + + /* --- Glow ------------------------------------------------------------------ + Light a surface emits rather than the shade it casts. Glow is for things + that are *on* — a live status, the selected item in a showcase, a lit key — + and it always sits beside an elevation, never instead of one: a glowing + surface still rests on something. + + The geometry is a scale like elevation. The color is the component's: a + tinted surface glows in its own tint, mixed down to --nv-glow-strength. */ + --nv-glow-strength: 40%; + --nv-glow-blur-sm: 12px; + --nv-glow-spread-sm: -2px; + --nv-glow-blur-md: 28px; + --nv-glow-spread-md: -6px; + --nv-glow-blur-lg: 64px; + --nv-glow-spread-lg: -12px; + } + + /* The accent glows are declared on every branded subtree as well as the root: + a var() inside a custom property resolves where the property is declared, so + a root-only declaration would glow in the root brand everywhere. */ + :root, + [data-nv-brand] { + --nv-glow-sm: 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) var(--nv-color-glow); + --nv-glow-md: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) var(--nv-color-glow); + --nv-glow-lg: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-lg) var(--nv-color-glow); + } +} +@layer niveus.tokens { + /* ========================================================================== + Niveus — Motion + -------------------------------------------------------------------------- + Motion explains state and spatial relationship. It is short enough to feel + immediate and eased enough to feel physical. + + Rule of thumb: the smaller the element, the faster it moves. A button's + hover is 120ms; a dialog's entrance is 240ms. + ========================================================================== */ + + :root { + /* --- Duration ------------------------------------------------------------ */ + --nv-duration-instant: 0ms; + --nv-duration-fast: 120ms; /* hover, press, color change */ + --nv-duration-quick: 160ms; /* small elements entering */ + --nv-duration-normal: 220ms; /* popovers, menus, expansion */ + --nv-duration-slow: 320ms; /* dialogs, sheets, large surfaces */ + --nv-duration-slower: 480ms; /* full-screen transitions */ + + /* --- Easing --------------------------------------------------------------- + `standard` covers almost everything. Entrance decelerates into place, exit + accelerates away, and `spring` is reserved for confirmations — never for + anything a user triggers repeatedly. */ + --nv-ease-standard: cubic-bezier(0.2, 0.05, 0, 1); + --nv-ease-entrance: cubic-bezier(0.05, 0.7, 0.1, 1); + --nv-ease-exit: cubic-bezier(0.3, 0, 0.8, 0.15); + --nv-ease-spring: cubic-bezier(0.34, 1.4, 0.64, 1); + --nv-ease-linear: linear; + + /* --- Continuity ------------------------------------------------------------- + Durations for changes the user is meant to *follow*, not merely notice. + They are longer than an interaction response because the eye has further + to travel: emphasis crossing a navigation bar, a panel growing open, a + whole surface changing appearance. */ + --nv-duration-travel: 260ms; /* emphasis moving from one element to another */ + --nv-duration-expand: 280ms; /* content growing out of, or back into, place */ + --nv-duration-theme: 400ms; /* the entire surface changing appearance */ + + /* Leaves quickly, lands softly. The right curve for something travelling a + distance the user is meant to follow with their eye. */ + --nv-ease-emphasized: cubic-bezier(0.32, 0.04, 0.06, 1); + + /* --- Composites ------------------------------------------------------------ + `translate` and `scale` are listed rather than `transform`, because the + components animate the individual properties — a transition on `transform` + does not cover them, and the movement snaps. */ + --nv-transition-colors: + color var(--nv-duration-fast) var(--nv-ease-standard), + background-color var(--nv-duration-fast) var(--nv-ease-standard), + border-color var(--nv-duration-fast) var(--nv-ease-standard); + --nv-transition-depth: + box-shadow var(--nv-duration-fast) var(--nv-ease-standard), + translate var(--nv-duration-fast) var(--nv-ease-standard), + scale var(--nv-duration-fast) var(--nv-ease-standard); + --nv-transition-control: + color var(--nv-duration-fast) var(--nv-ease-standard), + background-color var(--nv-duration-fast) var(--nv-ease-standard), + border-color var(--nv-duration-fast) var(--nv-ease-standard), + box-shadow var(--nv-duration-fast) var(--nv-ease-standard), + translate var(--nv-duration-fast) var(--nv-ease-standard), + scale var(--nv-duration-fast) var(--nv-ease-standard); + + /* Containers: the same as depth, plus the colors, so a surface glides into + its new appearance when the theme changes instead of snapping. */ + --nv-transition-surface: + color var(--nv-duration-fast) var(--nv-ease-standard), + background-color var(--nv-duration-fast) var(--nv-ease-standard), + border-color var(--nv-duration-fast) var(--nv-ease-standard), + box-shadow var(--nv-duration-fast) var(--nv-ease-standard), + translate var(--nv-duration-fast) var(--nv-ease-standard), + scale var(--nv-duration-fast) var(--nv-ease-standard); + + --nv-transition-expand: + block-size var(--nv-duration-expand) var(--nv-ease-standard), + grid-template-rows var(--nv-duration-expand) var(--nv-ease-standard), + opacity var(--nv-duration-expand) var(--nv-ease-standard); + + /* --- Displacement ---------------------------------------------------------- + How far things move. Presses sink by 1px; surfaces enter from 8px away. */ + --nv-lift-hover: -1px; + --nv-press-depth: 1px; + --nv-travel-sm: 4px; + --nv-travel-md: 8px; + --nv-travel-lg: 16px; + + /* --- Sequence --------------------------------------------------------------- + The gap between siblings arriving one after another. Short enough that a + group reads as one movement, long enough that the eye sees an order. */ + --nv-stagger: 60ms; + + /* A loading shimmer is a slow, constant sweep — slow enough to read as + "waiting", never fast enough to read as an alert. */ + --nv-duration-shimmer: 1600ms; + } + + /* Reduced motion is a system-wide contract, not a per-component opt-in: cut + duration to near-zero and neutralise displacement, but keep transitions + alive so state changes still resolve rather than snapping mid-flight. */ + @media (prefers-reduced-motion: reduce) { + :root { + --nv-duration-fast: 1ms; + --nv-duration-quick: 1ms; + --nv-duration-normal: 1ms; + --nv-duration-slow: 1ms; + --nv-duration-slower: 1ms; + --nv-duration-travel: 1ms; + --nv-duration-expand: 1ms; + --nv-duration-theme: 1ms; + --nv-ease-spring: var(--nv-ease-standard); + --nv-lift-hover: 0px; + --nv-press-depth: 0px; + --nv-travel-sm: 0px; + --nv-travel-md: 0px; + --nv-travel-lg: 0px; + --nv-stagger: 0ms; + } + } +} +@layer niveus.tokens { + /* ========================================================================== + Niveus — Materials + -------------------------------------------------------------------------- + A material is a surface treatment: what it is made of, not where it sits. + Elevation answers "how far from the canvas"; material answers "what does + light do when it hits this". + + solid opaque, stable. The default. Content lives here. + elevated solid with depth and a top highlight. Separated from context. + inset recessed into its parent. Fields, wells, tracks. + frosted translucent, moderately blurred, bevelled. Chrome that sits + over content: bars, docks, launchers, notifications, menus. + glass more translucent, lightly blurred. Contextual layers where the + content behind should stay legible: overlays, hover cards. + + Niveus is a solid system with glass used sparingly. Windows, cards, dialogs + and everything that holds content are solid; translucency is reserved for + the chrome that floats over them. Translucency has a cost — it tints + whatever is behind it and forces the GPU to composite — and used everywhere + it stops meaning "this floats". + + Sheen is the light falling across a raised face. It is laid over a + surface's own color as background-image, so a surface keeps its role color + and gains the gradient; anything that does not draw gradients simply shows + the role color. + ========================================================================== */ + + :root { + /* --- Blur ---------------------------------------------------------------- */ + --nv-blur-sm: 12px; + --nv-blur-md: 24px; + --nv-blur-lg: 36px; + --nv-blur-xl: 56px; + + /* Backdrops lose saturation when blurred; a touch of saturate() puts the + color back so frosted surfaces do not read as gray films. Moderate on + purpose: the blur says "this floats", it is not the look itself. */ + --nv-backdrop-saturate: 180%; + --nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate)); + --nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate)); + + /* --- Sheen ------------------------------------------------------------------ + raised neutral controls: buttons, the selected tab, a titlebar + filled saturated fills: primary and danger buttons, checked marks + surface large surfaces: windows, dialogs, cards. Brightest along the + top edge, easing through the middle and settling into a faint + shade at the foot, so a panel reads as a curved, lit body + rather than as a flat sheet. + knob a round thumb: lit from above, shaded towards its rim */ + --nv-sheen-raised: linear-gradient(var(--nv-color-sheen-top), var(--nv-color-sheen-bottom)); + --nv-sheen-filled: linear-gradient(var(--nv-color-sheen-strong-top), var(--nv-color-sheen-strong-bottom)); + --nv-sheen-surface: linear-gradient(var(--nv-color-sheen-top), transparent 7rem, transparent 55%, var(--nv-color-sheen-bottom)); + --nv-sheen-knob: radial-gradient(circle at 50% 22%, var(--nv-color-highlight-strong), transparent 45%), radial-gradient(circle at 50% 30%, transparent 40%, var(--nv-color-knob-shade)); + --nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(140%); + + /* --- Material recipes ----------------------------------------------------- + Each recipe is the full set of properties that defines the material. + Applied by the .nv-material-* classes in components/surface.css. */ + --nv-material-solid-bg: var(--nv-color-surface); + --nv-material-solid-border: var(--nv-color-border); + --nv-material-solid-shadow: var(--nv-elevation-0); + + --nv-material-elevated-bg: var(--nv-color-surface-elevated); + --nv-material-elevated-border: var(--nv-color-border-subtle); + --nv-material-elevated-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + + --nv-material-inset-bg: var(--nv-color-surface-inset); + --nv-material-inset-border: var(--nv-color-border-subtle); + --nv-material-inset-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + + --nv-material-frosted-bg: var(--nv-color-material-frosted); + --nv-material-frosted-border: var(--nv-color-material-border); + --nv-material-frosted-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); + + --nv-material-glass-bg: var(--nv-color-material-glass); + --nv-material-glass-border: var(--nv-color-material-border); + --nv-material-glass-shadow: var(--nv-highlight-top-strong), var(--nv-outline), var(--nv-elevation-4); + } + + /* Where translucency cannot be honoured — reduced transparency, or a browser + without backdrop-filter — materials fall back to their solid equivalents + rather than becoming unreadable washes. */ + @media (prefers-reduced-transparency: reduce) { + :root { + --nv-material-frosted-bg: var(--nv-color-surface-elevated); + --nv-material-glass-bg: var(--nv-color-surface-elevated); + --nv-backdrop-frosted: none; + --nv-backdrop-glass: none; + --nv-backdrop-scrim: none; + } + } + + @supports not (backdrop-filter: blur(1px)) { + :root { + --nv-material-frosted-bg: var(--nv-color-surface-elevated); + --nv-material-glass-bg: var(--nv-color-surface-elevated); + } + } +} + +/* --- Themes: what those values become in each appearance -------------------- */ +@layer niveus.theme { + /* ========================================================================== + Niveus — Light appearance + -------------------------------------------------------------------------- + Values for the light side of every semantic role. Nothing here is consumed + by components directly; tokens/color.css resolves these through light-dark(). + + Almost nothing below is a literal color. Each role is *derived* from + --nv-brand, so setting one value re-colours the entire system: the accent + and its states, what text goes on top of it, how the status colors lean, and + how much of the brand bleeds into the greys. See tokens/color.css for the + inputs. + + Light Niveus reads as soft paper and enamel on a warm grey desk: surfaces + that hold content stay near-white, so documents never look yellowed; the + canvas around them is a few steps warmer and darker, so a window sits *on* + something. Raised controls are bevelled — a bright top edge, a soft shade + along the bottom, light falling across the face — and shadows are warm, + the color of shade on paper rather than of a grey filter. + ========================================================================== */ + + :root { + /* Follow the environment unless the application states an appearance. */ + color-scheme: light dark; + } + + :root[data-theme="light"] { + color-scheme: light; + } + + /* [data-nv-brand] re-derives the whole set for a subtree, so one region can + carry a different brand without a second theme. */ + :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)); + --nv-light-scrim: rgb(28 24 18 / 0.34); + + /* --- Text --------------------------------------------------------------- + Primary text stays neutral — it carries the reading contrast. The quieter + two lean towards the brand, where a tint reads as warmth rather than as a + 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-disabled: var(--nv-palette-neutral-400); + --nv-light-text-inverted: var(--nv-palette-neutral-0); + + /* --- Borders ------------------------------------------------------------ */ + --nv-light-border-subtle: rgb(28 24 18 / 0.07); + --nv-light-border: rgb(28 24 18 / 0.13); + --nv-light-border-strong: rgb(28 24 18 / 0.22); + + /* --- Accent -------------------------------------------------------------- + Every filled color is pushed away from the middle of the lightness range + before it is used. A mid-tone is the one place where neither white nor + black text reaches 4.5:1, so a brand that lands there is darkened until + white works — or, if it is already light, lightened until black does. The + brand's hue and chroma are untouched; only its lightness moves, and only + as far as it has to. */ + --nv-light-accent: oklch(from var(--nv-brand) calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); + --nv-light-accent-hover: oklch(from var(--nv-light-accent) calc(l - 0.045) c h); + --nv-light-accent-active: oklch(from var(--nv-light-accent) calc(l - 0.09) c h); + --nv-light-accent-subtle: oklch(from var(--nv-light-accent) 0.965 calc(c * 0.16) h); + --nv-light-accent-subtle-hover: oklch(from var(--nv-light-accent) 0.93 calc(c * 0.22) h); + --nv-light-accent-border: oklch(from var(--nv-light-accent) l c h / 0.32); + --nv-light-accent-text: oklch(from var(--nv-light-accent) clamp(0.3, calc(l - 0.12), 0.5) c h); + /* Text that lands on the color itself. The clamp is a step function: below + the lightness threshold it resolves to 1 and the text goes near-white, + above it to 0 and the text goes near-black — so a yellow brand gets dark + labels and a navy one gets light labels, with no second token to set. */ + --nv-light-on-accent: oklch(from var(--nv-light-accent) + calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) + calc(c * 0.2) h); + + /* --- Success ------------------------------------------------------------- */ + --nv-light-success: oklch(from + color-mix(in oklab, var(--nv-palette-success-500), var(--nv-brand) var(--nv-brand-harmony)) + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); + --nv-light-success-hover: oklch(from var(--nv-light-success) calc(l - 0.05) c h); + --nv-light-success-subtle: oklch(from var(--nv-light-success) 0.965 calc(c * 0.16) h); + --nv-light-success-border: oklch(from var(--nv-light-success) l c h / 0.3); + --nv-light-success-text: oklch(from var(--nv-light-success) clamp(0.3, calc(l - 0.1), 0.48) c h); + --nv-light-on-success: oklch(from var(--nv-light-success) + calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) + calc(c * 0.2) h); + + /* --- Warning -------------------------------------------------------------- */ + /* Amber starts above the pivot on purpose. Pushed the other way it lands as + brown, which reads as neither a warning nor a color anyone chose. */ + --nv-light-warning: oklch(from + color-mix(in oklab, var(--nv-palette-warning-400), var(--nv-brand) var(--nv-brand-harmony)) + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); + --nv-light-warning-hover: oklch(from var(--nv-light-warning) calc(l - 0.05) c h); + --nv-light-warning-subtle: oklch(from var(--nv-light-warning) 0.965 calc(c * 0.16) h); + --nv-light-warning-border: oklch(from var(--nv-light-warning) l c h / 0.32); + --nv-light-warning-text: oklch(from var(--nv-light-warning) clamp(0.3, calc(l - 0.1), 0.48) c h); + --nv-light-on-warning: oklch(from var(--nv-light-warning) + calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) + calc(c * 0.2) h); + + /* --- Danger ---------------------------------------------------------------- */ + --nv-light-danger: oklch(from + color-mix(in oklab, var(--nv-palette-danger-500), var(--nv-brand) var(--nv-brand-harmony)) + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); + --nv-light-danger-hover: oklch(from var(--nv-light-danger) calc(l - 0.05) c h); + --nv-light-danger-active: oklch(from var(--nv-light-danger) calc(l - 0.1) c h); + --nv-light-danger-subtle: oklch(from var(--nv-light-danger) 0.965 calc(c * 0.16) h); + --nv-light-danger-border: oklch(from var(--nv-light-danger) l c h / 0.3); + --nv-light-danger-text: oklch(from var(--nv-light-danger) clamp(0.3, calc(l - 0.1), 0.48) c h); + --nv-light-on-danger: oklch(from var(--nv-light-danger) + calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) + calc(c * 0.2) h); + + /* --- 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); + + /* 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-sheen-top: rgb(255 255 255 / 0.9); + --nv-light-sheen-bottom: rgb(40 32 20 / 0.07); + --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); + + --nv-light-knob: var(--nv-palette-neutral-0); + --nv-light-knob-shade: rgb(40 32 20 / 0.13); + + /* Light surfaces are already lighter than their canvas; the shadow alone + carries elevation, so there is nothing to add. */ + --nv-light-elevation-tint-1: transparent; + --nv-light-elevation-tint-2: transparent; + --nv-light-elevation-tint-3: transparent; + --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); + + /* --- Window controls --------------------------------------------------------- */ + --nv-light-window-close: var(--nv-palette-window-close); + --nv-light-window-minimize: var(--nv-palette-window-minimize); + --nv-light-window-zoom: var(--nv-palette-window-zoom); + --nv-light-window-inactive: var(--nv-palette-neutral-300); + --nv-light-window-control-ring: rgb(0 0 0 / 0.16); + --nv-light-window-glyph: rgb(0 0 0 / 0.55); + + /* --- Interaction ----------------------------------------------------------- */ + --nv-light-focus-ring: oklch(from var(--nv-light-accent) l c h / 0.4); + --nv-light-focus-ring-contrast: rgb(255 255 255 / 0.9); + --nv-light-selection: oklch(from var(--nv-light-accent) l c h / 0.2); + } +} +@layer niveus.theme { + /* ========================================================================== + Niveus — Dark appearance + -------------------------------------------------------------------------- + Values for the dark side of every semantic role, resolved through + light-dark() in tokens/color.css. Like the light appearance, almost nothing + here is a literal color — every role is derived from --nv-brand. + + Dark Niveus is warm graphite, not the light theme inverted. Surfaces get + *lighter* as they rise and each step is large enough to see; shadows are + deep and every raised thing carries a near-black contact line, because on a + dark canvas a shadow alone has nothing left to darken. The bevel is what + carries depth here — a lit top edge, a shaded bottom one — and filled color + stays saturated: a vivid accent with a light label reads as a lit key, not + as a hole. + ========================================================================== */ + + :root[data-theme="dark"] { + color-scheme: dark; + } + + :root, + [data-nv-brand] { + /* --- Canvas and surfaces ----------------------------------------------- */ + --nv-dark-background: color-mix(in oklab, #0f0f12, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-background-subtle: color-mix(in oklab, #18181b, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface: color-mix(in oklab, #1f1f22, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface-elevated: color-mix(in oklab, #2a2a2e, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface-inset: color-mix(in oklab, #111113, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface-hover: color-mix(in oklab, #313136, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface-active: color-mix(in oklab, #39393f, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-scrim: rgb(6 6 8 / 0.62); + + /* --- Text --------------------------------------------------------------- */ + --nv-dark-text-primary: #ecebe8; + --nv-dark-text-secondary: color-mix(in oklab, #a9a8a3, var(--nv-brand) 8%); + --nv-dark-text-tertiary: color-mix(in oklab, #7d7c77, var(--nv-brand) 8%); + --nv-dark-text-disabled: #58585c; + --nv-dark-text-inverted: var(--nv-palette-neutral-975); + + /* --- Borders ------------------------------------------------------------ */ + --nv-dark-border-subtle: rgb(255 255 255 / 0.06); + --nv-dark-border: rgb(255 255 255 / 0.1); + --nv-dark-border-strong: rgb(255 255 255 / 0.2); + + /* --- Accent ------------------------------------------------------------- + The same derivation as light: a fill is pushed out of the mid-tones until + its label clears 4.5:1, so the brand keeps its full saturation and a light + label. Earlier Niveus lifted the dark accent into pastel to avoid neon; + with bevel and sheen doing the shaping, a saturated fill reads as a lit + key instead, and the pastel was what made the whole system feel washed + out. Only the accent *text* (links, subtle labels) moves up the ramp, + because text on a dark canvas needs the lightness that a fill does not. */ + --nv-dark-accent: oklch(from var(--nv-brand) calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); + --nv-dark-accent-hover: oklch(from var(--nv-dark-accent) calc(l + 0.05) c h); + --nv-dark-accent-active: oklch(from var(--nv-dark-accent) calc(l + 0.09) c h); + --nv-dark-accent-subtle: oklch(from var(--nv-dark-accent) l c h / 0.2); + --nv-dark-accent-subtle-hover: oklch(from var(--nv-dark-accent) l c h / 0.28); + --nv-dark-accent-border: oklch(from var(--nv-dark-accent) l c h / 0.5); + --nv-dark-accent-text: oklch(from var(--nv-dark-accent) clamp(0.74, calc(l + 0.2), 0.9) calc(c * 0.9) h); + --nv-dark-on-accent: oklch(from var(--nv-dark-accent) + calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) + calc(c * 0.2) h); + + /* --- Success ------------------------------------------------------------ */ + --nv-dark-success: oklch(from + color-mix(in oklab, var(--nv-palette-success-500), var(--nv-brand) var(--nv-brand-harmony)) + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); + --nv-dark-success-hover: oklch(from var(--nv-dark-success) calc(l + 0.05) c h); + --nv-dark-success-subtle: oklch(from var(--nv-dark-success) l c h / 0.2); + --nv-dark-success-border: oklch(from var(--nv-dark-success) l c h / 0.35); + --nv-dark-success-text: oklch(from var(--nv-dark-success) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h); + --nv-dark-on-success: oklch(from var(--nv-dark-success) + calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) + calc(c * 0.2) h); + + /* --- Warning ------------------------------------------------------------ */ + --nv-dark-warning: oklch(from + color-mix(in oklab, var(--nv-palette-warning-400), var(--nv-brand) var(--nv-brand-harmony)) + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); + --nv-dark-warning-hover: oklch(from var(--nv-dark-warning) calc(l + 0.05) c h); + --nv-dark-warning-subtle: oklch(from var(--nv-dark-warning) l c h / 0.2); + --nv-dark-warning-border: oklch(from var(--nv-dark-warning) l c h / 0.35); + --nv-dark-warning-text: oklch(from var(--nv-dark-warning) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h); + --nv-dark-on-warning: oklch(from var(--nv-dark-warning) + calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) + calc(c * 0.2) h); + + /* --- Danger ------------------------------------------------------------- */ + --nv-dark-danger: oklch(from + color-mix(in oklab, var(--nv-palette-danger-500), var(--nv-brand) var(--nv-brand-harmony)) + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); + --nv-dark-danger-hover: oklch(from var(--nv-dark-danger) calc(l + 0.05) c h); + --nv-dark-danger-active: oklch(from var(--nv-dark-danger) calc(l + 0.09) c h); + --nv-dark-danger-subtle: oklch(from var(--nv-dark-danger) l c h / 0.2); + --nv-dark-danger-border: oklch(from var(--nv-dark-danger) l c h / 0.35); + --nv-dark-danger-text: oklch(from var(--nv-dark-danger) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h); + --nv-dark-on-danger: oklch(from var(--nv-dark-danger) + calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) + calc(c * 0.2) h); + + /* --- Depth and material -------------------------------------------------- */ + --nv-dark-shadow-ambient: rgb(0 0 0 / 0.5); + --nv-dark-shadow-penumbra: rgb(0 0 0 / 0.5); + --nv-dark-shadow-umbra: rgb(0 0 0 / 0.55); + --nv-dark-shadow-inset: rgb(0 0 0 / 0.65); + --nv-dark-highlight: rgb(255 255 255 / 0.15); + --nv-dark-highlight-strong: rgb(255 255 255 / 0.24); + + /* The contact line is what separates graphite from graphite: a raised + surface over a dark canvas has no shadow to show, but it always has an + edge. */ + --nv-dark-lowlight: rgb(0 0 0 / 0.5); + --nv-dark-outline: rgb(0 0 0 / 0.72); + + --nv-dark-sheen-top: rgb(255 255 255 / 0.09); + --nv-dark-sheen-bottom: rgb(0 0 0 / 0.18); + --nv-dark-sheen-strong-top: rgb(255 255 255 / 0.3); + --nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.2); + --nv-dark-glow: oklch(from var(--nv-dark-accent) l c h / 0.5); + + --nv-dark-knob: #f4f3ef; + --nv-dark-knob-shade: rgb(40 32 20 / 0.3); + + /* The higher a surface sits, the more light reaches it. */ + --nv-dark-elevation-tint-1: rgb(255 255 255 / 0.03); + --nv-dark-elevation-tint-2: rgb(255 255 255 / 0.05); + --nv-dark-elevation-tint-3: rgb(255 255 255 / 0.07); + --nv-dark-elevation-tint-4: rgb(255 255 255 / 0.09); + --nv-dark-elevation-tint-5: rgb(255 255 255 / 0.11); + + --nv-dark-material-frosted: color-mix(in oklab, rgb(34 34 40 / 0.5), var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-material-border: rgb(255 255 255 / 0.14); + + /* --- Window controls --------------------------------------------------------- */ + --nv-dark-window-close: var(--nv-palette-window-close); + --nv-dark-window-minimize: var(--nv-palette-window-minimize); + --nv-dark-window-zoom: var(--nv-palette-window-zoom); + --nv-dark-window-inactive: var(--nv-palette-neutral-700); + --nv-dark-window-control-ring: rgb(0 0 0 / 0.35); + --nv-dark-window-glyph: rgb(0 0 0 / 0.6); + + /* --- Interaction ---------------------------------------------------------- */ + --nv-dark-focus-ring: oklch(from var(--nv-dark-accent-text) l c h / 0.55); + --nv-dark-focus-ring-contrast: rgb(8 8 10 / 0.9); + --nv-dark-selection: oklch(from var(--nv-dark-accent) l c h / 0.4); + } +} + +/* --- Base: elements, before any component ----------------------------------- */ +@layer niveus.base { + /* ========================================================================== + Niveus — Base + -------------------------------------------------------------------------- + The minimum an application needs before any component renders: a predictable + box model, the canvas, the default type, and the system-wide focus and + selection treatment. + + This layer styles elements, never classes with intent. Anything opinionated + enough to need a name belongs in components/. + ========================================================================== */ + + *, + *::before, + *::after { + box-sizing: border-box; + } + + html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + } + + body { + margin: 0; + min-height: 100svh; + background-color: var(--nv-color-background); + /* The canvas is lit, not painted: a wash of the accent falls in from the top + corner and a fainter one pools at the opposite foot. It is what gives + translucent surfaces something to be translucent against, and keeps a + page of solid panels from reading as one flat field. */ + background-image: + radial-gradient(120% 80% at 0% 0%, color-mix(in oklab, var(--nv-color-accent) light-dark(10%, 16%), transparent), transparent 60%), + radial-gradient(90% 70% at 100% 100%, color-mix(in oklab, var(--nv-color-accent) light-dark(6%, 10%), transparent), transparent 60%); + background-attachment: fixed; + color: var(--nv-color-text-primary); + font: var(--nv-font-body); + letter-spacing: var(--nv-letter-spacing-normal); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + font-synthesis-weight: none; + + /* The canvas glides into a new appearance with everything standing on it, + rather than cutting while the surfaces above it fade. */ + transition: var(--nv-transition-colors); + } + + /* --- Rhythm --------------------------------------------------------------- + Block elements carry no outer margin of their own; spacing between them is + the layout's decision, set with gap or the spacing tokens. */ + h1, h2, h3, h4, h5, h6, + p, figure, blockquote, dl, dd, pre { + margin: 0; + } + + h1, h2, h3, h4, h5, h6 { + font-weight: var(--nv-font-weight-semibold); + letter-spacing: var(--nv-letter-spacing-tight); + line-height: var(--nv-line-height-snug); + text-wrap: balance; + } + + h1 { font-size: var(--nv-font-size-3xl); letter-spacing: var(--nv-letter-spacing-tighter); } + h2 { font-size: var(--nv-font-size-2xl); } + h3 { font-size: var(--nv-font-size-xl); } + h4 { font-size: var(--nv-font-size-lg); } + h5 { font-size: var(--nv-font-size-md); } + h6 { font-size: var(--nv-font-size-sm); } + + p { + text-wrap: pretty; + } + + small { + font-size: var(--nv-font-size-xs); + } + + code, kbd, samp, pre { + font-family: var(--nv-font-family-mono); + font-size: var(--nv-font-size-sm); + } + + /* --- Links ----------------------------------------------------------------- */ + a { + color: var(--nv-color-accent-text); + text-decoration-color: color-mix(in srgb, currentColor 35%, transparent); + text-underline-offset: 0.18em; + transition: var(--nv-transition-colors); + } + + a:hover { + text-decoration-color: currentColor; + } + + /* --- Media ----------------------------------------------------------------- */ + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + max-width: 100%; + } + + svg { + flex: none; + } + + hr { + border: 0; + border-top: var(--nv-border-width) solid var(--nv-color-border-subtle); + margin: 0; + } + + /* --- Forms ----------------------------------------------------------------- + Form controls do not inherit type by default; without this every field in + the system would silently fall back to the browser's own font. */ + button, input, optgroup, select, textarea { + font: inherit; + color: inherit; + margin: 0; + } + + textarea { + resize: vertical; + } + + :disabled { + cursor: not-allowed; + } + + /* --- Focus ----------------------------------------------------------------- + One focus treatment for the whole system. Components that need the ring to + follow their own shape override box-shadow, not outline-color. */ + :focus-visible { + outline: var(--nv-focus-ring-width) solid var(--nv-color-focus-ring); + outline-offset: 2px; + border-radius: var(--nv-radius-xs); + } + + ::selection { + background-color: var(--nv-color-selection); + color: var(--nv-color-text-primary); + } + + /* --- Scrollbars ------------------------------------------------------------ */ + * { + scrollbar-color: var(--nv-color-border-strong) transparent; + scrollbar-width: thin; + } + + /* ========================================================================== + Type roles + -------------------------------------------------------------------------- + Named roles from tokens/typography.css, available as classes so hierarchy + stays consistent even when the markup cannot use a heading element. + ========================================================================== */ + + .nv-display { + font: var(--nv-font-display); + letter-spacing: var(--nv-letter-spacing-tighter); + text-wrap: balance; + } + + .nv-heading { + font: var(--nv-font-heading); + letter-spacing: var(--nv-letter-spacing-tight); + text-wrap: balance; + } + + .nv-title { + font: var(--nv-font-title); + letter-spacing: var(--nv-letter-spacing-tight); + } + + .nv-body { + font: var(--nv-font-body); + text-wrap: pretty; + } + + .nv-label { + font: var(--nv-font-label); + } + + .nv-caption { + font: var(--nv-font-caption); + color: var(--nv-color-text-secondary); + } + + /* Engraved: small mono capitals that mark a region the way markings mark an + instrument panel. Not for anything that has to be read as a sentence. */ + .nv-overline { + font: var(--nv-font-engraved); + letter-spacing: var(--nv-letter-spacing-engraved); + text-transform: uppercase; + color: var(--nv-color-text-tertiary); + } + + .nv-code { + font: var(--nv-font-code); + } + + /* Text color roles, for when the role is about emphasis rather than size. */ + .nv-text-primary { color: var(--nv-color-text-primary); } + .nv-text-secondary { color: var(--nv-color-text-secondary); } + .nv-text-tertiary { color: var(--nv-color-text-tertiary); } + .nv-text-accent { color: var(--nv-color-accent-text); } + .nv-text-success { color: var(--nv-color-success-text); } + .nv-text-warning { color: var(--nv-color-warning-text); } + .nv-text-danger { color: var(--nv-color-danger-text); } + + /* Lit text: type cut from the same material as the surfaces — bright along + the top, warming towards the accent below, the way light falls across a + raised face. Each line is lit on its own. For one display line on a page, + not for running text. */ + .nv-text-lit { + background-image: linear-gradient( + 180deg, + var(--nv-color-text-primary) 30%, + color-mix(in oklab, var(--nv-color-accent-text) 30%, var(--nv-color-text-primary)) + ); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: var(--nv-color-accent-text); + } + + /* The accent alone, lit from above: for an accented phrase inside a display + line, where flat accent text would read as a link. */ + .nv-text-lit--accent { + background-image: linear-gradient( + 180deg, + color-mix(in oklab, var(--nv-color-accent-text) 55%, var(--nv-color-text-primary)), + var(--nv-color-accent-text) 60%, + var(--nv-color-accent) + ); + } +} + +/* --- Continuity: how one state becomes the next ----------------------------- */ +@layer niveus.continuity { + /* ========================================================================== + Niveus — Continuity + -------------------------------------------------------------------------- + Every interaction should read as a continuation of the previous state, not + as a replacement of it. When something moves, grows, collapses or changes + appearance, the change itself should be perceptible: where it came from, + where it went, and why. + + This layer holds the cross-cutting machinery that individual components + then opt into: + + · one duration and easing for everything the browser animates between + two states via a view transition + · emphasis that travels between elements instead of disappearing from one + and reappearing on another (--nv-selection-name) + · direction, so moving forward and moving back do not look identical + · appearance changes that cross-fade rather than cut + + The goal is not to animate everything. It is that nothing pops into a new + state the user could have watched it reach. + ========================================================================== */ + + :root { + /* Lets block-size: auto participate in transitions, which is what allows + content to expand from where it already is instead of appearing at full + height. Without it, every disclosure in the system would jump. */ + interpolate-size: allow-keywords; + + /* The default name for travelling emphasis. A page with more than one + selection group must give each group its own name — two visible elements + sharing a view-transition-name abort the transition entirely. */ + --nv-selection-name: nv-selection; + } + + /* --- View transitions ------------------------------------------------------- + Anything the application wraps in document.startViewTransition() inherits + the system's travel duration and easing, so a state change animates like + the rest of Niveus without the application choosing timings. */ + + ::view-transition-group(*) { + animation-duration: var(--nv-duration-travel); + animation-timing-function: var(--nv-ease-emphasized); + } + + /* The page behind a travelling element cross-fades quickly — it is context, + not the subject of the change, and a slow fade would make the whole + interface feel as though it were being replaced. */ + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: var(--nv-duration-quick); + animation-timing-function: var(--nv-ease-standard); + } + + /* --- Travelling emphasis ------------------------------------------------------- + A selection marker moves between two elements, which no single element can + transition — so it is captured and interpolated. The problem that creates is + layering: transition layers paint above the page snapshot, so a marker that + sits *behind* its label at rest would slide over the top of every label it + passed, washing the text as it went. + + The fix is to capture the labels too and order the groups explicitly. The + marker travels below; the items ride above it, exactly as they do at rest. + niveus.js names the items of the group that is changing, and only while it + changes — names have to be unique per document, which no stylesheet can + guarantee — and every other item simply stays in the page snapshot. + + Because the layering is now correct, the marker needs no trick to stay out of + the way: it travels at full opacity, like the solid object it is. */ + + ::view-transition-group(.nv-travelling) { + z-index: 0; + } + + /* The marker changes shape as it travels — a short label's pill becomes a + long one's. By default the browser scales each snapshot to the group's + width and keeps its aspect ratio, so mid-way a 40×26 pill stretched to + 80px wide is drawn 52px tall, hanging out below its strip. The marker is a + plain surface, so its snapshots fill the box instead. */ + ::view-transition-old(.nv-travelling), + ::view-transition-new(.nv-travelling) { + block-size: 100%; + } + + ::view-transition-group(.nv-carried) { + z-index: 1; + } + + /* A carried item does not move — only its color changes when selection leaves + or arrives. That resolves quickly so the label does not sit half-way between + two colors for the length of the travel. */ + ::view-transition-old(.nv-carried), + ::view-transition-new(.nv-carried) { + animation-duration: var(--nv-duration-fast); + } + + /* --- Appearance --------------------------------------------------------------- + A theme change is the one moment the entire surface is the subject, so the + root cross-fade slows down and every color arrives together. The attribute + is set for the duration of the change by nv.setTheme(). */ + + :root[data-nv-transition="theme"]::view-transition-old(root), + :root[data-nv-transition="theme"]::view-transition-new(root) { + animation-duration: var(--nv-duration-theme); + animation-timing-function: var(--nv-ease-standard); + } + + /* Without the view-transition path — no script, or a browser without the API — + surfaces still glide, because their own transitions cover color. */ + + /* --- Direction ------------------------------------------------------------------ + Moving forward and moving back should not look the same. The application + sets data-nv-direction on the root for the length of the transition; content + then leaves towards where the user came from and enters from where they are + going. Without a direction, content simply rises into place. + + Content hands over; it does not cross-fade. Two blocks of text at half + opacity on top of each other read as a rendering fault, not as motion, so + the outgoing content is gone (fast) before the incoming arrives (delayed by + that same beat). The box itself never empties for longer than a blink. + + Any element whose content changes in place takes part by carrying the + nv-content class: .nv-continuous and .nv-tab-panel both do. + + The box clips what it holds. When a tall panel gives way to a short one, + the page around it has already reflowed to the new height, so unclipped + outgoing content would spill out below its own card. */ + + ::view-transition-group(.nv-content) { + overflow: clip; + } + + ::view-transition-old(.nv-content) { + animation: nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both; + } + + ::view-transition-new(.nv-content) { + animation: + nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both, + nv-rise var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both; + } + + :root[data-nv-direction="forward"]::view-transition-old(.nv-content) { + animation: + nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both, + nv-exit-to-start var(--nv-duration-fast) var(--nv-ease-exit) both; + } + + :root[data-nv-direction="forward"]::view-transition-new(.nv-content) { + animation: + nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both, + nv-enter-from-end var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both; + } + + :root[data-nv-direction="backward"]::view-transition-old(.nv-content) { + animation: + nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both, + nv-exit-to-end var(--nv-duration-fast) var(--nv-ease-exit) both; + } + + :root[data-nv-direction="backward"]::view-transition-new(.nv-content) { + animation: + nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both, + nv-enter-from-start var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both; + } + + @keyframes nv-fade-in { + from { opacity: 0; } + } + + @keyframes nv-fade-out { + to { opacity: 0; } + } + + @keyframes nv-rise { + from { translate: 0 var(--nv-travel-sm); } + } + + @keyframes nv-enter-from-end { + from { translate: var(--nv-travel-lg) 0; } + } + + @keyframes nv-enter-from-start { + from { translate: calc(var(--nv-travel-lg) * -1) 0; } + } + + @keyframes nv-exit-to-start { + to { translate: calc(var(--nv-travel-md) * -1) 0; } + } + + @keyframes nv-exit-to-end { + to { translate: var(--nv-travel-md) 0; } + } + + /* --- Utilities --------------------------------------------------------------------- + .nv-continuous marks a region whose content should cross-fade in place when + it changes — the "same surface, different content" relationship. */ + + .nv-continuous { + view-transition-name: var(--nv-content-name, nv-content); + view-transition-class: nv-content; + } + + /* --- Reveal ------------------------------------------------------------------------ + Content arriving as it scrolls into view: it rises a short way and fades in, + once, so a long page is read in the order it was written instead of all at + once. .nv-reveal-group does the same for its children one after another, + --nv-stagger apart. + + niveus.js marks what is waiting. Without the script, and under reduced + motion, nothing is ever marked — so nothing is ever hidden. */ + + .nv-reveal[data-nv-reveal="pending"], + .nv-reveal-group > [data-nv-reveal="pending"] { + opacity: 0; + } + + .nv-reveal[data-nv-reveal="shown"], + .nv-reveal-group > [data-nv-reveal="shown"] { + animation: nv-arrive var(--nv-duration-slower) var(--nv-ease-entrance) + calc(var(--nv-reveal-index, 0) * var(--nv-stagger)) both; + } + + @keyframes nv-arrive { + from { + opacity: 0; + translate: 0 var(--nv-travel-lg); + } + } + + /* Collapsible region for markup that is not a
    . The 0fr -> 1fr grid + row is the one technique that animates to an unknown content height in every + browser, with no measurement and no script. */ + + .nv-collapse { + display: grid; + grid-template-rows: 0fr; + transition: + grid-template-rows var(--nv-duration-expand) var(--nv-ease-standard), + opacity var(--nv-duration-expand) var(--nv-ease-standard); + opacity: 0; + } + + .nv-collapse > * { + min-block-size: 0; + overflow: hidden; + } + + .nv-collapse[data-open], + .nv-collapse:not([hidden])[open] { + grid-template-rows: 1fr; + opacity: 1; + } + + /* Reduced motion does not mean "no continuity" — a state change still has to + resolve rather than be cut mid-way. The token layer already collapses every + duration to 1ms; this removes the displacement so nothing slides. */ + @media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation-duration: 1ms; + } + } +} + +/* --- Components -------------------------------------------------------------- */ +@layer niveus.components { + /* ========================================================================== + Niveus — Surface + -------------------------------------------------------------------------- + The material vocabulary, expressed as classes. Every other component is + built from these; applications use them directly for anything the system + does not ship as a named component. + + .nv-surface a container using the solid material + .nv-material-{type} apply a material to any element + .nv-elevation-{0..5} apply a depth level to any element + + A material and an elevation are independent: .nv-material-solid + .nv-elevation-3 is a legitimate combination. What is not legitimate is a + raw box-shadow that belongs to neither scale. + ========================================================================== */ + + .nv-surface { + background-color: var(--nv-color-surface); + color: var(--nv-color-text-primary); + border: var(--nv-border-width) solid var(--nv-color-border); + border-radius: var(--nv-radius-lg); + transition: var(--nv-transition-surface); + } + + /* --- Materials ------------------------------------------------------------- */ + + .nv-material-solid { + background-color: var(--nv-material-solid-bg); + border: var(--nv-border-width) solid var(--nv-material-solid-border); + box-shadow: var(--nv-material-solid-shadow); + backdrop-filter: none; + } + + .nv-material-elevated { + background-color: var(--nv-material-elevated-bg); + border: var(--nv-border-width) solid var(--nv-material-elevated-border); + box-shadow: var(--nv-material-elevated-shadow); + backdrop-filter: none; + } + + .nv-material-inset { + background-color: var(--nv-material-inset-bg); + border: var(--nv-border-width) solid var(--nv-material-inset-border); + box-shadow: var(--nv-material-inset-shadow); + backdrop-filter: none; + } + + .nv-material-frosted { + background-color: var(--nv-material-frosted-bg); + border: var(--nv-border-width) solid var(--nv-material-frosted-border); + box-shadow: var(--nv-material-frosted-shadow); + backdrop-filter: var(--nv-backdrop-frosted); + -webkit-backdrop-filter: var(--nv-backdrop-frosted); + } + + .nv-material-glass { + background-color: var(--nv-material-glass-bg); + border: var(--nv-border-width) solid var(--nv-material-glass-border); + box-shadow: var(--nv-material-glass-shadow); + backdrop-filter: var(--nv-backdrop-glass); + -webkit-backdrop-filter: var(--nv-backdrop-glass); + } + + /* --- Elevation ------------------------------------------------------------- + Elevation utilities carry three things at once, because a lifted surface is + never only a shadow: the shadow itself, the highlight along its top edge, + and — on a dark canvas, where a shadow has nothing left to darken — a tint + that lets more light reach the surface the higher it sits. The tint is + transparent in light appearance, so the two read as one scale. */ + + .nv-elevation-0 { box-shadow: var(--nv-elevation-0); } + + .nv-elevation-1 { + box-shadow: var(--nv-highlight-top), var(--nv-elevation-1); + background-image: linear-gradient(var(--nv-color-elevation-tint-1) 0 0); + } + + .nv-elevation-2 { + box-shadow: var(--nv-highlight-top), var(--nv-elevation-2); + background-image: linear-gradient(var(--nv-color-elevation-tint-2) 0 0); + } + + .nv-elevation-3 { + box-shadow: var(--nv-highlight-top), var(--nv-elevation-3); + background-image: linear-gradient(var(--nv-color-elevation-tint-3) 0 0); + } + + .nv-elevation-4 { + box-shadow: var(--nv-highlight-top), var(--nv-elevation-4); + background-image: linear-gradient(var(--nv-color-elevation-tint-4) 0 0); + } + + .nv-elevation-5 { + box-shadow: var(--nv-highlight-top-strong), var(--nv-elevation-5); + background-image: linear-gradient(var(--nv-color-elevation-tint-5) 0 0); + } + + .nv-elevation-inset-sm { box-shadow: var(--nv-elevation-inset-sm); } + .nv-elevation-inset-md { box-shadow: var(--nv-elevation-inset-md); } + .nv-elevation-inset-lg { box-shadow: var(--nv-elevation-inset-lg); } + + /* --- Divider ---------------------------------------------------------------- */ + + .nv-divider { + border: 0; + border-top: var(--nv-border-width) solid var(--nv-color-border-subtle); + margin-block: var(--nv-space-4); + } + + .nv-divider--vertical { + border-top: 0; + border-left: var(--nv-border-width) solid var(--nv-color-border-subtle); + margin-block: 0; + margin-inline: var(--nv-space-4); + align-self: stretch; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Button + -------------------------------------------------------------------------- + .nv-button secondary by default — a raised surface + .nv-button--primary the single affirmative action in a view + .nv-button--ghost low-emphasis, no material until hovered + .nv-button--danger destructive, and only when it is + .nv-button--frosted for use over imagery or frosted chrome + .nv-button--sm | --lg 36px by default + .nv-button--icon square, for a lone icon (needs a label) + .nv-button--block fills its container + + A button is a key: a bevelled face with light falling across it, a contact + line where it meets the surface, and a press that sinks it into an inset. + + Every variant is defined purely by reassigning the private --_* properties + below. Interaction — the lift on hover, the sink on press, the focus ring — + lives once in the base rule, so a new variant can never drift from the way + the rest of the system responds to a pointer. + ========================================================================== */ + + .nv-button { + /* Variant surface, overridden per modifier. */ + --_bg: var(--nv-color-surface-elevated); + --_bg-hover: var(--nv-color-surface-hover); + --_bg-active: var(--nv-color-surface-active); + --_fg: var(--nv-color-text-primary); + --_border: var(--nv-color-border); + --_sheen: var(--nv-sheen-raised); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); + --_shadow-active: var(--nv-elevation-inset-sm), var(--nv-outline); + + /* Size, overridden per size modifier. */ + --_height: var(--nv-control-height-md); + --_padding: var(--nv-control-padding-md); + --_gap: var(--nv-control-gap-md); + --_font-size: var(--nv-font-size-sm); + --_radius: var(--nv-radius-md); + + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--_gap); + + block-size: var(--_height); + padding-inline: var(--_padding); + border: var(--nv-border-width) solid var(--_border); + border-radius: var(--_radius); + + background-color: var(--_bg); + background-image: var(--_sheen); + color: var(--_fg); + box-shadow: var(--_shadow); + + font-family: var(--nv-font-family-sans); + font-size: var(--_font-size); + font-weight: var(--nv-font-weight-medium); + line-height: var(--nv-line-height-snug); + letter-spacing: var(--nv-letter-spacing-normal); + white-space: nowrap; + text-decoration: none; + + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: var(--nv-transition-control); + } + + /* A control smaller than the minimum target still gets a full-size hit area: + the pseudo-element grows outward without changing the visual size. */ + .nv-button::after { + content: ""; + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; + translate: -50% -50%; + inline-size: max(100%, var(--nv-target-min)); + block-size: max(100%, var(--nv-target-min)); + } + + @media (hover: hover) { + .nv-button:hover:not(:disabled, [aria-disabled="true"]) { + background-color: var(--_bg-hover); + box-shadow: var(--_shadow-hover); + translate: 0 var(--nv-lift-hover); + } + } + + /* Pressed, the face is pushed below the light: the sheen goes and the inset + takes over. */ + .nv-button:active:not(:disabled, [aria-disabled="true"]) { + background-color: var(--_bg-active); + background-image: none; + box-shadow: var(--_shadow-active); + translate: 0 var(--nv-press-depth); + transition-duration: var(--nv-duration-instant); + } + + .nv-button:focus-visible { + outline: none; + box-shadow: var(--_shadow), var(--nv-focus-ring); + } + + .nv-button:disabled, + .nv-button[aria-disabled="true"] { + background-color: var(--nv-color-surface-inset); + background-image: none; + color: var(--nv-color-text-disabled); + border-color: var(--nv-color-border-subtle); + box-shadow: none; + translate: none; + cursor: not-allowed; + } + + /* --- Variants --------------------------------------------------------------- */ + + .nv-button--primary { + --_bg: var(--nv-color-accent); + --_bg-hover: var(--nv-color-accent-hover); + --_bg-active: var(--nv-color-accent-active); + --_fg: var(--nv-color-on-accent); + --_border: transparent; + --_sheen: var(--nv-sheen-filled); + /* A filled key gives off a little of its own color: the glow is what makes + it read as lit rather than printed. */ + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), + 0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent); + --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), + 0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent); + font-weight: var(--nv-font-weight-semibold); + } + + .nv-button--danger { + --_bg: var(--nv-color-danger); + --_bg-hover: var(--nv-color-danger-hover); + --_bg-active: var(--nv-color-danger-active); + --_fg: var(--nv-color-on-danger); + --_border: transparent; + --_sheen: var(--nv-sheen-filled); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), + 0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent); + --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), + 0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent); + --nv-color-focus-ring: var(--nv-color-danger-border); + font-weight: var(--nv-font-weight-semibold); + } + + .nv-button--ghost { + --_bg: transparent; + --_bg-hover: var(--nv-color-surface-hover); + --_bg-active: var(--nv-color-surface-active); + --_fg: var(--nv-color-text-secondary); + --_border: transparent; + --_sheen: none; + --_shadow: none; + --_shadow-hover: none; + --_shadow-active: var(--nv-elevation-inset-sm); + } + + /* A ghost has no material to lose, so disabling it only dims the label — + it does not suddenly grow a well its enabled siblings never had. */ + .nv-button--ghost:disabled, + .nv-button--ghost[aria-disabled="true"] { + background-color: transparent; + border-color: transparent; + } + + @media (hover: hover) { + .nv-button--ghost:hover:not(:disabled, [aria-disabled="true"]) { + color: var(--nv-color-text-primary); + translate: none; /* nothing to lift — a ghost has no material to raise */ + } + } + + .nv-button--subtle { + --_bg: var(--nv-color-accent-subtle); + --_bg-hover: var(--nv-color-accent-subtle-hover); + --_bg-active: var(--nv-color-accent-subtle-hover); + --_fg: var(--nv-color-accent-text); + --_border: var(--nv-color-accent-border); + --_sheen: none; + --_shadow: none; + --_shadow-hover: var(--nv-elevation-1); + } + + .nv-button--frosted { + --_bg: var(--nv-material-glass-bg); + --_bg-hover: var(--nv-material-frosted-bg); + --_bg-active: var(--nv-material-frosted-bg); + --_border: var(--nv-material-glass-border); + --_sheen: none; + backdrop-filter: var(--nv-backdrop-glass); + -webkit-backdrop-filter: var(--nv-backdrop-glass); + } + + /* --- Sizes ------------------------------------------------------------------- */ + + .nv-button--sm { + --_height: var(--nv-control-height-sm); + --_padding: var(--nv-control-padding-sm); + --_gap: var(--nv-control-gap-sm); + --_font-size: var(--nv-font-size-xs); + --_radius: var(--nv-radius-sm); + } + + .nv-button--lg { + --_height: var(--nv-control-height-lg); + --_padding: var(--nv-control-padding-lg); + --_gap: var(--nv-control-gap-lg); + --_font-size: var(--nv-font-size-md); + } + + .nv-button--icon { + --_padding: 0; + inline-size: var(--_height); + } + + .nv-button--pill { + --_radius: var(--nv-radius-full); + } + + .nv-button--block { + display: flex; + inline-size: 100%; + } + + /* --- Floating ------------------------------------------------------------------ + A control that sits *over* content rather than in the flow: a carousel arrow, + a floating action. Two things change. + + It is already off the surface, so there is nothing to lift it from — it grows + instead. And it is almost always positioned with `translate`, which the hover + lift would overwrite, throwing the button half its own height down the page + the moment a pointer touched it. --nv-button-offset carries that positioning + through every state. */ + + .nv-button--float { + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); + --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4); + --_shadow-active: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + + translate: var(--nv-button-offset, 0 0); + } + + @media (hover: hover) { + .nv-button--float:hover:not(:disabled, [aria-disabled="true"]) { + translate: var(--nv-button-offset, 0 0); + scale: 1.06; + } + } + + .nv-button--float:active:not(:disabled, [aria-disabled="true"]) { + translate: var(--nv-button-offset, 0 0); + scale: 0.97; + } + + /* --- Parts --------------------------------------------------------------------- */ + + .nv-button__icon { + inline-size: 1em; + block-size: 1em; + font-size: 1.15em; + opacity: 0.9; + } + + /* Busy: the label stays in place to keep the button from resizing mid-action, + and the spinner replaces it. */ + .nv-button[aria-busy="true"] { + color: transparent; + pointer-events: none; + } + + .nv-button[aria-busy="true"]::before { + content: ""; + position: absolute; + inline-size: 1em; + block-size: 1em; + border: 2px solid var(--_fg); + border-block-start-color: transparent; + border-radius: var(--nv-radius-full); + animation: nv-spin var(--nv-duration-slower) var(--nv-ease-linear) infinite; + } + + @keyframes nv-spin { + to { rotate: 1turn; } + } + + /* --- Group --------------------------------------------------------------------- + Segmented controls: buttons share an edge, so only the outer corners round + and the shared border is not drawn twice. */ + + .nv-button-group { + display: inline-flex; + align-items: center; + } + + .nv-button-group > .nv-button:not(:first-child) { + border-start-start-radius: 0; + border-end-start-radius: 0; + margin-inline-start: calc(var(--nv-border-width) * -1); + } + + .nv-button-group > .nv-button:not(:last-child) { + border-start-end-radius: 0; + border-end-end-radius: 0; + } + + .nv-button-group > .nv-button:hover, + .nv-button-group > .nv-button:focus-visible { + z-index: 1; + translate: none; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Input + -------------------------------------------------------------------------- + .nv-field label + control + hint, correctly spaced + .nv-input text input, textarea and select + .nv-input-group control with a leading or trailing affix + .nv-select wrapper that supplies the chevron + .nv-checkbox / .nv-radio custom marks on the native controls + .nv-switch binary control with an inset track + + Inputs are the system's inset material: they read as wells cut into the + surface that holds them — shaded inside, lit along the lower lip — which is + what distinguishes a place to type from a thing to press. On focus the recess stays — only the ring and border change, + so the field never appears to jump forward. + ========================================================================== */ + + .nv-field { + display: flex; + flex-direction: column; + gap: var(--nv-space-1-5); + min-inline-size: 0; + } + + .nv-field__label { + font: var(--nv-font-label); + color: var(--nv-color-text-primary); + } + + .nv-field__label[data-optional]::after { + content: " " attr(data-optional); + color: var(--nv-color-text-tertiary); + font-weight: var(--nv-font-weight-regular); + } + + .nv-field__hint { + font: var(--nv-font-caption); + color: var(--nv-color-text-secondary); + } + + .nv-field__error { + display: flex; + align-items: center; + gap: var(--nv-space-1-5); + font: var(--nv-font-caption); + font-weight: var(--nv-font-weight-medium); + color: var(--nv-color-danger-text); + } + + /* --- Control ----------------------------------------------------------------- */ + + .nv-input { + --_height: var(--nv-control-height-md); + --_padding: var(--nv-control-padding-md); + --_font-size: var(--nv-font-size-md); + --_radius: var(--nv-radius-md); + --_border: var(--nv-color-border); + + inline-size: 100%; + min-inline-size: 0; + block-size: var(--_height); + padding-inline: var(--_padding); + /* A well in dark, where recess reads as shade; paper in light, where a grey + well reads as a field that has been switched off. The inset shadow and + ledge carry the recess either way. */ + background-color: light-dark(var(--nv-color-surface), var(--nv-color-surface-inset)); + color: var(--nv-color-text-primary); + border: var(--nv-border-width) solid var(--_border); + border-radius: var(--_radius); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + + font-family: var(--nv-font-family-sans); + font-size: var(--_font-size); + line-height: var(--nv-line-height-snug); + transition: var(--nv-transition-control); + appearance: none; + } + + .nv-input::placeholder { + color: var(--nv-color-text-tertiary); + } + + @media (hover: hover) { + .nv-input:hover:not(:disabled, [readonly]) { + --_border: var(--nv-color-border-strong); + } + } + + .nv-input:focus-visible, + .nv-input:focus { + outline: none; + --_border: var(--nv-color-accent); + box-shadow: var(--nv-elevation-inset-md), var(--nv-focus-ring); + } + + .nv-input[aria-invalid="true"] { + --_border: var(--nv-color-danger); + --nv-color-focus-ring: var(--nv-color-danger-border); + } + + .nv-input:disabled { + background-color: var(--nv-color-background-subtle); + color: var(--nv-color-text-disabled); + box-shadow: none; + cursor: not-allowed; + } + + .nv-input[readonly] { + background-color: transparent; + box-shadow: none; + } + + /* Multi-line controls grow, so height becomes a minimum and the block padding + has to be stated explicitly. */ + textarea.nv-input { + block-size: auto; + min-block-size: calc(var(--nv-control-height-lg) * 2); + padding-block: var(--nv-space-2-5); + line-height: var(--nv-line-height-normal); + } + + .nv-input--sm { + --_height: var(--nv-control-height-sm); + --_padding: var(--nv-control-padding-sm); + --_font-size: var(--nv-font-size-sm); + --_radius: var(--nv-radius-sm); + } + + /* A color well: the swatch fills the control, so the value is the control. */ + .nv-input--color { + inline-size: var(--nv-control-height-md); + padding: var(--nv-space-1); + cursor: pointer; + } + + .nv-input--color::-webkit-color-swatch-wrapper { + padding: 0; + } + + .nv-input--color::-webkit-color-swatch { + border: var(--nv-border-width) solid var(--nv-color-border); + border-radius: calc(var(--nv-radius-md) - var(--nv-space-1)); + } + + /* Separate rules on purpose: a selector list that names another engine's + pseudo-element is invalid as a whole, and each browser would drop both. */ + .nv-input--color::-moz-color-swatch { + border: var(--nv-border-width) solid var(--nv-color-border); + border-radius: calc(var(--nv-radius-md) - var(--nv-space-1)); + } + + .nv-input--lg { + --_height: var(--nv-control-height-lg); + --_padding: var(--nv-control-padding-lg); + --_font-size: var(--nv-font-size-lg); + } + + /* --- Affixes ------------------------------------------------------------------- +
    + @ + +
    + The affix sits inside the field's recess rather than beside it, so the group + still reads as a single surface. */ + + .nv-input-group { + position: relative; + display: flex; + align-items: center; + inline-size: 100%; + } + + .nv-input-group__affix { + position: absolute; + z-index: 1; + display: flex; + align-items: center; + padding-inline: var(--nv-control-padding-md); + color: var(--nv-color-text-tertiary); + font-size: var(--nv-font-size-sm); + pointer-events: none; + } + + .nv-input-group__affix:first-child { + inset-inline-start: 0; + } + + .nv-input-group__affix:last-child { + inset-inline-end: 0; + } + + .nv-input-group__affix:first-child ~ .nv-input { + padding-inline-start: calc(var(--nv-control-padding-md) * 2 + 1em); + } + + .nv-input:has(~ .nv-input-group__affix:last-child) { + padding-inline-end: calc(var(--nv-control-padding-md) * 2 + 1em); + } + + /* Interactive affixes — a clear button, a reveal toggle — opt back in. */ + .nv-input-group__affix > * { + pointer-events: auto; + } + + /* --- Select ---------------------------------------------------------------------- +
    */ + + .nv-select { + position: relative; + display: flex; + inline-size: 100%; + } + + .nv-select > .nv-input { + padding-inline-end: calc(var(--nv-control-padding-md) * 2 + 0.5em); + cursor: pointer; + } + + .nv-select::after { + content: ""; + position: absolute; + inset-inline-end: var(--nv-control-padding-md); + inset-block-start: 50%; + inline-size: 0.45em; + block-size: 0.45em; + margin-block-start: -0.28em; + border-inline-end: 1.5px solid var(--nv-color-text-tertiary); + border-block-end: 1.5px solid var(--nv-color-text-tertiary); + rotate: 45deg; + pointer-events: none; + } + + /* --- Checkbox and radio ------------------------------------------------------------ */ + + .nv-checkbox, + .nv-radio { + position: relative; + flex: none; + /* The marks are drawn in em; pinning the size keeps them the same whatever + the label around them is set in. */ + font-size: var(--nv-font-size-md); + inline-size: var(--nv-space-5); + block-size: var(--nv-space-5); + margin: 0; + background-color: var(--nv-color-surface-inset); + border: var(--nv-border-width) solid var(--nv-color-border-strong); + border-radius: var(--nv-radius-xs); + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + cursor: pointer; + appearance: none; + transition: var(--nv-transition-control); + } + + .nv-radio { + border-radius: var(--nv-radius-full); + } + + .nv-checkbox:checked, + .nv-radio:checked, + .nv-checkbox:indeterminate { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + border-color: transparent; + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + } + + /* The marks are always present, at zero size and zero opacity, so checking a + box grows the tick out of its centre instead of stamping it there. A mark + that only exists in the :checked state has nothing to animate from. */ + + .nv-checkbox::after, + .nv-radio::after, + .nv-checkbox::before { + content: ""; + position: absolute; + opacity: 0; + scale: 0; + transition: + opacity var(--nv-duration-fast) var(--nv-ease-standard), + scale var(--nv-duration-quick) var(--nv-ease-spring); + } + + /* The tick is drawn from two borders so it scales with the control and needs + no asset. */ + .nv-checkbox::after { + inset-block-start: 45%; + inset-inline-start: 50%; + inline-size: 0.28em; + block-size: 0.55em; + translate: -50% -50%; + rotate: 45deg; + border-inline-end: 2px solid var(--nv-color-on-accent); + border-block-end: 2px solid var(--nv-color-on-accent); + } + + .nv-checkbox:checked:not(:indeterminate)::after { + opacity: 1; + scale: 1; + } + + /* Indeterminate is its own mark rather than a variation of the tick: the two + have to be able to cross over each other when the state changes. */ + .nv-checkbox::before { + inset: 50% 25% auto; + translate: 0 -50%; + block-size: 2px; + background-color: var(--nv-color-on-accent); + border-radius: var(--nv-radius-full); + } + + .nv-checkbox:indeterminate::before { + opacity: 1; + scale: 1; + } + + .nv-radio::after { + inset: 50% auto auto 50%; + translate: -50% -50%; + inline-size: 0.4em; + block-size: 0.4em; + background-color: var(--nv-color-on-accent); + border-radius: var(--nv-radius-full); + } + + .nv-radio:checked::after { + opacity: 1; + scale: 1; + } + + .nv-checkbox:disabled, + .nv-radio:disabled { + background-color: var(--nv-color-background-subtle); + border-color: var(--nv-color-border); + box-shadow: none; + cursor: not-allowed; + } + + /* --- Switch -------------------------------------------------------------------------- + The track is a slot cut into the surface and the thumb is a knob riding in + it: the physical relationship does the explaining, so the control reads as + on or off without a label. The slot stays recessed when it fills with color + — switching on lights the slot, it does not raise it. */ + + .nv-switch { + --_track-inline: calc(var(--nv-space-6) * 1.5); + --_track-block: var(--nv-space-6); + --_thumb: calc(var(--_track-block) - 6px); + --_inset: 2px; + --_travel: calc( + var(--_track-inline) - var(--_thumb) - var(--_inset) * 2 - + var(--nv-border-width) * 2 + ); + + position: relative; + flex: none; + inline-size: var(--_track-inline); + block-size: var(--_track-block); + margin: 0; + background-color: var(--nv-color-surface-inset); + border: var(--nv-border-width) solid var(--nv-color-border); + border-radius: var(--nv-radius-full); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + cursor: pointer; + appearance: none; + transition: var(--nv-transition-control); + } + + .nv-switch::after { + content: ""; + position: absolute; + inset-block-start: 50%; + inset-inline-start: var(--_inset); + inline-size: var(--_thumb); + block-size: var(--_thumb); + translate: 0 -50%; + background-color: var(--nv-color-knob); + background-image: var(--nv-sheen-knob); + border-radius: var(--nv-radius-full); + box-shadow: var(--nv-outline), var(--nv-elevation-2); + transition: + translate var(--nv-duration-travel) var(--nv-ease-emphasized), + background-color var(--nv-duration-fast) var(--nv-ease-standard); + } + + .nv-switch:checked { + background-color: var(--nv-color-accent); + border-color: transparent; + box-shadow: var(--nv-elevation-inset-md), var(--nv-outline); + } + + .nv-switch:checked::after { + translate: var(--_travel) -50%; + } + + .nv-switch:disabled { + cursor: not-allowed; + opacity: 0.55; + } + + /* --- Inline control + label --------------------------------------------------------- */ + + .nv-choice { + display: flex; + align-items: center; + gap: var(--nv-space-2-5); + /* Labelled at control size, so a row of switches and checkboxes sits level + with the buttons and fields beside it. */ + font: var(--nv-font-body); + font-size: var(--nv-font-size-sm); + cursor: pointer; + } + + .nv-choice:has(:disabled) { + color: var(--nv-color-text-disabled); + cursor: not-allowed; + } + + /* translate has no logical equivalent, so the thumb's direction is flipped + explicitly rather than travelling the wrong way in RTL. */ + .nv-switch:checked:dir(rtl)::after { + translate: calc(var(--_travel) * -1) -50%; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Card + -------------------------------------------------------------------------- + .nv-card resting surface, elevation 1 + .nv-card--elevated separated from its context, elevation 2 + .nv-card--inset recessed into its parent + .nv-card--frosted over imagery or scrolling content + .nv-card--interactive the whole card is one target + .nv-card--lit lit along its top edge in its tint + .nv-card--glow gives off its tint — the one card that is on + .nv-card--spotlight catches a light that follows the pointer + .nv-card__header / __body / __footer / __media + + A card is a container, not a look. If a card needs a shadow that is not on + the elevation scale, the answer is a different elevation — not a different + shadow. + ========================================================================== */ + + .nv-card { + /* Content surfaces are mostly solid: enough of the canvas comes through to + tie a card to the light around it, never enough to compete with what it + holds. The blur keeps anything busy behind it (a bed, an image) from + reading through as texture. */ + --_bg: color-mix(in oklab, var(--nv-color-surface) 90%, transparent); + --_border: var(--nv-color-border-subtle); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + --_sheen: var(--nv-sheen-surface); + --_padding: var(--nv-space-5); + /* Light layers the variants switch on. Kept separate from --_shadow so a + lit or glowing card can still be elevated, floating or interactive. */ + --_lit: 0 0 transparent; + --_glow: 0 0 transparent; + --_spot: linear-gradient(transparent, transparent); + + position: relative; + display: flex; + flex-direction: column; + min-inline-size: 0; + + background-color: var(--_bg); + background-image: var(--_spot), var(--_sheen); + color: var(--nv-color-text-primary); + border: var(--nv-border-width) solid var(--_border); + border-radius: var(--nv-radius-lg); + box-shadow: var(--_lit), var(--_shadow), var(--_glow); + backdrop-filter: var(--nv-backdrop-glass); + -webkit-backdrop-filter: var(--nv-backdrop-glass); + + /* Children are clipped to the corner radius so media and inset regions + cannot square off the card. */ + overflow: hidden; + transition: var(--nv-transition-surface); + } + + .nv-card--elevated { + --_bg: color-mix(in oklab, var(--nv-color-surface-elevated) 90%, transparent); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + } + + .nv-card--floating { + --_bg: color-mix(in oklab, var(--nv-color-surface-elevated) 88%, transparent); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); + } + + .nv-card--inset { + --_bg: color-mix(in oklab, var(--nv-color-surface-inset) 85%, transparent); + --_shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + --_sheen: none; + --_border: var(--nv-color-border-subtle); + } + + .nv-card--frosted { + --_bg: var(--nv-material-frosted-bg); + --_border: var(--nv-material-frosted-border); + --_shadow: var(--nv-material-frosted-shadow); + --_sheen: none; + backdrop-filter: var(--nv-backdrop-frosted); + -webkit-backdrop-filter: var(--nv-backdrop-frosted); + } + + .nv-card--flush { + --_padding: 0; + } + + /* --- Interactive ------------------------------------------------------------ + Rises on hover and sinks on press, exactly like a button — one physical + language for anything that responds to a pointer. */ + + .nv-card--interactive { + cursor: pointer; + text-align: start; + text-decoration: none; + } + + @media (hover: hover) { + .nv-card--interactive:hover { + box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), var(--_glow); + translate: 0 calc(var(--nv-lift-hover) * 2); + } + } + + .nv-card--interactive:active { + box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + translate: 0 var(--nv-press-depth); + transition-duration: var(--nv-duration-instant); + } + + .nv-card--interactive:focus-visible { + outline: none; + box-shadow: var(--_lit), var(--_shadow), var(--_glow), var(--nv-focus-ring); + } + + /* --- Light ------------------------------------------------------------------ + Three ways a card can carry light, each stated once and combinable with + every elevation above. They all take the card's tint — the accent unless + --nv-card-tint names another color, the way --nv-bed-tint does for a bed. + + Lit: a strip of light along the top edge, spilling a little way down + the face. For a card that belongs to something with a color: a + project, a status, a category. + Glow: the card gives off its tint. For the one card that is on — the + current plan, the live item. Two glowing cards side by side + cancel each other out. + Spotlight: a soft light follows the pointer across the face (niveus.js + tracks it). A showcase effect; a list of work items does not + need one. */ + + .nv-card--lit, + .nv-card--glow, + .nv-card--spotlight { + --nv-card-tint: var(--nv-color-accent); + } + + .nv-card--lit { + --_lit: + inset 0 2px 0 color-mix(in oklab, var(--nv-card-tint) 85%, transparent), + inset 0 18px 28px -22px color-mix(in oklab, var(--nv-card-tint) 70%, transparent); + --_sheen: + radial-gradient( + 120% 70% at 0% 0%, + color-mix(in oklab, var(--nv-card-tint) 12%, transparent), + transparent 70% + ), + var(--nv-sheen-surface); + } + + .nv-card--glow { + --_border: color-mix(in oklab, var(--nv-card-tint) 40%, var(--nv-color-border-subtle)); + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) + color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent); + } + + @media (hover: hover) { + .nv-card--glow.nv-card--interactive:hover { + --_glow: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-md) + color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent); + } + + .nv-card--spotlight:hover { + --_spot: radial-gradient( + 22rem circle at var(--nv-pointer-x, 50%) var(--nv-pointer-y, 0%), + color-mix(in oklab, var(--nv-card-tint) 14%, var(--nv-color-sheen-top)), + transparent 70% + ); + } + } + + /* Cards whose whole area is clickable via a link inside: the link's target is + stretched over the card, so the markup stays semantic and only the link text + is announced. */ + .nv-card__link::after { + content: ""; + position: absolute; + inset: 0; + } + + /* --- Parts ------------------------------------------------------------------- */ + + .nv-card__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--nv-space-4); + padding: var(--_padding); + padding-block-end: var(--nv-space-3); + } + + .nv-card__title { + font: var(--nv-font-title); + letter-spacing: var(--nv-letter-spacing-tight); + margin: 0; + } + + .nv-card__subtitle { + font: var(--nv-font-caption); + color: var(--nv-color-text-secondary); + margin-block-start: var(--nv-space-1); + } + + .nv-card__body { + flex: 1; + padding: var(--_padding); + color: var(--nv-color-text-secondary); + font: var(--nv-font-body); + } + + /* A header already opened the block, so the body should not open it again. */ + .nv-card__header + .nv-card__body { + padding-block-start: 0; + } + + .nv-card__footer { + display: flex; + align-items: center; + gap: var(--nv-space-2); + padding: var(--_padding); + padding-block-start: var(--nv-space-3); + border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle); + margin-block-start: auto; + } + + .nv-card__footer--plain { + border-block-start: 0; + padding-block-start: 0; + } + + .nv-card__media { + display: block; + inline-size: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + background-color: var(--nv-color-surface-inset); + } + + /* --- Group --------------------------------------------------------------------- + Equal-height cards on one rhythm, collapsing to a single column without a + media query. */ + + .nv-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr)); + gap: var(--nv-space-4); + align-items: stretch; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Dialog + -------------------------------------------------------------------------- + Built on the native element, so focus trapping, the top layer, + Escape-to-close and inertness come from the platform rather than from + script. + + … opened with .showModal() + .nv-dialog--sheet enters from the block end + .nv-dialog--wide | --compact width + .nv-dialog__header / __body / __footer / __close + + The dialog is the system's highest material: elevation 4 over a blurred + scrim. It enters by rising and settling, and leaves the way it came — the + motion is what tells the user the surface was always there, above the page. + ========================================================================== */ + + .nv-dialog { + --_width: 30rem; + --_padding: var(--nv-space-6); + + inline-size: min(var(--_width), calc(100vw - var(--nv-space-8))); + max-block-size: min(42rem, calc(100svh - var(--nv-space-12))); + padding: 0; + + /* The dialog sits over a dimmed, blurred page, so it can afford to let a + little of it through: it reads as a pane lifted off the page rather than + a card pasted over it. */ + background-color: color-mix(in oklab, var(--nv-color-surface-elevated) 86%, transparent); + background-image: var(--nv-sheen-surface); + backdrop-filter: var(--nv-backdrop-frosted); + -webkit-backdrop-filter: var(--nv-backdrop-frosted); + color: var(--nv-color-text-primary); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-xl); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4); + + overflow: hidden; + + /* Closed state. [open] below is the resting state; @starting-style replays + these values for the entrance. */ + /* A dialog should look as though it came from the control that opened it. + nv.openDialog() sets --nv-origin to that control's position relative to + the dialog, so the entrance scale expands away from the trigger rather + than from the middle of the screen. Centred when nothing sets it. */ + transform-origin: var(--nv-origin, 50% 50%); + + opacity: 0; + scale: 0.97; + translate: 0 var(--nv-travel-md); + + transition: + opacity var(--nv-duration-normal) var(--nv-ease-standard), + scale var(--nv-duration-normal) var(--nv-ease-standard), + translate var(--nv-duration-normal) var(--nv-ease-standard), + display var(--nv-duration-normal) allow-discrete, + overlay var(--nv-duration-normal) allow-discrete; + } + + .nv-dialog[open] { + opacity: 1; + scale: 1; + translate: 0; + transition-timing-function: var(--nv-ease-entrance); + } + + @starting-style { + .nv-dialog[open] { + opacity: 0; + scale: 0.97; + translate: 0 var(--nv-travel-md); + } + } + + .nv-dialog::backdrop { + background-color: var(--nv-color-scrim); + backdrop-filter: var(--nv-backdrop-scrim); + -webkit-backdrop-filter: var(--nv-backdrop-scrim); + opacity: 0; + transition: + opacity var(--nv-duration-normal) var(--nv-ease-standard), + display var(--nv-duration-normal) allow-discrete, + overlay var(--nv-duration-normal) allow-discrete; + } + + .nv-dialog[open]::backdrop { + opacity: 1; + } + + @starting-style { + .nv-dialog[open]::backdrop { + opacity: 0; + } + } + + /* showModal() does not stop the page behind from scrolling. */ + html:has(.nv-dialog:modal) { + overflow: hidden; + } + + /* --- Widths -------------------------------------------------------------------- */ + + .nv-dialog--compact { --_width: 24rem; } + .nv-dialog--wide { --_width: 44rem; } + + /* --- Sheet ---------------------------------------------------------------------- + Anchored to the block end and full width. On small screens this is usually + the right form for anything with more than two actions. */ + + .nv-dialog--sheet { + --_width: 34rem; + + /* Flush to the viewport edges — a sheet with side gaps and square bottom + corners reads as a mistake rather than as a sheet. */ + inline-size: min(var(--_width), 100vw); + max-block-size: min(46rem, calc(100svh - var(--nv-space-10))); + margin-block-end: 0; + margin-block-start: auto; + border-start-start-radius: var(--nv-radius-xl); + border-start-end-radius: var(--nv-radius-xl); + border-end-start-radius: 0; + border-end-end-radius: 0; + scale: 1; + translate: 0 var(--nv-travel-lg); + } + + @starting-style { + .nv-dialog--sheet[open] { + scale: 1; + translate: 0 var(--nv-travel-lg); + } + } + + /* --- Parts ------------------------------------------------------------------------ */ + + .nv-dialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--nv-space-4); + padding: var(--_padding); + padding-block-end: var(--nv-space-3); + } + + .nv-dialog__title { + font: var(--nv-font-heading); + font-size: var(--nv-font-size-xl); + letter-spacing: var(--nv-letter-spacing-tight); + margin: 0; + } + + .nv-dialog__description { + font: var(--nv-font-body); + color: var(--nv-color-text-secondary); + margin-block-start: var(--nv-space-2); + } + + .nv-dialog__body { + padding: var(--_padding); + padding-block: var(--nv-space-2); + overflow-y: auto; + overscroll-behavior: contain; + color: var(--nv-color-text-secondary); + } + + .nv-dialog__footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--nv-space-2); + padding: var(--_padding); + padding-block-start: var(--nv-space-4); + } + + /* A footer over scrollable content needs its own ground: the inset tint and + hairline tell the user there is more above it. */ + .nv-dialog__footer--divided { + background-color: var(--nv-color-background-subtle); + border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle); + } + + .nv-dialog__close { + flex: none; + margin: calc(var(--nv-space-1) * -1); + } + + /* A dialog laid out as a column keeps its header and footer fixed while only + the body scrolls. */ + .nv-dialog:has(.nv-dialog__body) { + display: flex; + flex-direction: column; + } + + .nv-dialog:not([open]) { + display: none; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Navigation + -------------------------------------------------------------------------- + .nv-nav horizontal bar, frosted, sticky by default + .nv-nav--vertical sidebar + .nv-nav--plain solid instead of frosted + .nv-nav__brand / __list / __item / __section / __spacer + .nv-nav-tabs in-page tab strip with a sliding indicator + + Navigation is chrome: it sits above content rather than in it. That is why + the bar is frosted and bevelled — the blur is not decoration, it is the + signal that content is passing underneath. A sidebar that scrolls with the page is not + chrome, so .nv-nav--vertical drops the translucency by default. + ========================================================================== */ + + .nv-nav { + position: sticky; + inset-block-start: 0; + z-index: 10; + + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--nv-space-2) var(--nv-space-4); + padding: var(--nv-space-2) var(--nv-space-4); + min-block-size: var(--nv-control-height-lg); + background-color: var(--nv-material-frosted-bg); + background-image: var(--nv-sheen-raised); + border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle); + + /* The travelling selection marker sits behind its item at z-index -1; the + bar has to be its own stacking context or the marker would fall behind + the bar's background and disappear. */ + isolation: isolate; + box-shadow: var(--nv-bevel); + backdrop-filter: var(--nv-backdrop-frosted); + -webkit-backdrop-filter: var(--nv-backdrop-frosted); + transition: var(--nv-transition-surface); + } + + /* The bar only earns a shadow once something is behind it. Set data-scrolled + from the application on scroll. */ + .nv-nav[data-scrolled] { + box-shadow: var(--nv-bevel), var(--nv-elevation-2); + transition: box-shadow var(--nv-duration-normal) var(--nv-ease-standard); + } + + .nv-nav--plain { + background-color: var(--nv-color-surface); + background-image: var(--nv-sheen-raised); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .nv-nav--static { + position: static; + } + + /* A navigation group used inside content rather than as page chrome: no bar, + no border, no blur — just the items and their travelling selection. */ + .nv-nav--inline { + position: static; + display: inline-flex; + min-block-size: 0; + padding: var(--nv-space-1); + + background-color: transparent; + border-block-end: 0; + background-image: none; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .nv-nav__brand { + display: flex; + align-items: center; + gap: var(--nv-space-2); + font: var(--nv-font-title); + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); + text-decoration: none; + padding-inline: var(--nv-space-1); + } + + .nv-nav__list { + display: flex; + align-items: center; + gap: var(--nv-space-1); + margin: 0; + padding: 0; + list-style: none; + min-inline-size: 0; + /* On a narrow screen a long list scrolls inside the bar instead of pushing + the page sideways. */ + max-inline-size: 100%; + overflow-x: auto; + scrollbar-width: none; + } + + .nv-nav__list::-webkit-scrollbar { + display: none; + } + + .nv-nav__spacer { + flex: 1; + } + + /* --- Item -------------------------------------------------------------------- + Items are flat until they are touched. The active item is the only one with + material, which keeps the bar quiet while still answering "where am I". */ + + .nv-nav__item { + /* Rides above a travelling marker once niveus.js has given it a name. */ + view-transition-class: nv-carried; + + position: relative; + display: flex; + align-items: center; + gap: var(--nv-space-2); + padding: var(--nv-space-2) var(--nv-space-3); + min-block-size: var(--nv-control-height-md); + + border: var(--nv-border-width) solid transparent; + border-radius: var(--nv-radius-md); + background-color: transparent; + + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: var(--nv-transition-control); + } + + @media (hover: hover) { + .nv-nav__item:hover { + background-color: var(--nv-color-surface-hover); + color: var(--nv-color-text-primary); + } + } + + .nv-nav__item:active { + background-color: var(--nv-color-surface-active); + box-shadow: var(--nv-elevation-inset-sm); + transition-duration: var(--nv-duration-instant); + } + + .nv-nav__item:focus-visible { + outline: none; + box-shadow: var(--nv-focus-ring); + } + + /* Selection is a material that *moves*. The emphasis lives in a pseudo-element + rather than on the item itself, and carries a view-transition-name: when the + selection changes inside a transition, the browser interpolates the marker's + old box into its new one, so the highlight slides from the previous item to + the next instead of blinking out and in somewhere else. + + Only one item in a group may be selected at a time — that is what keeps the + name unique. A page with several selection groups must give each its own + --nv-selection-name, or the duplicate name aborts the transition. */ + + /* Selection changes color and gains material — but not weight. Bolding the + selected item re-measures its text, which reflows every item beside it on + each change, and makes the old and new labels cross-fade against each other + at different widths. The pill is the cue; the weight was costing a flicker + for nothing. */ + .nv-nav__item[aria-current], + .nv-nav__item--active, + .nv-nav__item[aria-selected="true"] { + color: var(--nv-color-text-primary); + } + + .nv-nav__item[aria-current]::before, + .nv-nav__item--active::before, + .nv-nav__item[aria-selected="true"]::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: inherit; + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + view-transition-class: nv-travelling; + } + + /* The marker is named only while its own group is changing (niveus.js sets + data-nv-travelling for the length of the transition). A permanent name + would lift every marker on the page out of the page snapshot on every + transition, theme changes included. */ + [data-nv-travelling] .nv-nav__item[aria-current]::before, + [data-nv-travelling] .nv-nav__item--active::before, + [data-nv-travelling] .nv-nav__item[aria-selected="true"]::before { + view-transition-name: var(--nv-selection-name); + } + + /* Hovering an already-selected item should not restate the emphasis. */ + @media (hover: hover) { + .nv-nav__item[aria-current]:hover, + .nv-nav__item--active:hover, + .nv-nav__item[aria-selected="true"]:hover { + background-color: transparent; + } + } + + .nv-nav__item[aria-disabled="true"] { + color: var(--nv-color-text-disabled); + pointer-events: none; + } + + .nv-nav__icon { + inline-size: 1.15em; + block-size: 1.15em; + flex: none; + opacity: 0.85; + } + + .nv-nav__badge { + margin-inline-start: auto; + padding-inline: var(--nv-space-1-5); + min-inline-size: var(--nv-space-5); + block-size: var(--nv-space-5); + display: inline-flex; + align-items: center; + justify-content: center; + + background-color: var(--nv-color-accent-subtle); + color: var(--nv-color-accent-text); + border-radius: var(--nv-radius-full); + font: var(--nv-font-caption); + font-size: var(--nv-font-size-2xs); + font-weight: var(--nv-font-weight-semibold); + } + + /* --- Vertical ------------------------------------------------------------------ */ + + .nv-nav--vertical { + position: sticky; + inset-block-start: 0; + + flex-direction: column; + align-items: stretch; + gap: var(--nv-space-1); + inline-size: 15rem; + block-size: 100svh; + flex-wrap: nowrap; + padding: var(--nv-space-4) var(--nv-space-3); + /* A sidebar is a pane of the canvas rather than chrome over content, so it + stays mostly opaque — but it lets the light of the canvas through, which + is what stops it reading as a grey slab beside the page. */ + background-color: color-mix(in oklab, var(--nv-color-background-subtle) 72%, transparent); + background-image: var(--nv-sheen-surface); + backdrop-filter: var(--nv-backdrop-frosted); + -webkit-backdrop-filter: var(--nv-backdrop-frosted); + border-block-end: 0; + border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle); + box-shadow: none; + overflow-y: auto; + } + + .nv-nav--vertical .nv-nav__list { + flex-direction: column; + align-items: stretch; + gap: var(--nv-space-0-5); + } + + .nv-nav--vertical .nv-nav__item { + justify-content: flex-start; + } + + .nv-nav--vertical .nv-nav__brand { + padding: var(--nv-space-2) var(--nv-space-3); + margin-block-end: var(--nv-space-3); + } + + /* Engraved, like .nv-overline: a section marks a region, it is not read. */ + .nv-nav__section { + padding: var(--nv-space-4) var(--nv-space-3) var(--nv-space-1-5); + font: var(--nv-font-engraved); + letter-spacing: var(--nv-letter-spacing-engraved); + text-transform: uppercase; + color: var(--nv-color-text-tertiary); + } + + .nv-nav__section:first-child { + padding-block-start: 0; + } + + /* --- Tabs ------------------------------------------------------------------------ + For switching views inside a page, not for moving between pages. The strip + itself is inset, so the selected tab reads as lifted out of the track. */ + + .nv-nav-tabs { + display: inline-flex; + align-items: center; + max-inline-size: 100%; + gap: var(--nv-space-0-5); + padding: var(--nv-space-1); + background-color: color-mix(in oklab, var(--nv-color-surface-inset) 80%, transparent); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-md); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + overflow-x: auto; + scrollbar-width: none; + isolation: isolate; + } + + .nv-nav-tabs::-webkit-scrollbar { + display: none; + } + + .nv-nav-tabs .nv-nav__item { + border-radius: var(--nv-radius-sm); + min-block-size: var(--nv-control-height-sm); + padding-block: var(--nv-space-1); + } + + /* Tabs are a selection group of their own, so they get their own marker name; + sharing the navigation bar's would mean two visible elements with the same + view-transition-name. */ + .nv-nav-tabs { + --nv-selection-name: nv-tabs-selection; + } + + .nv-nav-tabs .nv-nav__item[aria-selected="true"]::before { + border-radius: var(--nv-radius-sm); + } + + /* --- Panels ---------------------------------------------------------------------- + Switching tabs should feel like moving through one surface rather than + swapping the interface. nv.selectTab() gives the outgoing and incoming + panels one shared name for the length of the change, so they hand over + inside the same box — and slide with the direction of travel. The class is + what picks up the content animation in continuity.css. */ + .nv-tab-panel { + view-transition-class: nv-content; + } + + /* --- Breadcrumbs -------------------------------------------------------------------- */ + + .nv-breadcrumbs { + display: flex; + align-items: center; + gap: var(--nv-space-2); + margin: 0; + padding: 0; + list-style: none; + font: var(--nv-font-caption); + color: var(--nv-color-text-tertiary); + } + + .nv-breadcrumbs li + li::before { + content: "/"; + margin-inline-end: var(--nv-space-2); + color: var(--nv-color-border-strong); + } + + .nv-breadcrumbs a { + color: var(--nv-color-text-secondary); + text-decoration: none; + } + + .nv-breadcrumbs a:hover { + color: var(--nv-color-text-primary); + } + + .nv-breadcrumbs [aria-current] { + color: var(--nv-color-text-primary); + font-weight: var(--nv-font-weight-medium); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Menu + -------------------------------------------------------------------------- + + +
    + +
    + +
    + + A menu has a spatial relationship with the control that opened it: anchor + positioning ties it to the trigger, and it scales out of the trigger's edge + rather than appearing somewhere unrelated. Both sides name the same anchor + through --nv-anchor, so the pair stays obvious in the markup. + + Light dismiss, Escape, and top-layer stacking come from the popover + attribute — none of it is scripted. + ========================================================================== */ + + .nv-menu-trigger { + anchor-name: var(--nv-anchor); + } + + .nv-menu { + position: fixed; + inset: auto; + margin: 0; + padding: var(--nv-space-1); + + min-inline-size: 12rem; + max-inline-size: min(20rem, calc(100vw - var(--nv-space-8))); + max-block-size: min(24rem, calc(100svh - var(--nv-space-12))); + overflow-y: auto; + overscroll-behavior: contain; + + /* Below the trigger, aligned to its inline-start edge, flipping to whichever + side has room. */ + position-anchor: var(--nv-anchor); + position-area: block-end span-inline-end; + position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline; + margin-block-start: var(--nv-space-2); + + background-color: var(--nv-material-frosted-bg); + background-image: var(--nv-sheen-surface); + border: var(--nv-border-width) solid var(--nv-material-frosted-border); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-material-frosted-shadow); + backdrop-filter: var(--nv-backdrop-frosted); + -webkit-backdrop-filter: var(--nv-backdrop-frosted); + color: var(--nv-color-text-primary); + + /* Grows from the edge nearest the trigger. */ + transform-origin: center block-start; + + opacity: 0; + scale: 0.96; + translate: 0 calc(var(--nv-travel-sm) * -1); + transition: + opacity var(--nv-duration-quick) var(--nv-ease-standard), + scale var(--nv-duration-quick) var(--nv-ease-entrance), + translate var(--nv-duration-quick) var(--nv-ease-entrance), + display var(--nv-duration-quick) allow-discrete, + overlay var(--nv-duration-quick) allow-discrete; + } + + .nv-menu:popover-open { + opacity: 1; + scale: 1; + translate: 0; + } + + @starting-style { + .nv-menu:popover-open { + opacity: 0; + scale: 0.96; + translate: 0 calc(var(--nv-travel-sm) * -1); + } + } + + /* Without anchor positioning the menu cannot know where its trigger is, so it + falls back to the popover's own centring rather than landing somewhere + arbitrary. The relationship is lost; the usability is not. */ + @supports not (anchor-name: --nv-test) { + .nv-menu { + inset: 0; + margin: auto; + transform-origin: center; + } + } + + /* --- Items -------------------------------------------------------------------- */ + + .nv-menu__item { + display: flex; + align-items: center; + gap: var(--nv-space-2-5); + inline-size: 100%; + padding: var(--nv-space-2) var(--nv-space-2-5); + + background-color: transparent; + border: 0; + border-radius: var(--nv-radius-sm); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + text-align: start; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: var(--nv-transition-colors); + } + + @media (hover: hover) { + .nv-menu__item:hover:not(:disabled, [aria-disabled="true"]) { + background-color: var(--nv-color-surface-hover); + color: var(--nv-color-text-primary); + } + } + + .nv-menu__item:focus-visible { + outline: none; + background-color: var(--nv-color-surface-hover); + color: var(--nv-color-text-primary); + box-shadow: var(--nv-focus-ring); + } + + .nv-menu__item--danger { + color: var(--nv-color-danger-text); + } + + @media (hover: hover) { + .nv-menu__item--danger:hover:not(:disabled, [aria-disabled="true"]) { + background-color: var(--nv-color-danger-subtle); + color: var(--nv-color-danger-text); + } + } + + .nv-menu__item:disabled, + .nv-menu__item[aria-disabled="true"] { + color: var(--nv-color-text-disabled); + cursor: not-allowed; + } + + .nv-menu__item[aria-checked="true"]::before { + content: ""; + inline-size: 0.28em; + block-size: 0.55em; + margin-inline-end: var(--nv-space-1); + translate: 0 -0.12em; + rotate: 45deg; + border-inline-end: 2px solid var(--nv-color-accent); + border-block-end: 2px solid var(--nv-color-accent); + } + + .nv-menu__shortcut { + margin-inline-start: auto; + padding-inline-start: var(--nv-space-4); + color: var(--nv-color-text-tertiary); + font: var(--nv-font-caption); + } + + .nv-menu__separator { + border: 0; + border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle); + margin: var(--nv-space-1) 0; + } + + .nv-menu__label { + padding: var(--nv-space-2) var(--nv-space-2-5) var(--nv-space-1); + font: var(--nv-font-caption); + font-weight: var(--nv-font-weight-semibold); + letter-spacing: var(--nv-letter-spacing-wider); + text-transform: uppercase; + color: var(--nv-color-text-tertiary); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Disclosure + -------------------------------------------------------------------------- +
    + … +
    …
    +
    + + Content expands out of the row that revealed it, at the position it already + occupies, rather than appearing at full height. The height animates because + the continuity layer turns on `interpolate-size`, which is what lets + block-size: auto take part in a transition — no measurement, no script, and + no wrapper whose height has to be kept in sync. + + .nv-collapse in continuity.css covers the same relationship for markup that + cannot be a
    . + ========================================================================== */ + + .nv-disclosure { + --_padding: var(--nv-space-4); + + background-color: var(--nv-color-surface); + background-image: var(--nv-sheen-surface); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + transition: var(--nv-transition-surface); + } + + .nv-disclosure--plain { + background-color: transparent; + border-color: transparent; + box-shadow: none; + } + + .nv-disclosure__summary { + display: flex; + align-items: center; + gap: var(--nv-space-3); + padding: var(--nv-space-3) var(--_padding); + + font: var(--nv-font-label); + color: var(--nv-color-text-primary); + border-radius: inherit; + cursor: pointer; + user-select: none; + list-style: none; + transition: var(--nv-transition-colors); + } + + /* Both spellings are needed to suppress the native marker. */ + .nv-disclosure__summary::marker, + .nv-disclosure__summary::-webkit-details-marker { + display: none; + content: ""; + } + + @media (hover: hover) { + .nv-disclosure__summary:hover { + background-color: var(--nv-color-surface-hover); + } + } + + .nv-disclosure__summary:focus-visible { + outline: none; + box-shadow: var(--nv-focus-ring); + } + + /* The chevron turns rather than swapping to a different glyph — the rotation + is what says "this is the same control in its other state". */ + .nv-disclosure__chevron { + inline-size: 0.5em; + block-size: 0.5em; + margin-inline-start: auto; + margin-block-start: -0.2em; + flex: none; + + border-inline-end: 1.5px solid var(--nv-color-text-tertiary); + border-block-end: 1.5px solid var(--nv-color-text-tertiary); + rotate: 45deg; + transition: rotate var(--nv-duration-normal) var(--nv-ease-standard); + } + + .nv-disclosure[open] .nv-disclosure__chevron { + rotate: 225deg; + margin-block-start: 0.15em; + } + + .nv-disclosure__body { + padding: 0 var(--_padding) var(--_padding); + color: var(--nv-color-text-secondary); + font: var(--nv-font-body); + } + + /* The element the browser creates for a
    body. Transitioning + content-visibility as a discrete property is what keeps the content + rendered while it collapses, instead of vanishing on the first frame. */ + .nv-disclosure::details-content { + block-size: 0; + overflow: hidden; + opacity: 0; + transition: + block-size var(--nv-duration-expand) var(--nv-ease-standard), + opacity var(--nv-duration-expand) var(--nv-ease-standard), + content-visibility var(--nv-duration-expand) allow-discrete; + } + + .nv-disclosure[open]::details-content { + block-size: auto; + opacity: 1; + } + + /* Stacked disclosures read as one control surface, so only the outer corners + round and neighbours share a single hairline. */ + .nv-disclosure-group { + display: flex; + flex-direction: column; + } + + /* The negative margin collapses each pair of adjacent borders into a single + hairline, so neighbours are separated once rather than twice. */ + .nv-disclosure-group > .nv-disclosure:not(:first-child) { + border-start-start-radius: 0; + border-start-end-radius: 0; + margin-block-start: calc(var(--nv-border-width) * -1); + } + + .nv-disclosure-group > .nv-disclosure:not(:last-child) { + border-end-start-radius: 0; + border-end-end-radius: 0; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Scroller + -------------------------------------------------------------------------- +
    +
    …cards…
    + + +
    + + A row of content that continues past its frame. Two things make that + readable rather than merely clipped: the edges fade, so the row visibly runs + under them instead of being cut, and snapping lands each item at the same + place every time — so moving left and right returns you to positions you + have already seen rather than to arbitrary offsets. + + Scrolling works with no script at all: wheel, trackpad, touch and keyboard + are the browser's. The buttons are an affordance for pointers that have no + horizontal axis, and niveus.js wires them. + ========================================================================== */ + + .nv-scroller-frame { + position: relative; + } + + .nv-scroller { + --nv-scroller-item: 18rem; + --nv-scroller-fade: var(--nv-space-8); + + display: flex; + gap: var(--nv-space-4); + overflow-x: auto; + overscroll-behavior-inline: contain; + + /* Room for items to lift on hover without being clipped by the scroll box. */ + padding-block: var(--nv-space-2); + + scroll-snap-type: inline mandatory; + scroll-padding-inline: var(--nv-space-1); + scroll-behavior: smooth; + scrollbar-width: none; + + /* The row runs under its own edges rather than stopping at them — but only + on the side where there is actually more to see. A fade at an end the row + has already reached is a lie about the content. niveus.js marks the ends; + with no script both sides fade, which is the safe default. */ + mask-image: linear-gradient( + to right, + transparent, + black var(--nv-scroller-fade), + black calc(100% - var(--nv-scroller-fade)), + transparent + ); + } + + .nv-scroller-frame[data-nv-at-start] .nv-scroller { + mask-image: linear-gradient( + to right, + black calc(100% - var(--nv-scroller-fade)), + transparent + ); + } + + .nv-scroller-frame[data-nv-at-end] .nv-scroller { + mask-image: linear-gradient(to right, transparent, black var(--nv-scroller-fade)); + } + + .nv-scroller-frame[data-nv-at-start][data-nv-at-end] .nv-scroller { + mask-image: none; + } + + .nv-scroller::-webkit-scrollbar { + display: none; + } + + .nv-scroller > * { + flex: 0 0 auto; + inline-size: min(var(--nv-scroller-item), 78%); + scroll-snap-align: start; + } + + /* Items that should size to their content rather than to the track. */ + .nv-scroller--auto > * { + inline-size: auto; + } + + @media (prefers-reduced-motion: reduce) { + .nv-scroller { + scroll-behavior: auto; + } + } + + /* --- Controls ------------------------------------------------------------ + Centred on the row's block axis and pulled half outside it, so they read as + belonging to the frame rather than floating over the content. */ + + .nv-scroller-frame [data-nv-scroll] { + position: absolute; + inset-block-start: 50%; + z-index: 1; + + /* Read by .nv-button--float, which keeps it through hover and press. */ + --nv-button-offset: 0 -50%; + } + + /* Belt and braces: the frame knows these controls float, so their centring + survives even if the markup forgets --float. */ + .nv-scroller-frame [data-nv-scroll], + .nv-scroller-frame [data-nv-scroll]:hover, + .nv-scroller-frame [data-nv-scroll]:active { + translate: var(--nv-button-offset); + } + + .nv-scroller-frame [data-nv-scroll="prev"] { + inset-inline-start: calc(var(--nv-space-4) * -1); + } + + .nv-scroller-frame [data-nv-scroll="next"] { + inset-inline-end: calc(var(--nv-space-4) * -1); + } + + /* At either end the control has nothing left to do, and says so. */ + .nv-scroller-frame [data-nv-scroll]:disabled { + opacity: 0; + pointer-events: none; + } + + @media (hover: none) { + .nv-scroller-frame [data-nv-scroll] { + display: none; + } + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Bed + -------------------------------------------------------------------------- +
    …
    + + A bed is ground that is visibly *not* ordinary background. Where a card says + "this content belongs together", a bed says "what sits here is different in + kind" — a feature being shown off, a preview, a sandbox, an empty state, a + region under explanation. + + It reads that way because of what it does to the materials on top of it: the + hatch and the soft color blooms give translucent surfaces something to be + translucent *against*, and give shadows something to fall on. A frosted panel + over a flat grey proves nothing; over a bed it is obvious. + + That is also the rule for using one. A bed is for regions the user should + look *at*. Put content they need to work *through* on a plain surface — + patterning behind a form or a table is noise, and the calm is the point. + + Everything it is made of derives from the accent, so it follows the brand. + Set --nv-bed-tint to lead with a different color. + ========================================================================== */ + + .nv-bed { + --nv-bed-tint: var(--nv-color-accent); + --_bloom: 16%; + --_hatch: var(--nv-color-border-subtle); + + position: relative; + padding: var(--nv-space-6); + + background-color: var(--nv-color-background-subtle); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-xl); + + /* The hatch sits over the blooms, and both sit under the content: three + background layers rather than a pseudo-element, so nothing has to be + lifted out of the way. */ + background-image: + repeating-linear-gradient( + 45deg, + var(--_hatch) 0 2px, + transparent 2px 14px + ), + radial-gradient( + 60% 80% at 15% 20%, + color-mix(in oklab, var(--nv-bed-tint) var(--_bloom), transparent), + transparent 70% + ), + radial-gradient( + 50% 70% at 85% 25%, + color-mix(in oklab, var(--nv-color-danger) calc(var(--_bloom) * 0.7), transparent), + transparent 70% + ), + radial-gradient( + 70% 90% at 50% 100%, + color-mix(in oklab, var(--nv-color-success) calc(var(--_bloom) * 0.7), transparent), + transparent 70% + ); + + overflow: hidden; + isolation: isolate; + transition: var(--nv-transition-surface); + } + + /* Just the ground, no weather: for grouping a region without drawing the eye + to it. */ + .nv-bed--quiet { + --_bloom: 8%; + --_hatch: transparent; + } + + .nv-bed--vivid { + --_bloom: 26%; + } + + /* One color instead of three — a bed that belongs to a single state. */ + .nv-bed--single { + background-image: + repeating-linear-gradient(45deg, var(--_hatch) 0 2px, transparent 2px 14px), + radial-gradient( + 80% 100% at 30% 0%, + color-mix(in oklab, var(--nv-bed-tint) var(--_bloom), transparent), + transparent 75% + ); + } + + .nv-bed--flush { + border-radius: 0; + border-inline: 0; + } + + .nv-bed__label { + display: block; + margin-block-end: var(--nv-space-4); + font: var(--nv-font-caption); + font-weight: var(--nv-font-weight-semibold); + letter-spacing: var(--nv-letter-spacing-wider); + text-transform: uppercase; + color: var(--nv-color-text-tertiary); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Badge + -------------------------------------------------------------------------- + In stock + 3 failing + Draft + + A small, non-interactive statement of state. Subtle by default, because a + badge is almost always beside something more important than itself; --solid + is for the one badge in a view that must be seen before anything else. + + If it can be clicked it is not a badge — that is a button, probably + .nv-button--pill --sm. + ========================================================================== */ + + .nv-badge { + --_bg: var(--nv-color-surface-inset); + --_fg: var(--nv-color-text-secondary); + --_border: var(--nv-color-border); + --_solid-bg: var(--nv-color-text-secondary); + --_solid-fg: var(--nv-color-surface); + + display: inline-flex; + align-items: center; + gap: var(--nv-space-1-5); + + block-size: var(--nv-space-5); + padding-inline: var(--nv-space-2); + + background-color: var(--_bg); + color: var(--_fg); + border: var(--nv-border-width) solid var(--_border); + border-radius: var(--nv-radius-full); + + font: var(--nv-font-caption); + font-weight: var(--nv-font-weight-semibold); + white-space: nowrap; + transition: var(--nv-transition-colors); + } + + .nv-badge--accent { + --_bg: var(--nv-color-accent-subtle); + --_fg: var(--nv-color-accent-text); + --_border: var(--nv-color-accent-border); + --_solid-bg: var(--nv-color-accent); + --_solid-fg: var(--nv-color-on-accent); + } + + .nv-badge--success { + --_bg: var(--nv-color-success-subtle); + --_fg: var(--nv-color-success-text); + --_border: var(--nv-color-success-border); + --_solid-bg: var(--nv-color-success); + --_solid-fg: var(--nv-color-on-success); + } + + .nv-badge--warning { + --_bg: var(--nv-color-warning-subtle); + --_fg: var(--nv-color-warning-text); + --_border: var(--nv-color-warning-border); + --_solid-bg: var(--nv-color-warning); + --_solid-fg: var(--nv-color-on-warning); + } + + .nv-badge--danger { + --_bg: var(--nv-color-danger-subtle); + --_fg: var(--nv-color-danger-text); + --_border: var(--nv-color-danger-border); + --_solid-bg: var(--nv-color-danger); + --_solid-fg: var(--nv-color-on-danger); + } + + /* Declared after the colors so it wins for --_bg; the color modifier is still + what decides which solid pair it reads. */ + .nv-badge--solid { + --_bg: var(--_solid-bg); + --_fg: var(--_solid-fg); + --_border: transparent; + background-image: var(--nv-sheen-filled); + box-shadow: var(--nv-highlight-top); + } + + .nv-badge--square { + border-radius: var(--nv-radius-xs); + } + + /* Counts read better without a border, and with room for two digits. */ + .nv-badge--count { + min-inline-size: var(--nv-space-5); + padding-inline: var(--nv-space-1-5); + justify-content: center; + border-color: transparent; + font-size: var(--nv-font-size-2xs); + font-variant-numeric: tabular-nums; + } + + .nv-badge__dot { + inline-size: var(--nv-space-1-5); + block-size: var(--nv-space-1-5); + border-radius: var(--nv-radius-full); + background-color: currentColor; + flex: none; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Table + -------------------------------------------------------------------------- +
    + …
    +
    + + Rows of the same kind of thing. The frame handles the overflow, because a + table is the one component that legitimately refuses to get narrower — it + scrolls sideways inside its frame rather than forcing the page to. + + Numbers belong in .nv-table__number: aligned to the end and set in tabular + figures, so columns of digits line up and can be compared by eye. + ========================================================================== */ + + .nv-table-frame { + inline-size: 100%; + overflow-x: auto; + overscroll-behavior-inline: contain; + } + + .nv-table { + inline-size: 100%; + border-collapse: collapse; + font: var(--nv-font-body); + text-align: start; + } + + .nv-table th { + padding: var(--nv-space-2-5) var(--nv-space-4); + font: var(--nv-font-caption); + font-weight: var(--nv-font-weight-semibold); + letter-spacing: var(--nv-letter-spacing-wide); + color: var(--nv-color-text-tertiary); + text-align: start; + white-space: nowrap; + border-block-end: var(--nv-border-width) solid var(--nv-color-border); + } + + .nv-table td { + padding: var(--nv-space-3) var(--nv-space-4); + color: var(--nv-color-text-secondary); + border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle); + vertical-align: middle; + } + + /* The first cell in a row names it, so it carries the reading weight. */ + .nv-table td:first-child { + color: var(--nv-color-text-primary); + font-weight: var(--nv-font-weight-medium); + } + + .nv-table tbody tr:last-child td { + border-block-end: 0; + } + + @media (hover: hover) { + .nv-table tbody tr { + transition: var(--nv-transition-colors); + } + + .nv-table tbody tr:hover { + background-color: var(--nv-color-surface-hover); + } + } + + .nv-table__number { + text-align: end; + font-variant-numeric: tabular-nums; + } + + .nv-table__actions { + inline-size: 1%; + white-space: nowrap; + text-align: end; + } + + .nv-table--compact th { + padding-block: var(--nv-space-2); + } + + .nv-table--compact td { + padding-block: var(--nv-space-2); + } + + /* Rows that lead somewhere. Same physical language as an interactive card: + it responds to a pointer, so it must look like it can be pressed. */ + .nv-table--interactive tbody tr { + cursor: pointer; + } + + .nv-table--interactive tbody tr:focus-visible { + outline: none; + box-shadow: var(--nv-focus-ring); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Window + -------------------------------------------------------------------------- + .nv-window a framed application window + .nv-window--inactive a window that does not have focus + .nv-window__titlebar controls · title · trailing actions + .nv-window__controls the three lights, at the start of the bar + .nv-window__control one light: --close, --minimize or --zoom + .nv-window__title the window's name, centered on the bar + .nv-window__actions trailing slot for a few ghost buttons + .nv-window__body the content area + +
    +
    +
    + + + +
    + Settings +
    +
    …
    +
    + + A window is a solid object: the content it holds never sits on glass. Its + titlebar is a raised strip lit like a control, and the controls are three + lights at its start — close, minimize, zoom — always in that order and in + those colors, because people find them by position and color before they + read anything. Their glyphs appear only while the pointer is over the + group. An inactive window greys its lights and steps back one elevation, + and the lights regain their color the moment they are hovered. + ========================================================================== */ + + .nv-window { + display: flex; + flex-direction: column; + min-inline-size: 0; + + background-color: var(--nv-color-surface); + background-image: var(--nv-sheen-surface); + color: var(--nv-color-text-primary); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-xl); + box-shadow: var(--nv-highlight-top), var(--nv-outline), var(--nv-elevation-4); + + overflow: hidden; + transition: var(--nv-transition-surface); + } + + .nv-window--inactive { + box-shadow: var(--nv-highlight-top), var(--nv-outline), var(--nv-elevation-3); + } + + /* --- Titlebar --------------------------------------------------------------- + Three columns with the outer two sharing the slack, so the title stays on + the window's center line whatever the controls and actions measure. */ + + .nv-window__titlebar { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--nv-space-3); + /* Slim, as titlebars on the desktop are: tall enough for the lights and + a label, not a toolbar's height. */ + min-block-size: calc(var(--nv-control-height-sm) + var(--nv-space-1)); + padding-inline: var(--nv-space-3); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle); + box-shadow: var(--nv-highlight-top); + user-select: none; + } + + .nv-window__title { + grid-column: 2; + min-inline-size: 0; + overflow: hidden; + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + color: var(--nv-color-text-primary); + text-overflow: ellipsis; + white-space: nowrap; + } + + .nv-window--inactive .nv-window__title { + color: var(--nv-color-text-tertiary); + } + + .nv-window__actions { + grid-column: 3; + justify-self: end; + display: flex; + align-items: center; + gap: var(--nv-space-1); + } + + /* --- Controls ----------------------------------------------------------------- */ + + .nv-window__controls { + grid-column: 1; + justify-self: start; + display: flex; + align-items: center; + gap: var(--nv-space-2); + } + + .nv-window__control { + --_light: var(--nv-color-window-close); + --_c: var(--_light); + + position: relative; + flex: none; + inline-size: var(--nv-space-3); + block-size: var(--nv-space-3); + margin: 0; + padding: 0; + + background-color: var(--_c); + background-image: var(--nv-sheen-filled); + border: 0; + border-radius: var(--nv-radius-full); + box-shadow: inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring); + + cursor: pointer; + appearance: none; + -webkit-tap-highlight-color: transparent; + transition: var(--nv-transition-colors); + } + + .nv-window__control--minimize { --_light: var(--nv-color-window-minimize); } + .nv-window__control--zoom { --_light: var(--nv-color-window-zoom); } + + /* The glyph is two bars drawn from the light's center: crossed for close, one + for minimize, a plus for zoom. They are always present and fade in, so the + whole group reveals at once instead of one glyph at a time. */ + .nv-window__control::before, + .nv-window__control::after { + content: ""; + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; + inline-size: 55%; + block-size: calc(var(--nv-border-width) * 1.5); + translate: -50% -50%; + background-color: var(--nv-color-window-glyph); + border-radius: var(--nv-radius-full); + opacity: 0; + transition: opacity var(--nv-duration-fast) var(--nv-ease-standard); + } + + .nv-window__control--close::before { rotate: 45deg; } + .nv-window__control--close::after { rotate: -45deg; } + .nv-window__control--minimize::after { display: none; } + .nv-window__control--zoom::after { rotate: 90deg; } + + .nv-window__controls:hover .nv-window__control::before, + .nv-window__controls:hover .nv-window__control::after, + .nv-window__controls:focus-within .nv-window__control::before, + .nv-window__controls:focus-within .nv-window__control::after { + opacity: 1; + } + + .nv-window__control:active { + background-image: none; + box-shadow: + inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring), + var(--nv-elevation-inset-sm); + } + + .nv-window__control:focus-visible { + outline: none; + box-shadow: + inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring), + var(--nv-focus-ring); + } + + .nv-window--inactive .nv-window__control { + --_c: var(--nv-color-window-inactive); + } + + .nv-window--inactive .nv-window__controls:hover .nv-window__control { + --_c: var(--_light); + } + + /* --- Body --------------------------------------------------------------------- */ + + .nv-window__body { + flex: 1; + min-block-size: 0; + overflow: auto; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Tile + -------------------------------------------------------------------------- + + + A tile is a small raised key that holds one glyph: the icon beside a + feature, the mark of a product, the symbol on a stat. It is the physical + version of an icon — something with an edge you could press — which is why + a row of features reads as a set of objects rather than as clip art. + + .nv-tile soft key: tinted face, the glyph in its tint + .nv-tile--solid filled key: saturated face, a light glyph + .nv-tile--glow the key is lit from inside + .nv-tile--sm / --lg / --xl + + Everything derives from --nv-tile-tint, the accent unless set. A tile is + not a button: if it does something, it is an .nv-button--icon. + ========================================================================== */ + + .nv-tile { + --nv-tile-tint: var(--nv-color-accent); + --_size: 2.75rem; + --_radius: var(--nv-radius-md); + --_bg: color-mix(in oklab, var(--nv-tile-tint) 16%, var(--nv-color-surface-elevated)); + --_ink: color-mix(in oklab, var(--nv-tile-tint) 70%, var(--nv-color-text-primary)); + --_sheen: var(--nv-sheen-raised); + --_glow: 0 0 transparent; + + position: relative; + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--_size); + block-size: var(--_size); + + background-color: var(--_bg); + background-image: var(--_sheen); + color: var(--_ink); + border: var(--nv-border-width) solid color-mix(in oklab, var(--nv-tile-tint) 30%, var(--nv-color-border-subtle)); + border-radius: var(--_radius); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + transition: var(--nv-transition-surface); + } + + /* Glyphs take half the key; a mark that is itself a picture (an image, a + logo) fills it, clipped to the key's corners. */ + .nv-tile > svg { + inline-size: 50%; + block-size: 50%; + } + + .nv-tile > img { + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + object-fit: cover; + } + + .nv-tile--solid { + --_bg: var(--nv-tile-tint); + --_ink: var(--nv-color-on-accent); + --_sheen: var(--nv-sheen-filled); + } + + .nv-tile--glow { + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-tile-tint) var(--nv-glow-strength), transparent); + } + + .nv-tile--sm { + --_size: 2rem; + --_radius: var(--nv-radius-sm); + } + + .nv-tile--lg { + --_size: 3.5rem; + --_radius: var(--nv-radius-lg); + } + + .nv-tile--xl { + --_size: 4.5rem; + --_radius: var(--nv-radius-xl); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Avatar + -------------------------------------------------------------------------- + + NX + + A person, as a round raised disc: an image when there is one, initials when + there is not. The disc is lit like a knob — highlight above, shade below — + so a face reads as something set into the interface, not pasted onto it. + + .nv-avatar--ring a ring in the tint, set off by a gap of surface + .nv-avatar--glow the ring gives off its tint + .nv-avatar--sm / --lg / --xl + .nv-avatar__status presence dot; --online lights it + .nv-avatar-group overlapping row of avatars + + --nv-avatar-tint picks the ring color; the accent unless set. + ========================================================================== */ + + .nv-avatar { + --nv-avatar-tint: var(--nv-color-accent); + --_size: 2.5rem; + --_ring: 0 0 transparent; + --_glow: 0 0 transparent; + + position: relative; + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--_size); + block-size: var(--_size); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + border-radius: var(--nv-radius-full); + box-shadow: var(--_ring), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + user-select: none; + } + + .nv-avatar > img { + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + object-fit: cover; + } + + /* The gap is drawn in the surface color, so the ring reads as a separate band + sitting around the disc rather than as a thick border painted onto it. */ + .nv-avatar--ring, + .nv-avatar--glow { + --_ring: + 0 0 0 2px var(--nv-color-surface), + 0 0 0 4px var(--nv-avatar-tint); + } + + .nv-avatar--glow { + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-avatar-tint) var(--nv-glow-strength), transparent); + } + + .nv-avatar--sm { + --_size: 1.75rem; + font: var(--nv-font-caption); + } + + .nv-avatar--lg { + --_size: 4rem; + font: var(--nv-font-title); + } + + .nv-avatar--xl { + --_size: 6rem; + font: var(--nv-font-heading); + } + + /* --- Status ------------------------------------------------------------------ */ + + .nv-avatar__status { + position: absolute; + inset-block-end: 4%; + inset-inline-end: 4%; + inline-size: max(0.625rem, 24%); + block-size: max(0.625rem, 24%); + border-radius: var(--nv-radius-full); + background-color: var(--nv-color-text-tertiary); + box-shadow: 0 0 0 2px var(--nv-color-surface); + } + + .nv-avatar__status--online { + background-color: var(--nv-color-success); + box-shadow: + 0 0 0 2px var(--nv-color-surface), + 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-color-success) var(--nv-glow-strength), transparent); + } + + /* --- Group ----------------------------------------------------------------------- */ + + .nv-avatar-group { + display: inline-flex; + align-items: center; + } + + .nv-avatar-group > .nv-avatar { + box-shadow: 0 0 0 2px var(--nv-color-surface), var(--nv-bevel), var(--nv-elevation-1); + } + + .nv-avatar-group > .nv-avatar + .nv-avatar { + margin-inline-start: calc(var(--_size) * -0.3); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Stat + -------------------------------------------------------------------------- +
    +
    +
    Servers
    +
    1,204
    +
    +
    + + A number the user is meant to read at a glance. A group of stats is set into + one shallow well, like the readout panel of an instrument: the figures sit + behind the surface, separated by hairlines, so a row of numbers reads as one + display instead of as four loose labels. + + .nv-stat-group the readout well; wraps without a media query + .nv-stat one reading + .nv-stat__label what is measured + .nv-stat__value the figure, in tabular numerals + .nv-stat__delta change since last time; --up / --down + ========================================================================== */ + + .nv-stat-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr)); + /* The hairlines between readings are the well itself showing through a + one-pixel gap, so they stay continuous however the row wraps. */ + gap: var(--nv-border-width); + margin: 0; + background-color: var(--nv-color-border-subtle); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + overflow: hidden; + } + + .nv-stat { + display: flex; + flex-direction: column; + gap: var(--nv-space-1); + min-inline-size: 0; + padding: var(--nv-space-4) var(--nv-space-5); + } + + .nv-stat-group > .nv-stat { + background-color: var(--nv-color-surface-inset); + } + + .nv-stat__label { + font: var(--nv-font-caption); + color: var(--nv-color-text-tertiary); + } + + .nv-stat__value { + margin: 0; + font: var(--nv-font-heading); + font-variant-numeric: tabular-nums; + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); + } + + .nv-stat__delta { + font: var(--nv-font-caption); + font-variant-numeric: tabular-nums; + color: var(--nv-color-text-secondary); + } + + .nv-stat__delta--up { + color: var(--nv-color-success); + } + + .nv-stat__delta--down { + color: var(--nv-color-danger); + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Steps + -------------------------------------------------------------------------- +
      +
    1. + 1 +
      +

      Listen

      +

      …

      +
      +
    2. +
    + + A sequence the user moves through: a process, an onboarding, an order's + progress. The steps sit on a rail — a groove cut into the surface — with + each marker a raised knob set into it. Finished steps light their stretch + of the rail, so progress reads as something filling up rather than as a + row of ticks. + + .nv-steps horizontal; becomes vertical on narrow screens + .nv-steps--vertical always vertical + data-state="done" the step and its stretch of rail are lit + aria-current="step" the step in progress: lit marker, rail still dark + ========================================================================== */ + + .nv-steps { + --_marker: 2.25rem; + --_rail: 6px; + --_gap: var(--nv-space-4); + + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: var(--_gap); + margin: 0; + padding: 0; + list-style: none; + } + + .nv-steps__item { + position: relative; + display: flex; + flex-direction: column; + gap: var(--nv-space-3); + min-inline-size: 0; + } + + /* Each step draws the rail from its own marker to the next one, so a finished + step can light exactly its stretch and the rail never runs past the end. */ + .nv-steps__item:not(:last-child)::before { + content: ""; + position: absolute; + inset-block-start: calc((var(--_marker) - var(--_rail)) / 2); + inset-inline-start: calc(var(--_marker) / 2); + inline-size: calc(100% + var(--_gap)); + block-size: var(--_rail); + border-radius: var(--nv-radius-full); + background-color: var(--nv-color-surface-inset); + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + transition: var(--nv-transition-surface); + } + + .nv-steps__item[data-state="done"]:not(:last-child)::before { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + box-shadow: var(--nv-glow-sm); + } + + .nv-steps__marker { + position: relative; + z-index: 1; + display: grid; + place-items: center; + flex: none; + inline-size: var(--_marker); + block-size: var(--_marker); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + font-variant-numeric: tabular-nums; + border-radius: var(--nv-radius-full); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + transition: var(--nv-transition-surface); + } + + .nv-steps__item[data-state="done"] > .nv-steps__marker, + .nv-steps__item[aria-current="step"] > .nv-steps__marker { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + color: var(--nv-color-on-accent); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), var(--nv-glow-md); + } + + .nv-steps__body { + display: flex; + flex-direction: column; + gap: var(--nv-space-1); + min-inline-size: 0; + } + + .nv-steps__title { + margin: 0; + font: var(--nv-font-title); + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); + } + + .nv-steps__text { + margin: 0; + font: var(--nv-font-caption); + color: var(--nv-color-text-secondary); + } + + /* --- Vertical ------------------------------------------------------------------- + Marker beside the text and the rail running down between markers. Also what + a horizontal sequence becomes once its columns are too narrow to read. */ + + .nv-steps--vertical { + grid-auto-flow: row; + grid-auto-columns: auto; + --_gap: var(--nv-space-5); + } + + .nv-steps--vertical > .nv-steps__item { + flex-direction: row; + gap: var(--nv-space-4); + } + + .nv-steps--vertical > .nv-steps__item:not(:last-child)::before { + inset-block-start: calc(var(--_marker) / 2); + inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2); + inline-size: var(--_rail); + block-size: calc(100% + var(--_gap)); + } + + @media (max-width: 40rem) { + .nv-steps { + grid-auto-flow: row; + grid-auto-columns: auto; + --_gap: var(--nv-space-5); + } + + .nv-steps > .nv-steps__item { + flex-direction: row; + gap: var(--nv-space-4); + } + + .nv-steps > .nv-steps__item:not(:last-child)::before { + inset-block-start: calc(var(--_marker) / 2); + inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2); + inline-size: var(--_rail); + block-size: calc(100% + var(--_gap)); + } + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Deck + -------------------------------------------------------------------------- +
    +
    …
    + + +
    + + One surface in front of others of its kind: the current item of a set that + has more behind it. The layers are cards seen edge-on, stepping back and + dimming with distance, so the set has depth without anything having to + move. Use it where "there are more of these" is part of the message — a + showcase of products, a stack of notifications, a carousel at rest. + + Everything in the deck shares one grid cell. The front surface is any + element; the layers are decorative and hidden from assistive technology. + Panels that swap (an .nv-tab-panel set) can live in the deck together: + the hidden ones take no space. + ========================================================================== */ + + .nv-deck { + --_step: var(--nv-space-3); + + display: grid; + position: relative; + isolation: isolate; + /* Room above the front surface for the layers that peek out behind it. */ + padding-block-start: calc(var(--_step) * 2); + } + + .nv-deck > * { + grid-area: 1 / 1; + min-inline-size: 0; + } + + .nv-deck__layer { + --_depth: 1; + + z-index: -1; + pointer-events: none; + background-color: color-mix(in oklab, var(--nv-color-surface) 90%, transparent); + background-image: var(--nv-sheen-surface); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + translate: 0 calc(var(--_step) * var(--_depth) * -1); + scale: calc(1 - var(--_depth) * 0.05) 1; + opacity: calc(1 - var(--_depth) * 0.3); + } + + .nv-deck__layer + .nv-deck__layer { + --_depth: 2; + z-index: -2; + } +} +@layer niveus.components { + /* ========================================================================== + Niveus — Skeleton + -------------------------------------------------------------------------- + + + The shape of content that has not arrived yet. A skeleton is a shallow well + cut where the content will sit, with a slow band of light crossing it — the + surface is recessed and waiting, not broken. + + .nv-skeleton a block; size it with the layout + .nv-skeleton--text one line of body text + .nv-skeleton--circle an avatar or a tile + + Under reduced motion the light stops crossing and the well stays: the state + is still legible, it just does not move. + ========================================================================== */ + + .nv-skeleton { + display: block; + min-block-size: var(--nv-space-4); + border-radius: var(--nv-radius-md); + background-color: var(--nv-color-surface-inset); + background-image: linear-gradient( + 100deg, + transparent 30%, + var(--nv-color-sheen-top) 50%, + transparent 70% + ); + background-size: 220% 100%; + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + animation: nv-shimmer var(--nv-duration-shimmer) var(--nv-ease-linear) infinite; + } + + .nv-skeleton--text { + inline-size: 100%; + min-block-size: 0; + block-size: 0.75em; + border-radius: var(--nv-radius-full); + } + + /* A paragraph of skeleton lines ends short, like the paragraph it stands for. */ + .nv-skeleton--text:last-child:not(:first-child) { + inline-size: 60%; + } + + .nv-skeleton--circle { + aspect-ratio: 1; + border-radius: var(--nv-radius-full); + } + + @keyframes nv-shimmer { + from { background-position: 100% 0; } + to { background-position: -120% 0; } + } + + @media (prefers-reduced-motion: reduce) { + .nv-skeleton { + animation: none; + } + } +} + +/* --- Utilities ----------------------------------------------------------------- */ +@layer niveus.utilities { + /* ========================================================================== + Niveus — Utilities + -------------------------------------------------------------------------- + A deliberately short list. Utilities exist for the handful of patterns that + appear in every application and have exactly one correct implementation — + not as a general-purpose styling language. Anything that needs a second + utility to look right is a component. + ========================================================================== */ + + /* Content for assistive technology only. Not display:none, which removes it + from the accessibility tree as well. */ + .nv-visually-hidden:not(:focus):not(:active) { + position: absolute; + inline-size: 1px; + block-size: 1px; + margin: -1px; + padding: 0; + border: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .nv-container { + inline-size: 100%; + max-inline-size: var(--nv-layout-max-width); + margin-inline: auto; + padding-inline: var(--nv-layout-gutter); + } + + /* Vertical rhythm between siblings, on the spacing scale. */ + .nv-stack { + display: flex; + flex-direction: column; + gap: var(--nv-space-4); + } + + .nv-stack--tight { gap: var(--nv-space-2); } + .nv-stack--loose { gap: var(--nv-space-6); } + + /* A row that wraps instead of overflowing. */ + .nv-cluster { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--nv-space-3); + } + + .nv-cluster--tight { gap: var(--nv-space-2); } + .nv-cluster--between { justify-content: space-between; } + + .nv-truncate { + min-inline-size: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* Long-form text at a readable measure. */ + .nv-prose { + max-inline-size: var(--nv-measure); + line-height: var(--nv-line-height-relaxed); + color: var(--nv-color-text-secondary); + } +} diff --git a/docs/Website/niveus/examples/ai-chat/index.html b/docs/Website/niveus/examples/ai-chat/index.html index f29d71d..fe59588 100644 --- a/docs/Website/niveus/examples/ai-chat/index.html +++ b/docs/Website/niveus/examples/ai-chat/index.html @@ -404,7 +404,7 @@ .forEach((el) => el.removeAttribute("aria-current")); item.setAttribute("aria-current", "page"); render(item.dataset.thread); - }); + }, { scope: list }); scroller.scrollTop = 0; }); }); diff --git a/docs/Website/niveus/examples/ai-landing/index.html b/docs/Website/niveus/examples/ai-landing/index.html index efeee92..0e71fd8 100644 --- a/docs/Website/niveus/examples/ai-landing/index.html +++ b/docs/Website/niveus/examples/ai-landing/index.html @@ -454,7 +454,7 @@ nv.transition(() => { nav.querySelectorAll("[aria-current]").forEach((el) => el.removeAttribute("aria-current")); item.setAttribute("aria-current", "page"); - }); + }, { scope: nav }); }); /* The bar earns its shadow only once content has passed under it. */ diff --git a/docs/Website/niveus/examples/dashboard/index.html b/docs/Website/niveus/examples/dashboard/index.html index 5ae8f42..ec9fead 100644 --- a/docs/Website/niveus/examples/dashboard/index.html +++ b/docs/Website/niveus/examples/dashboard/index.html @@ -560,7 +560,7 @@ nv.transition(() => { sidebar.querySelectorAll("[aria-current]").forEach((el) => el.removeAttribute("aria-current")); item.setAttribute("aria-current", "page"); - }); + }, { scope: sidebar }); }); document.querySelectorAll("[data-range]").forEach((option) => { diff --git a/docs/Website/niveus/fonts/InstrumentSans-Italic.woff2 b/docs/Website/niveus/fonts/InstrumentSans-Italic.woff2 new file mode 100644 index 0000000..3266aee Binary files /dev/null and b/docs/Website/niveus/fonts/InstrumentSans-Italic.woff2 differ diff --git a/docs/Website/niveus/fonts/InstrumentSans.woff2 b/docs/Website/niveus/fonts/InstrumentSans.woff2 new file mode 100644 index 0000000..55fc752 Binary files /dev/null and b/docs/Website/niveus/fonts/InstrumentSans.woff2 differ diff --git a/docs/Website/niveus/fonts/JetBrainsMono.woff2 b/docs/Website/niveus/fonts/JetBrainsMono.woff2 new file mode 100644 index 0000000..863db7f Binary files /dev/null and b/docs/Website/niveus/fonts/JetBrainsMono.woff2 differ diff --git a/docs/Website/niveus/fonts/OFL-InstrumentSans.txt b/docs/Website/niveus/fonts/OFL-InstrumentSans.txt new file mode 100644 index 0000000..26bd2f9 --- /dev/null +++ b/docs/Website/niveus/fonts/OFL-InstrumentSans.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/Website/niveus/fonts/OFL-JetBrainsMono.txt b/docs/Website/niveus/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 0000000..821a3da --- /dev/null +++ b/docs/Website/niveus/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/docs/Website/niveus/fonts/static/InstrumentSans-Bold.ttf b/docs/Website/niveus/fonts/static/InstrumentSans-Bold.ttf new file mode 100644 index 0000000..9a55c17 Binary files /dev/null and b/docs/Website/niveus/fonts/static/InstrumentSans-Bold.ttf differ diff --git a/docs/Website/niveus/fonts/static/InstrumentSans-BoldItalic.ttf b/docs/Website/niveus/fonts/static/InstrumentSans-BoldItalic.ttf new file mode 100644 index 0000000..a04867c Binary files /dev/null and b/docs/Website/niveus/fonts/static/InstrumentSans-BoldItalic.ttf differ diff --git a/docs/Website/niveus/fonts/static/InstrumentSans-Italic.ttf b/docs/Website/niveus/fonts/static/InstrumentSans-Italic.ttf new file mode 100644 index 0000000..35a3df9 Binary files /dev/null and b/docs/Website/niveus/fonts/static/InstrumentSans-Italic.ttf differ diff --git a/docs/Website/niveus/fonts/static/InstrumentSans-Medium.ttf b/docs/Website/niveus/fonts/static/InstrumentSans-Medium.ttf new file mode 100644 index 0000000..e67ed5d Binary files /dev/null and b/docs/Website/niveus/fonts/static/InstrumentSans-Medium.ttf differ diff --git a/docs/Website/niveus/fonts/static/InstrumentSans-MediumItalic.ttf b/docs/Website/niveus/fonts/static/InstrumentSans-MediumItalic.ttf new file mode 100644 index 0000000..04b1c43 Binary files /dev/null and b/docs/Website/niveus/fonts/static/InstrumentSans-MediumItalic.ttf differ diff --git a/docs/Website/niveus/fonts/static/InstrumentSans-Regular.ttf b/docs/Website/niveus/fonts/static/InstrumentSans-Regular.ttf new file mode 100644 index 0000000..0eb2260 Binary files /dev/null and b/docs/Website/niveus/fonts/static/InstrumentSans-Regular.ttf differ diff --git a/docs/Website/niveus/fonts/static/InstrumentSans-SemiBold.ttf b/docs/Website/niveus/fonts/static/InstrumentSans-SemiBold.ttf new file mode 100644 index 0000000..5c3a092 Binary files /dev/null and b/docs/Website/niveus/fonts/static/InstrumentSans-SemiBold.ttf differ diff --git a/docs/Website/niveus/fonts/static/InstrumentSans-SemiBoldItalic.ttf b/docs/Website/niveus/fonts/static/InstrumentSans-SemiBoldItalic.ttf new file mode 100644 index 0000000..073510f Binary files /dev/null and b/docs/Website/niveus/fonts/static/InstrumentSans-SemiBoldItalic.ttf differ diff --git a/docs/Website/niveus/fonts/static/JetBrainsMono-Bold.ttf b/docs/Website/niveus/fonts/static/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..373df29 Binary files /dev/null and b/docs/Website/niveus/fonts/static/JetBrainsMono-Bold.ttf differ diff --git a/docs/Website/niveus/fonts/static/JetBrainsMono-Medium.ttf b/docs/Website/niveus/fonts/static/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..d4c69c2 Binary files /dev/null and b/docs/Website/niveus/fonts/static/JetBrainsMono-Medium.ttf differ diff --git a/docs/Website/niveus/fonts/static/JetBrainsMono-Regular.ttf b/docs/Website/niveus/fonts/static/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..660323e Binary files /dev/null and b/docs/Website/niveus/fonts/static/JetBrainsMono-Regular.ttf differ diff --git a/docs/Website/niveus/fonts/static/JetBrainsMono-SemiBold.ttf b/docs/Website/niveus/fonts/static/JetBrainsMono-SemiBold.ttf new file mode 100644 index 0000000..5ecc5e1 Binary files /dev/null and b/docs/Website/niveus/fonts/static/JetBrainsMono-SemiBold.ttf differ diff --git a/docs/Website/niveus/index.html b/docs/Website/niveus/index.html index 3f3ce39..3e6e30c 100644 --- a/docs/Website/niveus/index.html +++ b/docs/Website/niveus/index.html @@ -240,12 +240,14 @@

    Design system

    -

    Surfaces that feel understandable.

    +

    Surfaces you could press.

    - Niveus is a calm, adaptive interface language built on clarity, depth, - material and consistency. The same components carry their hierarchy from - light to dark, because nothing below is defined in terms of an - appearance — only in terms of a role. + Niveus is a tactile, adaptive interface language. Controls are bevelled + keys with light falling across them, fields are wells cut into the + surface, and glass is kept for the chrome that floats over everything + else. The same components carry their hierarchy from light to dark, + because nothing below is defined in terms of an appearance — only in + terms of a role.

    Overline - --nv-font-caption · uppercase + --nv-font-engraved · mono, uppercase
    @@ -762,6 +764,58 @@ +
    +

    Window

    +
    +
    +
    +
    + + + +
    + Appearance +
    + +
    +
    +
    +

    Style

    +
    + + + +
    + + +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + + + +
    + Inactive — hover the lights +
    +
    +

    A window without focus greys its lights and steps back one elevation. The lights take their color back the moment the pointer reaches them.

    +
    +
    +
    +
    +

    Dialog

    @@ -1315,7 +1369,7 @@ previous.removeAttribute("aria-current"); }); item.setAttribute("aria-current", "page"); - }); + }, { scope: group }); }); } diff --git a/docs/Website/niveus/niveus/base.css b/docs/Website/niveus/niveus/base.css index 5bbcf1a..5c752e3 100644 --- a/docs/Website/niveus/niveus/base.css +++ b/docs/Website/niveus/niveus/base.css @@ -24,6 +24,14 @@ body { margin: 0; min-height: 100svh; background-color: var(--nv-color-background); + /* The canvas is lit, not painted: a wash of the accent falls in from the top + corner and a fainter one pools at the opposite foot. It is what gives + translucent surfaces something to be translucent against, and keeps a + page of solid panels from reading as one flat field. */ + background-image: + radial-gradient(120% 80% at 0% 0%, color-mix(in oklab, var(--nv-color-accent) light-dark(10%, 16%), transparent), transparent 60%), + radial-gradient(90% 70% at 100% 100%, color-mix(in oklab, var(--nv-color-accent) light-dark(6%, 10%), transparent), transparent 60%); + background-attachment: fixed; color: var(--nv-color-text-primary); font: var(--nv-font-body); letter-spacing: var(--nv-letter-spacing-normal); @@ -175,10 +183,11 @@ textarea { color: var(--nv-color-text-secondary); } +/* Engraved: small mono capitals that mark a region the way markings mark an + instrument panel. Not for anything that has to be read as a sentence. */ .nv-overline { - font: var(--nv-font-caption); - font-weight: var(--nv-font-weight-semibold); - letter-spacing: var(--nv-letter-spacing-wider); + font: var(--nv-font-engraved); + letter-spacing: var(--nv-letter-spacing-engraved); text-transform: uppercase; color: var(--nv-color-text-tertiary); } @@ -195,3 +204,32 @@ textarea { .nv-text-success { color: var(--nv-color-success-text); } .nv-text-warning { color: var(--nv-color-warning-text); } .nv-text-danger { color: var(--nv-color-danger-text); } + +/* Lit text: type cut from the same material as the surfaces — bright along + the top, warming towards the accent below, the way light falls across a + raised face. Each line is lit on its own. For one display line on a page, + not for running text. */ +.nv-text-lit { + background-image: linear-gradient( + 180deg, + var(--nv-color-text-primary) 30%, + color-mix(in oklab, var(--nv-color-accent-text) 30%, var(--nv-color-text-primary)) + ); + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: var(--nv-color-accent-text); +} + +/* The accent alone, lit from above: for an accented phrase inside a display + line, where flat accent text would read as a link. */ +.nv-text-lit--accent { + background-image: linear-gradient( + 180deg, + color-mix(in oklab, var(--nv-color-accent-text) 55%, var(--nv-color-text-primary)), + var(--nv-color-accent-text) 60%, + var(--nv-color-accent) + ); +} diff --git a/docs/Website/niveus/niveus/components/avatar.css b/docs/Website/niveus/niveus/components/avatar.css new file mode 100644 index 0000000..20f5f36 --- /dev/null +++ b/docs/Website/niveus/niveus/components/avatar.css @@ -0,0 +1,113 @@ +/* ========================================================================== + Niveus — Avatar + -------------------------------------------------------------------------- + + NX + + A person, as a round raised disc: an image when there is one, initials when + there is not. The disc is lit like a knob — highlight above, shade below — + so a face reads as something set into the interface, not pasted onto it. + + .nv-avatar--ring a ring in the tint, set off by a gap of surface + .nv-avatar--glow the ring gives off its tint + .nv-avatar--sm / --lg / --xl + .nv-avatar__status presence dot; --online lights it + .nv-avatar-group overlapping row of avatars + + --nv-avatar-tint picks the ring color; the accent unless set. + ========================================================================== */ + +.nv-avatar { + --nv-avatar-tint: var(--nv-color-accent); + --_size: 2.5rem; + --_ring: 0 0 transparent; + --_glow: 0 0 transparent; + + position: relative; + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--_size); + block-size: var(--_size); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + border-radius: var(--nv-radius-full); + box-shadow: var(--_ring), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + user-select: none; +} + +.nv-avatar > img { + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + object-fit: cover; +} + +/* The gap is drawn in the surface color, so the ring reads as a separate band + sitting around the disc rather than as a thick border painted onto it. */ +.nv-avatar--ring, +.nv-avatar--glow { + --_ring: + 0 0 0 2px var(--nv-color-surface), + 0 0 0 4px var(--nv-avatar-tint); +} + +.nv-avatar--glow { + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-avatar-tint) var(--nv-glow-strength), transparent); +} + +.nv-avatar--sm { + --_size: 1.75rem; + font: var(--nv-font-caption); +} + +.nv-avatar--lg { + --_size: 4rem; + font: var(--nv-font-title); +} + +.nv-avatar--xl { + --_size: 6rem; + font: var(--nv-font-heading); +} + +/* --- Status ------------------------------------------------------------------ */ + +.nv-avatar__status { + position: absolute; + inset-block-end: 4%; + inset-inline-end: 4%; + inline-size: max(0.625rem, 24%); + block-size: max(0.625rem, 24%); + border-radius: var(--nv-radius-full); + background-color: var(--nv-color-text-tertiary); + box-shadow: 0 0 0 2px var(--nv-color-surface); +} + +.nv-avatar__status--online { + background-color: var(--nv-color-success); + box-shadow: + 0 0 0 2px var(--nv-color-surface), + 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-color-success) var(--nv-glow-strength), transparent); +} + +/* --- Group ----------------------------------------------------------------------- */ + +.nv-avatar-group { + display: inline-flex; + align-items: center; +} + +.nv-avatar-group > .nv-avatar { + box-shadow: 0 0 0 2px var(--nv-color-surface), var(--nv-bevel), var(--nv-elevation-1); +} + +.nv-avatar-group > .nv-avatar + .nv-avatar { + margin-inline-start: calc(var(--_size) * -0.3); +} diff --git a/docs/Website/niveus/niveus/components/badge.css b/docs/Website/niveus/niveus/components/badge.css index 7cf80a1..600c858 100644 --- a/docs/Website/niveus/niveus/components/badge.css +++ b/docs/Website/niveus/niveus/components/badge.css @@ -76,6 +76,8 @@ --_bg: var(--_solid-bg); --_fg: var(--_solid-fg); --_border: transparent; + background-image: var(--nv-sheen-filled); + box-shadow: var(--nv-highlight-top); } .nv-badge--square { diff --git a/docs/Website/niveus/niveus/components/button.css b/docs/Website/niveus/niveus/components/button.css index 1353aab..9c4bb4d 100644 --- a/docs/Website/niveus/niveus/components/button.css +++ b/docs/Website/niveus/niveus/components/button.css @@ -10,6 +10,9 @@ .nv-button--icon square, for a lone icon (needs a label) .nv-button--block fills its container + A button is a key: a bevelled face with light falling across it, a contact + line where it meets the surface, and a press that sinks it into an inset. + Every variant is defined purely by reassigning the private --_* properties below. Interaction — the lift on hover, the sink on press, the focus ring — lives once in the base rule, so a new variant can never drift from the way @@ -23,9 +26,10 @@ --_bg-active: var(--nv-color-surface-active); --_fg: var(--nv-color-text-primary); --_border: var(--nv-color-border); - --_shadow: var(--nv-highlight-top), var(--nv-elevation-2); - --_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-3); - --_shadow-active: var(--nv-elevation-inset-sm); + --_sheen: var(--nv-sheen-raised); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); + --_shadow-active: var(--nv-elevation-inset-sm), var(--nv-outline); /* Size, overridden per size modifier. */ --_height: var(--nv-control-height-md); @@ -46,6 +50,7 @@ border-radius: var(--_radius); background-color: var(--_bg); + background-image: var(--_sheen); color: var(--_fg); box-shadow: var(--_shadow); @@ -83,8 +88,11 @@ } } +/* Pressed, the face is pushed below the light: the sheen goes and the inset + takes over. */ .nv-button:active:not(:disabled, [aria-disabled="true"]) { background-color: var(--_bg-active); + background-image: none; box-shadow: var(--_shadow-active); translate: 0 var(--nv-press-depth); transition-duration: var(--nv-duration-instant); @@ -98,6 +106,7 @@ .nv-button:disabled, .nv-button[aria-disabled="true"] { background-color: var(--nv-color-surface-inset); + background-image: none; color: var(--nv-color-text-disabled); border-color: var(--nv-color-border-subtle); box-shadow: none; @@ -113,6 +122,13 @@ --_bg-active: var(--nv-color-accent-active); --_fg: var(--nv-color-on-accent); --_border: transparent; + --_sheen: var(--nv-sheen-filled); + /* A filled key gives off a little of its own color: the glow is what makes + it read as lit rather than printed. */ + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), + 0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent); + --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), + 0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent); font-weight: var(--nv-font-weight-semibold); } @@ -122,6 +138,11 @@ --_bg-active: var(--nv-color-danger-active); --_fg: var(--nv-color-on-danger); --_border: transparent; + --_sheen: var(--nv-sheen-filled); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), + 0 6px 16px -8px color-mix(in oklab, var(--_bg) 80%, transparent); + --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), + 0 8px 22px -8px color-mix(in oklab, var(--_bg) 85%, transparent); --nv-color-focus-ring: var(--nv-color-danger-border); font-weight: var(--nv-font-weight-semibold); } @@ -132,11 +153,20 @@ --_bg-active: var(--nv-color-surface-active); --_fg: var(--nv-color-text-secondary); --_border: transparent; + --_sheen: none; --_shadow: none; --_shadow-hover: none; --_shadow-active: var(--nv-elevation-inset-sm); } +/* A ghost has no material to lose, so disabling it only dims the label — + it does not suddenly grow a well its enabled siblings never had. */ +.nv-button--ghost:disabled, +.nv-button--ghost[aria-disabled="true"] { + background-color: transparent; + border-color: transparent; +} + @media (hover: hover) { .nv-button--ghost:hover:not(:disabled, [aria-disabled="true"]) { color: var(--nv-color-text-primary); @@ -150,6 +180,7 @@ --_bg-active: var(--nv-color-accent-subtle-hover); --_fg: var(--nv-color-accent-text); --_border: var(--nv-color-accent-border); + --_sheen: none; --_shadow: none; --_shadow-hover: var(--nv-elevation-1); } @@ -159,6 +190,7 @@ --_bg-hover: var(--nv-material-frosted-bg); --_bg-active: var(--nv-material-frosted-bg); --_border: var(--nv-material-glass-border); + --_sheen: none; backdrop-filter: var(--nv-backdrop-glass); -webkit-backdrop-filter: var(--nv-backdrop-glass); } @@ -205,9 +237,9 @@ through every state. */ .nv-button--float { - --_shadow: var(--nv-highlight-top), var(--nv-elevation-3); - --_shadow-hover: var(--nv-highlight-top), var(--nv-elevation-4); - --_shadow-active: var(--nv-highlight-top), var(--nv-elevation-2); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); + --_shadow-hover: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4); + --_shadow-active: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); translate: var(--nv-button-offset, 0 0); } diff --git a/docs/Website/niveus/niveus/components/card.css b/docs/Website/niveus/niveus/components/card.css index 78a3adb..4dd9646 100644 --- a/docs/Website/niveus/niveus/components/card.css +++ b/docs/Website/niveus/niveus/components/card.css @@ -6,6 +6,9 @@ .nv-card--inset recessed into its parent .nv-card--frosted over imagery or scrolling content .nv-card--interactive the whole card is one target + .nv-card--lit lit along its top edge in its tint + .nv-card--glow gives off its tint — the one card that is on + .nv-card--spotlight catches a light that follows the pointer .nv-card__header / __body / __footer / __media A card is a container, not a look. If a card needs a shadow that is not on @@ -14,10 +17,20 @@ ========================================================================== */ .nv-card { - --_bg: var(--nv-color-surface); + /* Content surfaces are mostly solid: enough of the canvas comes through to + tie a card to the light around it, never enough to compete with what it + holds. The blur keeps anything busy behind it (a bed, an image) from + reading through as texture. */ + --_bg: color-mix(in oklab, var(--nv-color-surface) 90%, transparent); --_border: var(--nv-color-border-subtle); - --_shadow: var(--nv-highlight-top), var(--nv-elevation-1); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + --_sheen: var(--nv-sheen-surface); --_padding: var(--nv-space-5); + /* Light layers the variants switch on. Kept separate from --_shadow so a + lit or glowing card can still be elevated, floating or interactive. */ + --_lit: 0 0 transparent; + --_glow: 0 0 transparent; + --_spot: linear-gradient(transparent, transparent); position: relative; display: flex; @@ -25,10 +38,13 @@ min-inline-size: 0; background-color: var(--_bg); + background-image: var(--_spot), var(--_sheen); color: var(--nv-color-text-primary); border: var(--nv-border-width) solid var(--_border); border-radius: var(--nv-radius-lg); - box-shadow: var(--_shadow); + box-shadow: var(--_lit), var(--_shadow), var(--_glow); + backdrop-filter: var(--nv-backdrop-glass); + -webkit-backdrop-filter: var(--nv-backdrop-glass); /* Children are clipped to the corner radius so media and inset regions cannot square off the card. */ @@ -37,18 +53,19 @@ } .nv-card--elevated { - --_bg: var(--nv-color-surface-elevated); - --_shadow: var(--nv-highlight-top), var(--nv-elevation-2); + --_bg: color-mix(in oklab, var(--nv-color-surface-elevated) 90%, transparent); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); } .nv-card--floating { - --_bg: var(--nv-color-surface-elevated); - --_shadow: var(--nv-highlight-top), var(--nv-elevation-3); + --_bg: color-mix(in oklab, var(--nv-color-surface-elevated) 88%, transparent); + --_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); } .nv-card--inset { - --_bg: var(--nv-color-surface-inset); - --_shadow: var(--nv-elevation-inset-md); + --_bg: color-mix(in oklab, var(--nv-color-surface-inset) 85%, transparent); + --_shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + --_sheen: none; --_border: var(--nv-color-border-subtle); } @@ -56,6 +73,7 @@ --_bg: var(--nv-material-frosted-bg); --_border: var(--nv-material-frosted-border); --_shadow: var(--nv-material-frosted-shadow); + --_sheen: none; backdrop-filter: var(--nv-backdrop-frosted); -webkit-backdrop-filter: var(--nv-backdrop-frosted); } @@ -76,20 +94,75 @@ @media (hover: hover) { .nv-card--interactive:hover { - box-shadow: var(--nv-highlight-top), var(--nv-elevation-3); + box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3), var(--_glow); translate: 0 calc(var(--nv-lift-hover) * 2); } } .nv-card--interactive:active { - box-shadow: var(--nv-highlight-top), var(--nv-elevation-1); + box-shadow: var(--_lit), var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); translate: 0 var(--nv-press-depth); transition-duration: var(--nv-duration-instant); } .nv-card--interactive:focus-visible { outline: none; - box-shadow: var(--_shadow), var(--nv-focus-ring); + box-shadow: var(--_lit), var(--_shadow), var(--_glow), var(--nv-focus-ring); +} + +/* --- Light ------------------------------------------------------------------ + Three ways a card can carry light, each stated once and combinable with + every elevation above. They all take the card's tint — the accent unless + --nv-card-tint names another color, the way --nv-bed-tint does for a bed. + + Lit: a strip of light along the top edge, spilling a little way down + the face. For a card that belongs to something with a color: a + project, a status, a category. + Glow: the card gives off its tint. For the one card that is on — the + current plan, the live item. Two glowing cards side by side + cancel each other out. + Spotlight: a soft light follows the pointer across the face (niveus.js + tracks it). A showcase effect; a list of work items does not + need one. */ + +.nv-card--lit, +.nv-card--glow, +.nv-card--spotlight { + --nv-card-tint: var(--nv-color-accent); +} + +.nv-card--lit { + --_lit: + inset 0 2px 0 color-mix(in oklab, var(--nv-card-tint) 85%, transparent), + inset 0 18px 28px -22px color-mix(in oklab, var(--nv-card-tint) 70%, transparent); + --_sheen: + radial-gradient( + 120% 70% at 0% 0%, + color-mix(in oklab, var(--nv-card-tint) 12%, transparent), + transparent 70% + ), + var(--nv-sheen-surface); +} + +.nv-card--glow { + --_border: color-mix(in oklab, var(--nv-card-tint) 40%, var(--nv-color-border-subtle)); + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) + color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent); +} + +@media (hover: hover) { + .nv-card--glow.nv-card--interactive:hover { + --_glow: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-md) + color-mix(in oklab, var(--nv-card-tint) var(--nv-glow-strength), transparent); + } + + .nv-card--spotlight:hover { + --_spot: radial-gradient( + 22rem circle at var(--nv-pointer-x, 50%) var(--nv-pointer-y, 0%), + color-mix(in oklab, var(--nv-card-tint) 14%, var(--nv-color-sheen-top)), + transparent 70% + ); + } } /* Cards whose whole area is clickable via a link inside: the link's target is diff --git a/docs/Website/niveus/niveus/components/deck.css b/docs/Website/niveus/niveus/components/deck.css new file mode 100644 index 0000000..3131d0b --- /dev/null +++ b/docs/Website/niveus/niveus/components/deck.css @@ -0,0 +1,55 @@ +/* ========================================================================== + Niveus — Deck + -------------------------------------------------------------------------- +
    +
    …
    + + +
    + + One surface in front of others of its kind: the current item of a set that + has more behind it. The layers are cards seen edge-on, stepping back and + dimming with distance, so the set has depth without anything having to + move. Use it where "there are more of these" is part of the message — a + showcase of products, a stack of notifications, a carousel at rest. + + Everything in the deck shares one grid cell. The front surface is any + element; the layers are decorative and hidden from assistive technology. + Panels that swap (an .nv-tab-panel set) can live in the deck together: + the hidden ones take no space. + ========================================================================== */ + +.nv-deck { + --_step: var(--nv-space-3); + + display: grid; + position: relative; + isolation: isolate; + /* Room above the front surface for the layers that peek out behind it. */ + padding-block-start: calc(var(--_step) * 2); +} + +.nv-deck > * { + grid-area: 1 / 1; + min-inline-size: 0; +} + +.nv-deck__layer { + --_depth: 1; + + z-index: -1; + pointer-events: none; + background-color: color-mix(in oklab, var(--nv-color-surface) 90%, transparent); + background-image: var(--nv-sheen-surface); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); + translate: 0 calc(var(--_step) * var(--_depth) * -1); + scale: calc(1 - var(--_depth) * 0.05) 1; + opacity: calc(1 - var(--_depth) * 0.3); +} + +.nv-deck__layer + .nv-deck__layer { + --_depth: 2; + z-index: -2; +} diff --git a/docs/Website/niveus/niveus/components/dialog.css b/docs/Website/niveus/niveus/components/dialog.css index 3c048a0..8bdf3d9 100644 --- a/docs/Website/niveus/niveus/components/dialog.css +++ b/docs/Website/niveus/niveus/components/dialog.css @@ -23,11 +23,17 @@ max-block-size: min(42rem, calc(100svh - var(--nv-space-12))); padding: 0; - background-color: var(--nv-color-surface-elevated); + /* The dialog sits over a dimmed, blurred page, so it can afford to let a + little of it through: it reads as a pane lifted off the page rather than + a card pasted over it. */ + background-color: color-mix(in oklab, var(--nv-color-surface-elevated) 86%, transparent); + background-image: var(--nv-sheen-surface); + backdrop-filter: var(--nv-backdrop-frosted); + -webkit-backdrop-filter: var(--nv-backdrop-frosted); color: var(--nv-color-text-primary); border: var(--nv-border-width) solid var(--nv-color-border-subtle); border-radius: var(--nv-radius-xl); - box-shadow: var(--nv-highlight-top), var(--nv-elevation-4); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-4); overflow: hidden; diff --git a/docs/Website/niveus/niveus/components/disclosure.css b/docs/Website/niveus/niveus/components/disclosure.css index f5fca76..ecd7e77 100644 --- a/docs/Website/niveus/niveus/components/disclosure.css +++ b/docs/Website/niveus/niveus/components/disclosure.css @@ -20,9 +20,10 @@ --_padding: var(--nv-space-4); background-color: var(--nv-color-surface); + background-image: var(--nv-sheen-surface); border: var(--nv-border-width) solid var(--nv-color-border-subtle); border-radius: var(--nv-radius-lg); - box-shadow: var(--nv-highlight-top), var(--nv-elevation-1); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); transition: var(--nv-transition-surface); } diff --git a/docs/Website/niveus/niveus/components/input.css b/docs/Website/niveus/niveus/components/input.css index d84eae6..cb880f3 100644 --- a/docs/Website/niveus/niveus/components/input.css +++ b/docs/Website/niveus/niveus/components/input.css @@ -8,9 +8,9 @@ .nv-checkbox / .nv-radio custom marks on the native controls .nv-switch binary control with an inset track - Inputs are the system's inset material: they read as recessed into the - surface that holds them, which is what distinguishes a place to type from a - thing to press. On focus the recess stays — only the ring and border change, + Inputs are the system's inset material: they read as wells cut into the + surface that holds them — shaded inside, lit along the lower lip — which is + what distinguishes a place to type from a thing to press. On focus the recess stays — only the ring and border change, so the field never appears to jump forward. ========================================================================== */ @@ -59,12 +59,14 @@ min-inline-size: 0; block-size: var(--_height); padding-inline: var(--_padding); - - background-color: var(--nv-color-surface-inset); + /* A well in dark, where recess reads as shade; paper in light, where a grey + well reads as a field that has been switched off. The inset shadow and + ledge carry the recess either way. */ + background-color: light-dark(var(--nv-color-surface), var(--nv-color-surface-inset)); color: var(--nv-color-text-primary); border: var(--nv-border-width) solid var(--_border); border-radius: var(--_radius); - box-shadow: var(--nv-elevation-inset-sm); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); font-family: var(--nv-font-family-sans); font-size: var(--_font-size); @@ -87,7 +89,7 @@ .nv-input:focus { outline: none; --_border: var(--nv-color-accent); - box-shadow: var(--nv-elevation-inset-sm), var(--nv-focus-ring); + box-shadow: var(--nv-elevation-inset-md), var(--nv-focus-ring); } .nv-input[aria-invalid="true"] { @@ -134,7 +136,13 @@ textarea.nv-input { padding: 0; } -.nv-input--color::-webkit-color-swatch, +.nv-input--color::-webkit-color-swatch { + border: var(--nv-border-width) solid var(--nv-color-border); + border-radius: calc(var(--nv-radius-md) - var(--nv-space-1)); +} + +/* Separate rules on purpose: a selector list that names another engine's + pseudo-element is invalid as a whole, and each browser would drop both. */ .nv-input--color::-moz-color-swatch { border: var(--nv-border-width) solid var(--nv-color-border); border-radius: calc(var(--nv-radius-md) - var(--nv-space-1)); @@ -227,13 +235,16 @@ textarea.nv-input { .nv-radio { position: relative; flex: none; + /* The marks are drawn in em; pinning the size keeps them the same whatever + the label around them is set in. */ + font-size: var(--nv-font-size-md); inline-size: var(--nv-space-5); block-size: var(--nv-space-5); margin: 0; background-color: var(--nv-color-surface-inset); border: var(--nv-border-width) solid var(--nv-color-border-strong); border-radius: var(--nv-radius-xs); - box-shadow: var(--nv-elevation-inset-sm); + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); cursor: pointer; appearance: none; transition: var(--nv-transition-control); @@ -247,8 +258,9 @@ textarea.nv-input { .nv-radio:checked, .nv-checkbox:indeterminate { background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); border-color: transparent; - box-shadow: var(--nv-highlight-top), var(--nv-elevation-1); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); } /* The marks are always present, at zero size and zero opacity, so checking a @@ -323,8 +335,10 @@ textarea.nv-input { } /* --- Switch -------------------------------------------------------------------------- - The track is inset and the thumb is elevated: the physical relationship does - the explaining, so the control reads as on or off without a label. */ + The track is a slot cut into the surface and the thumb is a knob riding in + it: the physical relationship does the explaining, so the control reads as + on or off without a label. The slot stays recessed when it fills with color + — switching on lights the slot, it does not raise it. */ .nv-switch { --_track-inline: calc(var(--nv-space-6) * 1.5); @@ -344,7 +358,7 @@ textarea.nv-input { background-color: var(--nv-color-surface-inset); border: var(--nv-border-width) solid var(--nv-color-border); border-radius: var(--nv-radius-full); - box-shadow: var(--nv-elevation-inset-sm); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); cursor: pointer; appearance: none; transition: var(--nv-transition-control); @@ -358,9 +372,10 @@ textarea.nv-input { inline-size: var(--_thumb); block-size: var(--_thumb); translate: 0 -50%; - background-color: var(--nv-color-surface-elevated); + background-color: var(--nv-color-knob); + background-image: var(--nv-sheen-knob); border-radius: var(--nv-radius-full); - box-shadow: var(--nv-highlight-top), var(--nv-elevation-2); + box-shadow: var(--nv-outline), var(--nv-elevation-2); transition: translate var(--nv-duration-travel) var(--nv-ease-emphasized), background-color var(--nv-duration-fast) var(--nv-ease-standard); @@ -369,6 +384,7 @@ textarea.nv-input { .nv-switch:checked { background-color: var(--nv-color-accent); border-color: transparent; + box-shadow: var(--nv-elevation-inset-md), var(--nv-outline); } .nv-switch:checked::after { @@ -386,7 +402,10 @@ textarea.nv-input { display: flex; align-items: center; gap: var(--nv-space-2-5); + /* Labelled at control size, so a row of switches and checkboxes sits level + with the buttons and fields beside it. */ font: var(--nv-font-body); + font-size: var(--nv-font-size-sm); cursor: pointer; } diff --git a/docs/Website/niveus/niveus/components/menu.css b/docs/Website/niveus/niveus/components/menu.css index 35f6636..8bddbef 100644 --- a/docs/Website/niveus/niveus/components/menu.css +++ b/docs/Website/niveus/niveus/components/menu.css @@ -43,9 +43,10 @@ margin-block-start: var(--nv-space-2); background-color: var(--nv-material-frosted-bg); + background-image: var(--nv-sheen-surface); border: var(--nv-border-width) solid var(--nv-material-frosted-border); - border-radius: var(--nv-radius-md); - box-shadow: var(--nv-highlight-top), var(--nv-elevation-3); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-material-frosted-shadow); backdrop-filter: var(--nv-backdrop-frosted); -webkit-backdrop-filter: var(--nv-backdrop-frosted); color: var(--nv-color-text-primary); diff --git a/docs/Website/niveus/niveus/components/navigation.css b/docs/Website/niveus/niveus/components/navigation.css index c9b7f0c..267ed61 100644 --- a/docs/Website/niveus/niveus/components/navigation.css +++ b/docs/Website/niveus/niveus/components/navigation.css @@ -8,8 +8,8 @@ .nv-nav-tabs in-page tab strip with a sliding indicator Navigation is chrome: it sits above content rather than in it. That is why - the bar is frosted — the blur is not decoration, it is the signal that - content is passing underneath. A sidebar that scrolls with the page is not + the bar is frosted and bevelled — the blur is not decoration, it is the + signal that content is passing underneath. A sidebar that scrolls with the page is not chrome, so .nv-nav--vertical drops the translucency by default. ========================================================================== */ @@ -20,18 +20,19 @@ display: flex; align-items: center; - gap: var(--nv-space-4); + flex-wrap: wrap; + gap: var(--nv-space-2) var(--nv-space-4); padding: var(--nv-space-2) var(--nv-space-4); min-block-size: var(--nv-control-height-lg); - background-color: var(--nv-material-frosted-bg); + background-image: var(--nv-sheen-raised); border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle); /* The travelling selection marker sits behind its item at z-index -1; the bar has to be its own stacking context or the marker would fall behind the bar's background and disappear. */ isolation: isolate; - box-shadow: var(--nv-highlight-top); + box-shadow: var(--nv-bevel); backdrop-filter: var(--nv-backdrop-frosted); -webkit-backdrop-filter: var(--nv-backdrop-frosted); transition: var(--nv-transition-surface); @@ -40,12 +41,13 @@ /* The bar only earns a shadow once something is behind it. Set data-scrolled from the application on scroll. */ .nv-nav[data-scrolled] { - box-shadow: var(--nv-highlight-top), var(--nv-elevation-2); + box-shadow: var(--nv-bevel), var(--nv-elevation-2); transition: box-shadow var(--nv-duration-normal) var(--nv-ease-standard); } .nv-nav--plain { background-color: var(--nv-color-surface); + background-image: var(--nv-sheen-raised); backdrop-filter: none; -webkit-backdrop-filter: none; } @@ -64,6 +66,7 @@ background-color: transparent; border-block-end: 0; + background-image: none; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; @@ -88,6 +91,15 @@ padding: 0; list-style: none; min-inline-size: 0; + /* On a narrow screen a long list scrolls inside the bar instead of pushing + the page sideways. */ + max-inline-size: 100%; + overflow-x: auto; + scrollbar-width: none; +} + +.nv-nav__list::-webkit-scrollbar { + display: none; } .nv-nav__spacer { @@ -169,14 +181,23 @@ z-index: -1; background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); border: var(--nv-border-width) solid var(--nv-color-border-subtle); border-radius: inherit; - box-shadow: var(--nv-highlight-top), var(--nv-elevation-1); - - view-transition-name: var(--nv-selection-name); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1); view-transition-class: nv-travelling; } +/* The marker is named only while its own group is changing (niveus.js sets + data-nv-travelling for the length of the transition). A permanent name + would lift every marker on the page out of the page snapshot on every + transition, theme changes included. */ +[data-nv-travelling] .nv-nav__item[aria-current]::before, +[data-nv-travelling] .nv-nav__item--active::before, +[data-nv-travelling] .nv-nav__item[aria-selected="true"]::before { + view-transition-name: var(--nv-selection-name); +} + /* Hovering an already-selected item should not restate the emphasis. */ @media (hover: hover) { .nv-nav__item[aria-current]:hover, @@ -226,11 +247,15 @@ gap: var(--nv-space-1); inline-size: 15rem; block-size: 100svh; + flex-wrap: nowrap; padding: var(--nv-space-4) var(--nv-space-3); - - background-color: var(--nv-color-background-subtle); - backdrop-filter: none; - -webkit-backdrop-filter: none; + /* A sidebar is a pane of the canvas rather than chrome over content, so it + stays mostly opaque — but it lets the light of the canvas through, which + is what stops it reading as a grey slab beside the page. */ + background-color: color-mix(in oklab, var(--nv-color-background-subtle) 72%, transparent); + background-image: var(--nv-sheen-surface); + backdrop-filter: var(--nv-backdrop-frosted); + -webkit-backdrop-filter: var(--nv-backdrop-frosted); border-block-end: 0; border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle); box-shadow: none; @@ -252,11 +277,11 @@ margin-block-end: var(--nv-space-3); } +/* Engraved, like .nv-overline: a section marks a region, it is not read. */ .nv-nav__section { padding: var(--nv-space-4) var(--nv-space-3) var(--nv-space-1-5); - font: var(--nv-font-caption); - font-weight: var(--nv-font-weight-semibold); - letter-spacing: var(--nv-letter-spacing-wider); + font: var(--nv-font-engraved); + letter-spacing: var(--nv-letter-spacing-engraved); text-transform: uppercase; color: var(--nv-color-text-tertiary); } @@ -272,13 +297,13 @@ .nv-nav-tabs { display: inline-flex; align-items: center; + max-inline-size: 100%; gap: var(--nv-space-0-5); padding: var(--nv-space-1); - - background-color: var(--nv-color-surface-inset); + background-color: color-mix(in oklab, var(--nv-color-surface-inset) 80%, transparent); border: var(--nv-border-width) solid var(--nv-color-border-subtle); border-radius: var(--nv-radius-md); - box-shadow: var(--nv-elevation-inset-sm); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); overflow-x: auto; scrollbar-width: none; isolation: isolate; @@ -307,12 +332,12 @@ /* --- Panels ---------------------------------------------------------------------- Switching tabs should feel like moving through one surface rather than - swapping the interface. The panel carries the shared content name, so the - outgoing and incoming panels cross-fade inside the same box — and slide - with the direction of travel when the application supplies one. */ - + swapping the interface. nv.selectTab() gives the outgoing and incoming + panels one shared name for the length of the change, so they hand over + inside the same box — and slide with the direction of travel. The class is + what picks up the content animation in continuity.css. */ .nv-tab-panel { - view-transition-name: var(--nv-content-name, nv-content); + view-transition-class: nv-content; } /* --- Breadcrumbs -------------------------------------------------------------------- */ diff --git a/docs/Website/niveus/niveus/components/skeleton.css b/docs/Website/niveus/niveus/components/skeleton.css new file mode 100644 index 0000000..a79aec6 --- /dev/null +++ b/docs/Website/niveus/niveus/components/skeleton.css @@ -0,0 +1,60 @@ +/* ========================================================================== + Niveus — Skeleton + -------------------------------------------------------------------------- + + + The shape of content that has not arrived yet. A skeleton is a shallow well + cut where the content will sit, with a slow band of light crossing it — the + surface is recessed and waiting, not broken. + + .nv-skeleton a block; size it with the layout + .nv-skeleton--text one line of body text + .nv-skeleton--circle an avatar or a tile + + Under reduced motion the light stops crossing and the well stays: the state + is still legible, it just does not move. + ========================================================================== */ + +.nv-skeleton { + display: block; + min-block-size: var(--nv-space-4); + border-radius: var(--nv-radius-md); + background-color: var(--nv-color-surface-inset); + background-image: linear-gradient( + 100deg, + transparent 30%, + var(--nv-color-sheen-top) 50%, + transparent 70% + ); + background-size: 220% 100%; + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + animation: nv-shimmer var(--nv-duration-shimmer) var(--nv-ease-linear) infinite; +} + +.nv-skeleton--text { + inline-size: 100%; + min-block-size: 0; + block-size: 0.75em; + border-radius: var(--nv-radius-full); +} + +/* A paragraph of skeleton lines ends short, like the paragraph it stands for. */ +.nv-skeleton--text:last-child:not(:first-child) { + inline-size: 60%; +} + +.nv-skeleton--circle { + aspect-ratio: 1; + border-radius: var(--nv-radius-full); +} + +@keyframes nv-shimmer { + from { background-position: 100% 0; } + to { background-position: -120% 0; } +} + +@media (prefers-reduced-motion: reduce) { + .nv-skeleton { + animation: none; + } +} diff --git a/docs/Website/niveus/niveus/components/stat.css b/docs/Website/niveus/niveus/components/stat.css new file mode 100644 index 0000000..7a140f0 --- /dev/null +++ b/docs/Website/niveus/niveus/components/stat.css @@ -0,0 +1,74 @@ +/* ========================================================================== + Niveus — Stat + -------------------------------------------------------------------------- +
    +
    +
    Servers
    +
    1,204
    +
    +
    + + A number the user is meant to read at a glance. A group of stats is set into + one shallow well, like the readout panel of an instrument: the figures sit + behind the surface, separated by hairlines, so a row of numbers reads as one + display instead of as four loose labels. + + .nv-stat-group the readout well; wraps without a media query + .nv-stat one reading + .nv-stat__label what is measured + .nv-stat__value the figure, in tabular numerals + .nv-stat__delta change since last time; --up / --down + ========================================================================== */ + +.nv-stat-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr)); + /* The hairlines between readings are the well itself showing through a + one-pixel gap, so they stay continuous however the row wraps. */ + gap: var(--nv-border-width); + margin: 0; + background-color: var(--nv-color-border-subtle); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-lg); + box-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); + overflow: hidden; +} + +.nv-stat { + display: flex; + flex-direction: column; + gap: var(--nv-space-1); + min-inline-size: 0; + padding: var(--nv-space-4) var(--nv-space-5); +} + +.nv-stat-group > .nv-stat { + background-color: var(--nv-color-surface-inset); +} + +.nv-stat__label { + font: var(--nv-font-caption); + color: var(--nv-color-text-tertiary); +} + +.nv-stat__value { + margin: 0; + font: var(--nv-font-heading); + font-variant-numeric: tabular-nums; + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); +} + +.nv-stat__delta { + font: var(--nv-font-caption); + font-variant-numeric: tabular-nums; + color: var(--nv-color-text-secondary); +} + +.nv-stat__delta--up { + color: var(--nv-color-success); +} + +.nv-stat__delta--down { + color: var(--nv-color-danger); +} diff --git a/docs/Website/niveus/niveus/components/steps.css b/docs/Website/niveus/niveus/components/steps.css new file mode 100644 index 0000000..6aa4598 --- /dev/null +++ b/docs/Website/niveus/niveus/components/steps.css @@ -0,0 +1,157 @@ +/* ========================================================================== + Niveus — Steps + -------------------------------------------------------------------------- +
      +
    1. + 1 +
      +

      Listen

      +

      …

      +
      +
    2. +
    + + A sequence the user moves through: a process, an onboarding, an order's + progress. The steps sit on a rail — a groove cut into the surface — with + each marker a raised knob set into it. Finished steps light their stretch + of the rail, so progress reads as something filling up rather than as a + row of ticks. + + .nv-steps horizontal; becomes vertical on narrow screens + .nv-steps--vertical always vertical + data-state="done" the step and its stretch of rail are lit + aria-current="step" the step in progress: lit marker, rail still dark + ========================================================================== */ + +.nv-steps { + --_marker: 2.25rem; + --_rail: 6px; + --_gap: var(--nv-space-4); + + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + gap: var(--_gap); + margin: 0; + padding: 0; + list-style: none; +} + +.nv-steps__item { + position: relative; + display: flex; + flex-direction: column; + gap: var(--nv-space-3); + min-inline-size: 0; +} + +/* Each step draws the rail from its own marker to the next one, so a finished + step can light exactly its stretch and the rail never runs past the end. */ +.nv-steps__item:not(:last-child)::before { + content: ""; + position: absolute; + inset-block-start: calc((var(--_marker) - var(--_rail)) / 2); + inset-inline-start: calc(var(--_marker) / 2); + inline-size: calc(100% + var(--_gap)); + block-size: var(--_rail); + border-radius: var(--nv-radius-full); + background-color: var(--nv-color-surface-inset); + box-shadow: var(--nv-elevation-inset-sm), var(--nv-ledge); + transition: var(--nv-transition-surface); +} + +.nv-steps__item[data-state="done"]:not(:last-child)::before { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + box-shadow: var(--nv-glow-sm); +} + +.nv-steps__marker { + position: relative; + z-index: 1; + display: grid; + place-items: center; + flex: none; + inline-size: var(--_marker); + block-size: var(--_marker); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + color: var(--nv-color-text-secondary); + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + font-variant-numeric: tabular-nums; + border-radius: var(--nv-radius-full); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); + transition: var(--nv-transition-surface); +} + +.nv-steps__item[data-state="done"] > .nv-steps__marker, +.nv-steps__item[aria-current="step"] > .nv-steps__marker { + background-color: var(--nv-color-accent); + background-image: var(--nv-sheen-filled); + color: var(--nv-color-on-accent); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2), var(--nv-glow-md); +} + +.nv-steps__body { + display: flex; + flex-direction: column; + gap: var(--nv-space-1); + min-inline-size: 0; +} + +.nv-steps__title { + margin: 0; + font: var(--nv-font-title); + letter-spacing: var(--nv-letter-spacing-tight); + color: var(--nv-color-text-primary); +} + +.nv-steps__text { + margin: 0; + font: var(--nv-font-caption); + color: var(--nv-color-text-secondary); +} + +/* --- Vertical ------------------------------------------------------------------- + Marker beside the text and the rail running down between markers. Also what + a horizontal sequence becomes once its columns are too narrow to read. */ + +.nv-steps--vertical { + grid-auto-flow: row; + grid-auto-columns: auto; + --_gap: var(--nv-space-5); +} + +.nv-steps--vertical > .nv-steps__item { + flex-direction: row; + gap: var(--nv-space-4); +} + +.nv-steps--vertical > .nv-steps__item:not(:last-child)::before { + inset-block-start: calc(var(--_marker) / 2); + inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2); + inline-size: var(--_rail); + block-size: calc(100% + var(--_gap)); +} + +@media (max-width: 40rem) { + .nv-steps { + grid-auto-flow: row; + grid-auto-columns: auto; + --_gap: var(--nv-space-5); + } + + .nv-steps > .nv-steps__item { + flex-direction: row; + gap: var(--nv-space-4); + } + + .nv-steps > .nv-steps__item:not(:last-child)::before { + inset-block-start: calc(var(--_marker) / 2); + inset-inline-start: calc((var(--_marker) - var(--_rail)) / 2); + inline-size: var(--_rail); + block-size: calc(100% + var(--_gap)); + } +} diff --git a/docs/Website/niveus/niveus/components/tile.css b/docs/Website/niveus/niveus/components/tile.css new file mode 100644 index 0000000..f8083b7 --- /dev/null +++ b/docs/Website/niveus/niveus/components/tile.css @@ -0,0 +1,83 @@ +/* ========================================================================== + Niveus — Tile + -------------------------------------------------------------------------- + + + A tile is a small raised key that holds one glyph: the icon beside a + feature, the mark of a product, the symbol on a stat. It is the physical + version of an icon — something with an edge you could press — which is why + a row of features reads as a set of objects rather than as clip art. + + .nv-tile soft key: tinted face, the glyph in its tint + .nv-tile--solid filled key: saturated face, a light glyph + .nv-tile--glow the key is lit from inside + .nv-tile--sm / --lg / --xl + + Everything derives from --nv-tile-tint, the accent unless set. A tile is + not a button: if it does something, it is an .nv-button--icon. + ========================================================================== */ + +.nv-tile { + --nv-tile-tint: var(--nv-color-accent); + --_size: 2.75rem; + --_radius: var(--nv-radius-md); + --_bg: color-mix(in oklab, var(--nv-tile-tint) 16%, var(--nv-color-surface-elevated)); + --_ink: color-mix(in oklab, var(--nv-tile-tint) 70%, var(--nv-color-text-primary)); + --_sheen: var(--nv-sheen-raised); + --_glow: 0 0 transparent; + + position: relative; + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--_size); + block-size: var(--_size); + + background-color: var(--_bg); + background-image: var(--_sheen); + color: var(--_ink); + border: var(--nv-border-width) solid color-mix(in oklab, var(--nv-tile-tint) 30%, var(--nv-color-border-subtle)); + border-radius: var(--_radius); + box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1), var(--_glow); + transition: var(--nv-transition-surface); +} + +/* Glyphs take half the key; a mark that is itself a picture (an image, a + logo) fills it, clipped to the key's corners. */ +.nv-tile > svg { + inline-size: 50%; + block-size: 50%; +} + +.nv-tile > img { + inline-size: 100%; + block-size: 100%; + border-radius: inherit; + object-fit: cover; +} + +.nv-tile--solid { + --_bg: var(--nv-tile-tint); + --_ink: var(--nv-color-on-accent); + --_sheen: var(--nv-sheen-filled); +} + +.nv-tile--glow { + --_glow: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-sm) + color-mix(in oklab, var(--nv-tile-tint) var(--nv-glow-strength), transparent); +} + +.nv-tile--sm { + --_size: 2rem; + --_radius: var(--nv-radius-sm); +} + +.nv-tile--lg { + --_size: 3.5rem; + --_radius: var(--nv-radius-lg); +} + +.nv-tile--xl { + --_size: 4.5rem; + --_radius: var(--nv-radius-xl); +} diff --git a/docs/Website/niveus/niveus/components/window.css b/docs/Website/niveus/niveus/components/window.css new file mode 100644 index 0000000..9551359 --- /dev/null +++ b/docs/Website/niveus/niveus/components/window.css @@ -0,0 +1,192 @@ +/* ========================================================================== + Niveus — Window + -------------------------------------------------------------------------- + .nv-window a framed application window + .nv-window--inactive a window that does not have focus + .nv-window__titlebar controls · title · trailing actions + .nv-window__controls the three lights, at the start of the bar + .nv-window__control one light: --close, --minimize or --zoom + .nv-window__title the window's name, centered on the bar + .nv-window__actions trailing slot for a few ghost buttons + .nv-window__body the content area + +
    +
    +
    + + + +
    + Settings +
    +
    …
    +
    + + A window is a solid object: the content it holds never sits on glass. Its + titlebar is a raised strip lit like a control, and the controls are three + lights at its start — close, minimize, zoom — always in that order and in + those colors, because people find them by position and color before they + read anything. Their glyphs appear only while the pointer is over the + group. An inactive window greys its lights and steps back one elevation, + and the lights regain their color the moment they are hovered. + ========================================================================== */ + +.nv-window { + display: flex; + flex-direction: column; + min-inline-size: 0; + + background-color: var(--nv-color-surface); + background-image: var(--nv-sheen-surface); + color: var(--nv-color-text-primary); + border: var(--nv-border-width) solid var(--nv-color-border-subtle); + border-radius: var(--nv-radius-xl); + box-shadow: var(--nv-highlight-top), var(--nv-outline), var(--nv-elevation-4); + + overflow: hidden; + transition: var(--nv-transition-surface); +} + +.nv-window--inactive { + box-shadow: var(--nv-highlight-top), var(--nv-outline), var(--nv-elevation-3); +} + +/* --- Titlebar --------------------------------------------------------------- + Three columns with the outer two sharing the slack, so the title stays on + the window's center line whatever the controls and actions measure. */ + +.nv-window__titlebar { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: var(--nv-space-3); + /* Slim, as titlebars on the desktop are: tall enough for the lights and + a label, not a toolbar's height. */ + min-block-size: calc(var(--nv-control-height-sm) + var(--nv-space-1)); + padding-inline: var(--nv-space-3); + + background-color: var(--nv-color-surface-elevated); + background-image: var(--nv-sheen-raised); + border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle); + box-shadow: var(--nv-highlight-top); + user-select: none; +} + +.nv-window__title { + grid-column: 2; + min-inline-size: 0; + overflow: hidden; + font: var(--nv-font-label); + font-weight: var(--nv-font-weight-semibold); + color: var(--nv-color-text-primary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.nv-window--inactive .nv-window__title { + color: var(--nv-color-text-tertiary); +} + +.nv-window__actions { + grid-column: 3; + justify-self: end; + display: flex; + align-items: center; + gap: var(--nv-space-1); +} + +/* --- Controls ----------------------------------------------------------------- */ + +.nv-window__controls { + grid-column: 1; + justify-self: start; + display: flex; + align-items: center; + gap: var(--nv-space-2); +} + +.nv-window__control { + --_light: var(--nv-color-window-close); + --_c: var(--_light); + + position: relative; + flex: none; + inline-size: var(--nv-space-3); + block-size: var(--nv-space-3); + margin: 0; + padding: 0; + + background-color: var(--_c); + background-image: var(--nv-sheen-filled); + border: 0; + border-radius: var(--nv-radius-full); + box-shadow: inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring); + + cursor: pointer; + appearance: none; + -webkit-tap-highlight-color: transparent; + transition: var(--nv-transition-colors); +} + +.nv-window__control--minimize { --_light: var(--nv-color-window-minimize); } +.nv-window__control--zoom { --_light: var(--nv-color-window-zoom); } + +/* The glyph is two bars drawn from the light's center: crossed for close, one + for minimize, a plus for zoom. They are always present and fade in, so the + whole group reveals at once instead of one glyph at a time. */ +.nv-window__control::before, +.nv-window__control::after { + content: ""; + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; + inline-size: 55%; + block-size: calc(var(--nv-border-width) * 1.5); + translate: -50% -50%; + background-color: var(--nv-color-window-glyph); + border-radius: var(--nv-radius-full); + opacity: 0; + transition: opacity var(--nv-duration-fast) var(--nv-ease-standard); +} + +.nv-window__control--close::before { rotate: 45deg; } +.nv-window__control--close::after { rotate: -45deg; } +.nv-window__control--minimize::after { display: none; } +.nv-window__control--zoom::after { rotate: 90deg; } + +.nv-window__controls:hover .nv-window__control::before, +.nv-window__controls:hover .nv-window__control::after, +.nv-window__controls:focus-within .nv-window__control::before, +.nv-window__controls:focus-within .nv-window__control::after { + opacity: 1; +} + +.nv-window__control:active { + background-image: none; + box-shadow: + inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring), + var(--nv-elevation-inset-sm); +} + +.nv-window__control:focus-visible { + outline: none; + box-shadow: + inset 0 0 0 var(--nv-border-width) var(--nv-color-window-control-ring), + var(--nv-focus-ring); +} + +.nv-window--inactive .nv-window__control { + --_c: var(--nv-color-window-inactive); +} + +.nv-window--inactive .nv-window__controls:hover .nv-window__control { + --_c: var(--_light); +} + +/* --- Body --------------------------------------------------------------------- */ + +.nv-window__body { + flex: 1; + min-block-size: 0; + overflow: auto; +} diff --git a/docs/Website/niveus/niveus/continuity.css b/docs/Website/niveus/niveus/continuity.css index 772a754..98fde02 100644 --- a/docs/Website/niveus/niveus/continuity.css +++ b/docs/Website/niveus/niveus/continuity.css @@ -60,9 +60,9 @@ The fix is to capture the labels too and order the groups explicitly. The marker travels below; the items ride above it, exactly as they do at rest. - niveus.js assigns each item its name — they have to be unique per document, - which no stylesheet can guarantee — and items it has not named simply stay in - the page snapshot. + niveus.js names the items of the group that is changing, and only while it + changes — names have to be unique per document, which no stylesheet can + guarantee — and every other item simply stays in the page snapshot. Because the layering is now correct, the marker needs no trick to stay out of the way: it travels at full opacity, like the solid object it is. */ @@ -71,6 +71,16 @@ z-index: 0; } +/* The marker changes shape as it travels — a short label's pill becomes a + long one's. By default the browser scales each snapshot to the group's + width and keeps its aspect ratio, so mid-way a 40×26 pill stretched to + 80px wide is drawn 52px tall, hanging out below its strip. The marker is a + plain surface, so its snapshots fill the box instead. */ +::view-transition-old(.nv-travelling), +::view-transition-new(.nv-travelling) { + block-size: 100%; +} + ::view-transition-group(.nv-carried) { z-index: 1; } @@ -101,40 +111,56 @@ Moving forward and moving back should not look the same. The application sets data-nv-direction on the root for the length of the transition; content then leaves towards where the user came from and enters from where they are - going. Without a direction, content simply rises into place. */ + going. Without a direction, content simply rises into place. -::view-transition-old(nv-content) { - animation: nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both; + Content hands over; it does not cross-fade. Two blocks of text at half + opacity on top of each other read as a rendering fault, not as motion, so + the outgoing content is gone (fast) before the incoming arrives (delayed by + that same beat). The box itself never empties for longer than a blink. + + Any element whose content changes in place takes part by carrying the + nv-content class: .nv-continuous and .nv-tab-panel both do. + + The box clips what it holds. When a tall panel gives way to a short one, + the page around it has already reflowed to the new height, so unclipped + outgoing content would spill out below its own card. */ + +::view-transition-group(.nv-content) { + overflow: clip; } -::view-transition-new(nv-content) { - animation: - nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both, - nv-rise var(--nv-duration-normal) var(--nv-ease-entrance) both; +::view-transition-old(.nv-content) { + animation: nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both; } -:root[data-nv-direction="forward"]::view-transition-old(nv-content) { +::view-transition-new(.nv-content) { animation: - nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both, - nv-exit-to-start var(--nv-duration-normal) var(--nv-ease-emphasized) both; + nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both, + nv-rise var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both; } -:root[data-nv-direction="forward"]::view-transition-new(nv-content) { +:root[data-nv-direction="forward"]::view-transition-old(.nv-content) { animation: - nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both, - nv-enter-from-end var(--nv-duration-normal) var(--nv-ease-emphasized) both; + nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both, + nv-exit-to-start var(--nv-duration-fast) var(--nv-ease-exit) both; } -:root[data-nv-direction="backward"]::view-transition-old(nv-content) { +:root[data-nv-direction="forward"]::view-transition-new(.nv-content) { animation: - nv-fade-out var(--nv-duration-quick) var(--nv-ease-exit) both, - nv-exit-to-end var(--nv-duration-normal) var(--nv-ease-emphasized) both; + nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both, + nv-enter-from-end var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both; } -:root[data-nv-direction="backward"]::view-transition-new(nv-content) { +:root[data-nv-direction="backward"]::view-transition-old(.nv-content) { animation: - nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) both, - nv-enter-from-start var(--nv-duration-normal) var(--nv-ease-emphasized) both; + nv-fade-out var(--nv-duration-fast) var(--nv-ease-exit) both, + nv-exit-to-end var(--nv-duration-fast) var(--nv-ease-exit) both; +} + +:root[data-nv-direction="backward"]::view-transition-new(.nv-content) { + animation: + nv-fade-in var(--nv-duration-normal) var(--nv-ease-entrance) var(--nv-duration-fast) both, + nv-enter-from-start var(--nv-duration-normal) var(--nv-ease-emphasized) var(--nv-duration-fast) both; } @keyframes nv-fade-in { @@ -171,6 +197,34 @@ .nv-continuous { view-transition-name: var(--nv-content-name, nv-content); + view-transition-class: nv-content; +} + +/* --- Reveal ------------------------------------------------------------------------ + Content arriving as it scrolls into view: it rises a short way and fades in, + once, so a long page is read in the order it was written instead of all at + once. .nv-reveal-group does the same for its children one after another, + --nv-stagger apart. + + niveus.js marks what is waiting. Without the script, and under reduced + motion, nothing is ever marked — so nothing is ever hidden. */ + +.nv-reveal[data-nv-reveal="pending"], +.nv-reveal-group > [data-nv-reveal="pending"] { + opacity: 0; +} + +.nv-reveal[data-nv-reveal="shown"], +.nv-reveal-group > [data-nv-reveal="shown"] { + animation: nv-arrive var(--nv-duration-slower) var(--nv-ease-entrance) + calc(var(--nv-reveal-index, 0) * var(--nv-stagger)) both; +} + +@keyframes nv-arrive { + from { + opacity: 0; + translate: 0 var(--nv-travel-lg); + } } /* Collapsible region for markup that is not a
    . The 0fr -> 1fr grid diff --git a/docs/Website/niveus/niveus/fonts.css b/docs/Website/niveus/niveus/fonts.css new file mode 100644 index 0000000..16f861e --- /dev/null +++ b/docs/Website/niveus/niveus/fonts.css @@ -0,0 +1,42 @@ +/* ========================================================================== + Niveus — Fonts + -------------------------------------------------------------------------- + Niveus ships its own faces, so it reads the same wherever it is used + instead of depending on what a machine happens to have installed. Both are + variable fonts under the SIL Open Font License (fonts/OFL-*.txt), free to + bundle with anything. + + Instrument Sans the interface. Crisp, slightly narrow, warm at small + sizes — the voice of every label, control and heading. + JetBrains Mono code, figures, and the engraved labels (.nv-overline) + that mark sections the way markings mark an instrument. + + The paths are relative to this file and to dist/niveus.css alike: both sit + one level below the root, where fonts/ lives. + ========================================================================== */ + +@font-face { + font-family: "Instrument Sans"; + src: url("../fonts/InstrumentSans.woff2") format("woff2"); + font-weight: 400 700; + font-stretch: 75% 100%; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Instrument Sans"; + src: url("../fonts/InstrumentSans-Italic.woff2") format("woff2"); + font-weight: 400 700; + font-stretch: 75% 100%; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("../fonts/JetBrainsMono.woff2") format("woff2"); + font-weight: 100 800; + font-style: normal; + font-display: swap; +} diff --git a/docs/Website/niveus/niveus/niveus.css b/docs/Website/niveus/niveus/niveus.css index fa1425f..3f198b2 100644 --- a/docs/Website/niveus/niveus/niveus.css +++ b/docs/Website/niveus/niveus/niveus.css @@ -1,5 +1,5 @@ /* ========================================================================== - Niveus — a calm, adaptive interface language. + Niveus — a tactile, adaptive interface language. Made by Nexoniarz. -------------------------------------------------------------------------- The single entry point. An application links this file and nothing else: @@ -22,6 +22,9 @@ @layer niveus.tokens, niveus.theme, niveus.base, niveus.continuity, niveus.components, niveus.utilities; +/* --- Fonts: the faces Niveus ships, so it reads the same everywhere ---------- */ +@import url("fonts.css"); + /* --- Tokens: the values, appearance-agnostic ------------------------------- */ @import url("tokens/color.css") layer(niveus.tokens); @import url("tokens/typography.css") layer(niveus.tokens); @@ -54,6 +57,13 @@ @import url("components/bed.css") layer(niveus.components); @import url("components/badge.css") layer(niveus.components); @import url("components/table.css") layer(niveus.components); +@import url("components/window.css") layer(niveus.components); +@import url("components/tile.css") layer(niveus.components); +@import url("components/avatar.css") layer(niveus.components); +@import url("components/stat.css") layer(niveus.components); +@import url("components/steps.css") layer(niveus.components); +@import url("components/deck.css") layer(niveus.components); +@import url("components/skeleton.css") layer(niveus.components); /* --- Utilities ----------------------------------------------------------------- */ @import url("utilities.css") layer(niveus.utilities); diff --git a/docs/Website/niveus/niveus/niveus.js b/docs/Website/niveus/niveus/niveus.js index 5eb959a..144187e 100644 --- a/docs/Website/niveus/niveus/niveus.js +++ b/docs/Website/niveus/niveus/niveus.js @@ -13,7 +13,7 @@ state (a router, a framework) should call nv.transition() around its own update and ignore the rest. - nv.transition(update, { direction, kind }) run an update continuously + nv.transition(update, { direction, kind, scope }) run an update continuously nv.setTheme("dark" | "light" | "system") cross-fade the appearance nv.openDialog(dialog, trigger) open it out of its trigger nv.selectTab(tablist, tab) move through the same surface @@ -27,9 +27,16 @@ [data-nv-close] button that closes its dialog or popover [data-nv-scroller] frame whose [data-nv-scroll] controls move its .nv-scroller a page at a time + .nv-reveal / .nv-reveal-group content that arrives as it scrolls into + view, once (see continuity.css) + .nv-card--spotlight card whose light follows the pointer - Wiring also names every navigation item, which is what lets a travelling - selection marker pass *behind* the labels rather than over them. + Only what changes is lifted. A transition names the selection marker and + the items of the groups it is given as `scope`, and only for as long as it + runs; everything else stays in the one page snapshot. Naming every item on + the page permanently made every transition — a theme change included — + tear the page into dozens of layers, which is both slow and what pulled + items out from under frosted chrome. Reduced motion is honoured by skipping the view transition entirely: the update still runs, it simply arrives instead of travelling. @@ -51,7 +58,10 @@ /** * Run `update` so the user can follow the change. * `direction` ("forward" | "backward") tells content which way to travel; - * `kind` ("theme") marks a change the whole surface takes part in. + * `kind` ("theme") marks a change the whole surface takes part in; + * `scope` (an element or a list of them) names the selection groups whose + * marker should travel. Without a scope, nothing is lifted and the change + * cross-fades in place. */ function transition(update, options) { options = options || {}; @@ -63,11 +73,20 @@ if (options.direction) root.dataset.nvDirection = options.direction; if (options.kind) root.dataset.nvTransition = options.kind; + var groups = [].concat(options.scope || []).filter(Boolean); + var lifted = []; + groups.forEach(function (group) { + group.setAttribute("data-nv-travelling", ""); + lifted = lifted.concat(liftLabels(group)); + }); + var view = document.startViewTransition(update); function clear() { delete root.dataset.nvDirection; delete root.dataset.nvTransition; + groups.forEach(function (group) { group.removeAttribute("data-nv-travelling"); }); + lifted.forEach(function (item) { item.style.viewTransitionName = ""; }); } view.finished.then(clear, clear); @@ -146,16 +165,32 @@ } if (next < 0 || next === current) return Promise.resolve(); + // The outgoing and incoming panels carry one name between them — the old + // one in the old state, the new one in the new — so the browser treats + // them as one surface whose content changes. Named only for the length of + // the change, so any number of tab sets can live on one page. + var panelOf = function (candidate) { + return candidate ? document.getElementById(candidate.getAttribute("aria-controls")) : null; + }; + var outgoing = panelOf(tabs[current]); + var incoming = panelOf(tab); + if (outgoing && canTransition()) outgoing.style.viewTransitionName = "nv-panel"; + var done = transition(function () { tabs.forEach(function (candidate, index) { var selected = index === next; candidate.setAttribute("aria-selected", String(selected)); candidate.tabIndex = selected ? 0 : -1; - var panel = document.getElementById(candidate.getAttribute("aria-controls")); + var panel = panelOf(candidate); if (panel) panel.hidden = !selected; }); - }, { direction: next > current ? "forward" : "backward" }); + if (outgoing) outgoing.style.viewTransitionName = ""; + if (incoming && canTransition()) incoming.style.viewTransitionName = "nv-panel"; + }, { direction: next > current ? "forward" : "backward", scope: tablist }); + + var settle = function () { if (incoming) incoming.style.viewTransitionName = ""; }; + done.then(settle, settle); tab.focus(); return done; @@ -240,29 +275,84 @@ } /** - * Give every item in a selection group a view-transition name, so it is - * captured as its own layer and rides *above* the travelling marker instead - * of being painted over by it. + * Give every item in `scope` a view-transition name, so it is captured as + * its own layer and rides *above* the travelling marker instead of being + * painted over by it. Returns the items it named, so the caller can take + * the names back off; transition() does this for its `scope` and clears + * them when the change is over. * * This cannot live in CSS: a view-transition-name must be unique across the - * whole document, and a stylesheet cannot count how many navigations a page - * happens to contain. Items already carrying a name are left alone, so an - * application can assign its own. + * whole document, and a stylesheet cannot count items. Items already + * carrying a name are left alone, so an application can assign its own. */ var carried = 0; function liftLabels(scope) { - var groups = scope.querySelectorAll(".nv-nav, .nv-nav-tabs, [data-nv-travel]"); + var items = scope.querySelectorAll(".nv-nav__item"); + var named = []; - for (var i = 0; i < groups.length; i++) { - var items = groups[i].querySelectorAll(".nv-nav__item"); - - for (var j = 0; j < items.length; j++) { - if (items[j].style.viewTransitionName) continue; - carried += 1; - items[j].style.viewTransitionName = "nv-carried-" + carried; - } + for (var j = 0; j < items.length; j++) { + if (items[j].style.viewTransitionName) continue; + carried += 1; + items[j].style.viewTransitionName = "nv-carried-" + carried; + named.push(items[j]); } + return named; + } + + /** + * Reveal content as it scrolls into view. Elements are only marked as + * waiting here, so a page without this script — or under reduced motion — + * never hides anything. Each element arrives once and is then left alone. + */ + var revealer = null; + + function arrive(target) { + var items = target.classList.contains("nv-reveal-group") ? target.children : [target]; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + item.style.setProperty("--nv-reveal-index", String(i)); + item.dataset.nvReveal = "shown"; + item.addEventListener("animationend", function settle(event) { + if (event.target !== this || event.animationName !== "nv-arrive") return; + this.removeEventListener("animationend", settle); + delete this.dataset.nvReveal; + this.style.removeProperty("--nv-reveal-index"); + }); + } + } + + function initReveal(scope) { + if (prefersReducedMotion() || typeof global.IntersectionObserver !== "function") return; + + revealer = revealer || new IntersectionObserver(function (entries) { + entries.forEach(function (entry) { + if (!entry.isIntersecting) return; + revealer.unobserve(entry.target); + arrive(entry.target); + }); + }, { rootMargin: "0px 0px -8% 0px" }); + + scope.querySelectorAll(".nv-reveal, .nv-reveal-group").forEach(function (target) { + if (target.dataset.nvRevealWired) return; + target.dataset.nvRevealWired = ""; + var items = target.classList.contains("nv-reveal-group") ? target.children : [target]; + for (var i = 0; i < items.length; i++) items[i].dataset.nvReveal = "pending"; + revealer.observe(target); + }); + } + + /** + * The light on a .nv-card--spotlight sits under the pointer. One listener + * for the whole document, so cards added later need no wiring. + */ + function trackSpotlight(event) { + if (event.pointerType && event.pointerType !== "mouse") return; + var surface = event.target.closest && event.target.closest(".nv-card--spotlight"); + if (!surface) return; + var box = surface.getBoundingClientRect(); + surface.style.setProperty("--nv-pointer-x", event.clientX - box.left + "px"); + surface.style.setProperty("--nv-pointer-y", event.clientY - box.top + "px"); } function wire(scope) { @@ -282,7 +372,7 @@ scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs); scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller); - liftLabels(scope); + initReveal(scope); scope.querySelectorAll("[data-nv-close]").forEach(function (control) { control.addEventListener("click", function () { @@ -314,6 +404,8 @@ prefersReducedMotion: prefersReducedMotion, }; + document.addEventListener("pointermove", trackSpotlight, { passive: true }); + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", function () { wire(); }); } else { diff --git a/docs/Website/niveus/niveus/themes/dark.css b/docs/Website/niveus/niveus/themes/dark.css index 0b79aeb..0c41e85 100644 --- a/docs/Website/niveus/niveus/themes/dark.css +++ b/docs/Website/niveus/niveus/themes/dark.css @@ -5,11 +5,13 @@ light-dark() in tokens/color.css. Like the light appearance, almost nothing here is a literal color — every role is derived from --nv-brand. - Dark Niveus is not the light theme inverted. Surfaces get *lighter* as they - rise, shadows deepen rather than disappear, and the top highlight thins to a - faint rim so elevation still reads without glare. The accent moves up its - own ramp too: a mid-tone that reads as vivid on paper reads as muddy on a - dark canvas. + Dark Niveus is warm graphite, not the light theme inverted. Surfaces get + *lighter* as they rise and each step is large enough to see; shadows are + deep and every raised thing carries a near-black contact line, because on a + dark canvas a shadow alone has nothing left to darken. The bevel is what + carries depth here — a lit top edge, a shaded bottom one — and filled color + stays saturated: a vivid accent with a light label reads as a lit key, not + as a hole. ========================================================================== */ :root[data-theme="dark"] { @@ -19,20 +21,20 @@ :root, [data-nv-brand] { /* --- Canvas and surfaces ----------------------------------------------- */ - --nv-dark-background: color-mix(in oklab, #0b0e14, var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-background-subtle: color-mix(in oklab, #10141b, var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-surface: color-mix(in oklab, #171c24, var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-surface-elevated: color-mix(in oklab, #1e242e, var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-surface-inset: color-mix(in oklab, #10141b, var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-surface-hover: color-mix(in oklab, #222835, var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-surface-active: color-mix(in oklab, #2a3140, var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-scrim: rgb(3 5 9 / 0.62); + --nv-dark-background: color-mix(in oklab, #0f0f12, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-background-subtle: color-mix(in oklab, #18181b, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface: color-mix(in oklab, #1f1f22, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface-elevated: color-mix(in oklab, #2a2a2e, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface-inset: color-mix(in oklab, #111113, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface-hover: color-mix(in oklab, #313136, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-surface-active: color-mix(in oklab, #39393f, var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-scrim: rgb(6 6 8 / 0.62); /* --- Text --------------------------------------------------------------- */ - --nv-dark-text-primary: #e9edf4; - --nv-dark-text-secondary: color-mix(in oklab, #a3aebf, var(--nv-brand) 8%); - --nv-dark-text-tertiary: color-mix(in oklab, #7a8494, var(--nv-brand) 8%); - --nv-dark-text-disabled: #58616f; + --nv-dark-text-primary: #ecebe8; + --nv-dark-text-secondary: color-mix(in oklab, #a9a8a3, var(--nv-brand) 8%); + --nv-dark-text-tertiary: color-mix(in oklab, #7d7c77, var(--nv-brand) 8%); + --nv-dark-text-disabled: #58585c; --nv-dark-text-inverted: var(--nv-palette-neutral-975); /* --- Borders ------------------------------------------------------------ */ @@ -41,30 +43,38 @@ --nv-dark-border-strong: rgb(255 255 255 / 0.2); /* --- Accent ------------------------------------------------------------- - Lifted up the ramp and slightly desaturated: saturated mid-tones read as - muddy on dark canvases, and fully saturated light ones read as neon. The - floor also puts every filled color above the pivot, so on a dark canvas - the derived label is always the dark one — which is what makes an accent - button read as lit rather than as a hole. */ - --nv-dark-accent: oklch(from var(--nv-brand) clamp(0.72, calc(l + 0.12), 0.86) clamp(0.02, calc(c * 0.92), 0.2) h); + The same derivation as light: a fill is pushed out of the mid-tones until + its label clears 4.5:1, so the brand keeps its full saturation and a light + label. Earlier Niveus lifted the dark accent into pastel to avoid neon; + with bevel and sheen doing the shaping, a saturated fill reads as a lit + key instead, and the pastel was what made the whole system feel washed + out. Only the accent *text* (links, subtle labels) moves up the ramp, + because text on a dark canvas needs the lightness that a fill does not. */ + --nv-dark-accent: oklch(from var(--nv-brand) calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); --nv-dark-accent-hover: oklch(from var(--nv-dark-accent) calc(l + 0.05) c h); --nv-dark-accent-active: oklch(from var(--nv-dark-accent) calc(l + 0.09) c h); - --nv-dark-accent-subtle: oklch(from var(--nv-dark-accent) l c h / 0.15); - --nv-dark-accent-subtle-hover: oklch(from var(--nv-dark-accent) l c h / 0.22); - --nv-dark-accent-border: oklch(from var(--nv-dark-accent) l c h / 0.38); - --nv-dark-accent-text: oklch(from var(--nv-dark-accent) clamp(0.72, calc(l + 0.08), 0.9) calc(c * 0.9) h); + --nv-dark-accent-subtle: oklch(from var(--nv-dark-accent) l c h / 0.2); + --nv-dark-accent-subtle-hover: oklch(from var(--nv-dark-accent) l c h / 0.28); + --nv-dark-accent-border: oklch(from var(--nv-dark-accent) l c h / 0.5); + --nv-dark-accent-text: oklch(from var(--nv-dark-accent) clamp(0.74, calc(l + 0.2), 0.9) calc(c * 0.9) h); --nv-dark-on-accent: oklch(from var(--nv-dark-accent) calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) calc(c * 0.2) h); /* --- Success ------------------------------------------------------------ */ --nv-dark-success: oklch(from - color-mix(in oklab, var(--nv-palette-success-400), var(--nv-brand) var(--nv-brand-harmony)) - clamp(0.72, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h); + color-mix(in oklab, var(--nv-palette-success-500), var(--nv-brand) var(--nv-brand-harmony)) + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); --nv-dark-success-hover: oklch(from var(--nv-dark-success) calc(l + 0.05) c h); - --nv-dark-success-subtle: oklch(from var(--nv-dark-success) l c h / 0.15); + --nv-dark-success-subtle: oklch(from var(--nv-dark-success) l c h / 0.2); --nv-dark-success-border: oklch(from var(--nv-dark-success) l c h / 0.35); - --nv-dark-success-text: oklch(from var(--nv-dark-success) clamp(0.74, calc(l + 0.08), 0.9) calc(c * 0.9) h); + --nv-dark-success-text: oklch(from var(--nv-dark-success) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h); --nv-dark-on-success: oklch(from var(--nv-dark-success) calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) calc(c * 0.2) h); @@ -72,49 +82,78 @@ /* --- Warning ------------------------------------------------------------ */ --nv-dark-warning: oklch(from color-mix(in oklab, var(--nv-palette-warning-400), var(--nv-brand) var(--nv-brand-harmony)) - clamp(0.74, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h); + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); --nv-dark-warning-hover: oklch(from var(--nv-dark-warning) calc(l + 0.05) c h); - --nv-dark-warning-subtle: oklch(from var(--nv-dark-warning) l c h / 0.15); + --nv-dark-warning-subtle: oklch(from var(--nv-dark-warning) l c h / 0.2); --nv-dark-warning-border: oklch(from var(--nv-dark-warning) l c h / 0.35); - --nv-dark-warning-text: oklch(from var(--nv-dark-warning) clamp(0.76, calc(l + 0.08), 0.9) calc(c * 0.9) h); + --nv-dark-warning-text: oklch(from var(--nv-dark-warning) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h); --nv-dark-on-warning: oklch(from var(--nv-dark-warning) calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) calc(c * 0.2) h); /* --- Danger ------------------------------------------------------------- */ --nv-dark-danger: oklch(from - color-mix(in oklab, var(--nv-palette-danger-400), var(--nv-brand) var(--nv-brand-harmony)) - clamp(0.72, calc(l + 0.1), 0.88) clamp(0.02, calc(c * 0.9), 0.18) h); + color-mix(in oklab, var(--nv-palette-danger-500), var(--nv-brand) var(--nv-brand-harmony)) + calc( + clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1) * min(l, var(--nv-contrast-dark)) + + (1 - clamp(0, (var(--nv-contrast-pivot) - l) * 100, 1)) * max(l, var(--nv-contrast-light)) + ) clamp(0.02, c, 0.25) h); --nv-dark-danger-hover: oklch(from var(--nv-dark-danger) calc(l + 0.05) c h); --nv-dark-danger-active: oklch(from var(--nv-dark-danger) calc(l + 0.09) c h); - --nv-dark-danger-subtle: oklch(from var(--nv-dark-danger) l c h / 0.15); + --nv-dark-danger-subtle: oklch(from var(--nv-dark-danger) l c h / 0.2); --nv-dark-danger-border: oklch(from var(--nv-dark-danger) l c h / 0.35); - --nv-dark-danger-text: oklch(from var(--nv-dark-danger) clamp(0.74, calc(l + 0.08), 0.9) calc(c * 0.9) h); + --nv-dark-danger-text: oklch(from var(--nv-dark-danger) clamp(0.76, calc(l + 0.2), 0.9) calc(c * 0.9) h); --nv-dark-on-danger: oklch(from var(--nv-dark-danger) calc(0.14 + clamp(0, (0.68 - l) * 100, 1) * 0.84) calc(c * 0.2) h); /* --- Depth and material -------------------------------------------------- */ - --nv-dark-shadow-ambient: rgb(0 0 0 / 0.3); - --nv-dark-shadow-penumbra: rgb(0 0 0 / 0.4); - --nv-dark-shadow-umbra: rgb(0 0 0 / 0.35); - --nv-dark-shadow-inset: rgb(0 0 0 / 0.5); - --nv-dark-highlight: rgb(255 255 255 / 0.07); - --nv-dark-highlight-strong: rgb(255 255 255 / 0.12); + --nv-dark-shadow-ambient: rgb(0 0 0 / 0.5); + --nv-dark-shadow-penumbra: rgb(0 0 0 / 0.5); + --nv-dark-shadow-umbra: rgb(0 0 0 / 0.55); + --nv-dark-shadow-inset: rgb(0 0 0 / 0.65); + --nv-dark-highlight: rgb(255 255 255 / 0.15); + --nv-dark-highlight-strong: rgb(255 255 255 / 0.24); + + /* The contact line is what separates graphite from graphite: a raised + surface over a dark canvas has no shadow to show, but it always has an + edge. */ + --nv-dark-lowlight: rgb(0 0 0 / 0.5); + --nv-dark-outline: rgb(0 0 0 / 0.72); + + --nv-dark-sheen-top: rgb(255 255 255 / 0.09); + --nv-dark-sheen-bottom: rgb(0 0 0 / 0.18); + --nv-dark-sheen-strong-top: rgb(255 255 255 / 0.3); + --nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.2); + --nv-dark-glow: oklch(from var(--nv-dark-accent) l c h / 0.5); + + --nv-dark-knob: #f4f3ef; + --nv-dark-knob-shade: rgb(40 32 20 / 0.3); /* The higher a surface sits, the more light reaches it. */ - --nv-dark-elevation-tint-1: rgb(255 255 255 / 0.025); - --nv-dark-elevation-tint-2: rgb(255 255 255 / 0.042); - --nv-dark-elevation-tint-3: rgb(255 255 255 / 0.06); - --nv-dark-elevation-tint-4: rgb(255 255 255 / 0.078); - --nv-dark-elevation-tint-5: rgb(255 255 255 / 0.095); + --nv-dark-elevation-tint-1: rgb(255 255 255 / 0.03); + --nv-dark-elevation-tint-2: rgb(255 255 255 / 0.05); + --nv-dark-elevation-tint-3: rgb(255 255 255 / 0.07); + --nv-dark-elevation-tint-4: rgb(255 255 255 / 0.09); + --nv-dark-elevation-tint-5: rgb(255 255 255 / 0.11); - --nv-dark-material-frosted: color-mix(in oklab, rgb(23 28 36 / 0.72), var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-material-glass: color-mix(in oklab, rgb(30 36 46 / 0.55), var(--nv-brand) var(--nv-brand-tint)); - --nv-dark-material-border: rgb(255 255 255 / 0.1); + --nv-dark-material-frosted: color-mix(in oklab, rgb(34 34 40 / 0.5), var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-material-glass: color-mix(in oklab, rgb(44 44 52 / 0.36), var(--nv-brand) var(--nv-brand-tint)); + --nv-dark-material-border: rgb(255 255 255 / 0.14); + + /* --- Window controls --------------------------------------------------------- */ + --nv-dark-window-close: var(--nv-palette-window-close); + --nv-dark-window-minimize: var(--nv-palette-window-minimize); + --nv-dark-window-zoom: var(--nv-palette-window-zoom); + --nv-dark-window-inactive: var(--nv-palette-neutral-700); + --nv-dark-window-control-ring: rgb(0 0 0 / 0.35); + --nv-dark-window-glyph: rgb(0 0 0 / 0.6); /* --- Interaction ---------------------------------------------------------- */ - --nv-dark-focus-ring: oklch(from var(--nv-dark-accent) l c h / 0.5); - --nv-dark-focus-ring-contrast: rgb(6 8 12 / 0.9); - --nv-dark-selection: oklch(from var(--nv-dark-accent) l c h / 0.3); + --nv-dark-focus-ring: oklch(from var(--nv-dark-accent-text) l c h / 0.55); + --nv-dark-focus-ring-contrast: rgb(8 8 10 / 0.9); + --nv-dark-selection: oklch(from var(--nv-dark-accent) l c h / 0.4); } diff --git a/docs/Website/niveus/niveus/themes/light.css b/docs/Website/niveus/niveus/themes/light.css index ddee15f..357cf13 100644 --- a/docs/Website/niveus/niveus/themes/light.css +++ b/docs/Website/niveus/niveus/themes/light.css @@ -10,9 +10,12 @@ how much of the brand bleeds into the greys. See tokens/color.css for the inputs. - Light Niveus reads as paper on a cool, slightly recessed canvas: surfaces are - near-white, depth comes from soft shadow rather than from contrast, and a - crisp top highlight gives raised elements their edge. + Light Niveus reads as soft paper and enamel on a warm grey desk: surfaces + that hold content stay near-white, so documents never look yellowed; the + canvas around them is a few steps warmer and darker, so a window sits *on* + something. Raised controls are bevelled — a bright top edge, a soft shade + along the bottom, light falling across the face — and shadows are warm, + the color of shade on paper rather than of a grey filter. ========================================================================== */ :root { @@ -32,14 +35,14 @@ 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, #e9ecf3, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-background-subtle: color-mix(in oklab, #eff1f6, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface: var(--nv-palette-neutral-0); - --nv-light-surface-elevated: var(--nv-palette-neutral-0); - --nv-light-surface-inset: color-mix(in oklab, #e4e8f0, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface-hover: color-mix(in oklab, #f4f6fa, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-surface-active: color-mix(in oklab, #e8ebf2, var(--nv-brand) var(--nv-brand-tint)); - --nv-light-scrim: rgb(16 21 30 / 0.32); + --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)); + --nv-light-scrim: rgb(28 24 18 / 0.34); /* --- Text --------------------------------------------------------------- Primary text stays neutral — it carries the reading contrast. The quieter @@ -52,9 +55,9 @@ --nv-light-text-inverted: var(--nv-palette-neutral-0); /* --- Borders ------------------------------------------------------------ */ - --nv-light-border-subtle: rgb(16 21 30 / 0.06); - --nv-light-border: rgb(16 21 30 / 0.11); - --nv-light-border-strong: rgb(16 21 30 / 0.2); + --nv-light-border-subtle: rgb(28 24 18 / 0.07); + --nv-light-border: rgb(28 24 18 / 0.13); + --nv-light-border-strong: rgb(28 24 18 / 0.22); /* --- Accent -------------------------------------------------------------- Every filled color is pushed away from the middle of the lightness range @@ -132,13 +135,27 @@ /* --- 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(16 21 30 / 0.05); - --nv-light-shadow-penumbra: rgb(16 21 30 / 0.08); - --nv-light-shadow-umbra: rgb(16 21 30 / 0.06); - --nv-light-shadow-inset: rgb(16 21 30 / 0.12); - --nv-light-highlight: rgb(255 255 255 / 0.85); + --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); + /* 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-sheen-top: rgb(255 255 255 / 0.9); + --nv-light-sheen-bottom: rgb(40 32 20 / 0.07); + --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); + + --nv-light-knob: var(--nv-palette-neutral-0); + --nv-light-knob-shade: rgb(40 32 20 / 0.13); + /* Light surfaces are already lighter than their canvas; the shadow alone carries elevation, so there is nothing to add. */ --nv-light-elevation-tint-1: transparent; @@ -147,9 +164,17 @@ --nv-light-elevation-tint-4: transparent; --nv-light-elevation-tint-5: transparent; - --nv-light-material-frosted: rgb(255 255 255 / 0.72); - --nv-light-material-glass: rgb(255 255 255 / 0.5); - --nv-light-material-border: rgb(255 255 255 / 0.55); + --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); + + /* --- Window controls --------------------------------------------------------- */ + --nv-light-window-close: var(--nv-palette-window-close); + --nv-light-window-minimize: var(--nv-palette-window-minimize); + --nv-light-window-zoom: var(--nv-palette-window-zoom); + --nv-light-window-inactive: var(--nv-palette-neutral-300); + --nv-light-window-control-ring: rgb(0 0 0 / 0.16); + --nv-light-window-glyph: rgb(0 0 0 / 0.55); /* --- Interaction ----------------------------------------------------------- */ --nv-light-focus-ring: oklch(from var(--nv-light-accent) l c h / 0.4); diff --git a/docs/Website/niveus/niveus/tokens/color.css b/docs/Website/niveus/niveus/tokens/color.css index 9c3d7b9..9d35ca8 100644 --- a/docs/Website/niveus/niveus/tokens/color.css +++ b/docs/Website/niveus/niveus/tokens/color.css @@ -53,25 +53,37 @@ } :root { - /* --- Neutral ---------------------------------------------------------- */ + /* --- Neutral ---------------------------------------------------------- + Warm graphite to warm paper. A grey that leans very slightly towards + yellow reads as a material (metal, enamel, paper); a grey that leans blue + reads as a screen. The lean is small enough that the brand tint still + decides the family. */ --nv-palette-neutral-0: #ffffff; - --nv-palette-neutral-25: #fbfcfe; - --nv-palette-neutral-50: #f7f8fb; - --nv-palette-neutral-100: #f1f3f8; - --nv-palette-neutral-150: #e8ebf2; - --nv-palette-neutral-200: #dfe3ec; - --nv-palette-neutral-300: #ccd2de; - --nv-palette-neutral-400: #a7b0c0; - --nv-palette-neutral-500: #7c8797; - --nv-palette-neutral-600: #5b6574; - --nv-palette-neutral-700: #434b59; - --nv-palette-neutral-800: #2d3340; - --nv-palette-neutral-850: #232936; - --nv-palette-neutral-900: #1a1f29; - --nv-palette-neutral-925: #151a22; - --nv-palette-neutral-950: #10141b; - --nv-palette-neutral-975: #0b0e14; - --nv-palette-neutral-1000: #06080c; + --nv-palette-neutral-25: #fcfcfb; + --nv-palette-neutral-50: #f8f8f6; + --nv-palette-neutral-100: #f2f1ee; + --nv-palette-neutral-150: #eae9e5; + --nv-palette-neutral-200: #e1e0db; + --nv-palette-neutral-300: #cfcec8; + --nv-palette-neutral-400: #aeada7; + --nv-palette-neutral-500: #87867f; + --nv-palette-neutral-600: #63625d; + --nv-palette-neutral-700: #4a4945; + --nv-palette-neutral-800: #343337; + --nv-palette-neutral-850: #2a2a2d; + --nv-palette-neutral-900: #1d1d20; + --nv-palette-neutral-925: #18181a; + --nv-palette-neutral-950: #131315; + --nv-palette-neutral-975: #0e0e10; + --nv-palette-neutral-1000: #08080a; + + /* --- Window controls ---------------------------------------------------- + The three lights at the start of a titlebar. They are the one place the + system uses fixed colors instead of the brand: their meaning is learned + from position and color together, and re-coloring them would unteach it. */ + --nv-palette-window-close: #ff5f57; + --nv-palette-window-minimize: #febc2e; + --nv-palette-window-zoom: #28c840; /* --- Accent (indigo) --------------------------------------------------- */ --nv-palette-accent-50: #eef2ff; @@ -198,6 +210,33 @@ --nv-color-highlight: light-dark(var(--nv-light-highlight), var(--nv-dark-highlight)); --nv-color-highlight-strong: light-dark(var(--nv-light-highlight-strong), var(--nv-dark-highlight-strong)); + /* The bevel's other half: the shade along a raised surface's bottom edge, + and the contact line around anything that sits on another surface. Light + along the top and shade along the bottom is what makes a control read as + something pressable instead of a painted rectangle. */ + --nv-color-lowlight: light-dark(var(--nv-light-lowlight), var(--nv-dark-lowlight)); + --nv-color-outline: light-dark(var(--nv-light-outline), var(--nv-dark-outline)); + + /* Sheen: the light falling across a raised face, top to bottom. The plain + pair is for neutral controls and surfaces; the strong pair is for filled + color (primary buttons, checked marks), where it has to read through a + saturated fill. Both are laid over a surface's own color, never instead + of it. */ + --nv-color-sheen-top: light-dark(var(--nv-light-sheen-top), var(--nv-dark-sheen-top)); + --nv-color-sheen-bottom: light-dark(var(--nv-light-sheen-bottom), var(--nv-dark-sheen-bottom)); + --nv-color-sheen-strong-top: light-dark(var(--nv-light-sheen-strong-top), var(--nv-dark-sheen-strong-top)); + --nv-color-sheen-strong-bottom: light-dark(var(--nv-light-sheen-strong-bottom), var(--nv-dark-sheen-strong-bottom)); + + /* Glow: light a surface gives off, as opposed to the shade it casts. It is + the accent seen through air — strong enough in dark to read as a lit + edge, faint in light, where paper does not emit. */ + --nv-color-glow: light-dark(var(--nv-light-glow), var(--nv-dark-glow)); + + /* A knob: the thumb of a switch or a slider. It stays light in both + appearances, the way a physical knob does not change color with the room. */ + --nv-color-knob: light-dark(var(--nv-light-knob), var(--nv-dark-knob)); + --nv-color-knob-shade: light-dark(var(--nv-light-knob-shade), var(--nv-dark-knob-shade)); + /* Elevation tint: on a dark canvas a shadow has nothing to darken, so a raised surface also has to gain light. Transparent in light, where the shadow does all the work. */ @@ -211,6 +250,14 @@ --nv-color-material-glass: light-dark(var(--nv-light-material-glass), var(--nv-dark-material-glass)); --nv-color-material-border: light-dark(var(--nv-light-material-border), var(--nv-dark-material-border)); + /* --- Window controls ------------------------------------------------------- */ + --nv-color-window-close: light-dark(var(--nv-light-window-close), var(--nv-dark-window-close)); + --nv-color-window-minimize: light-dark(var(--nv-light-window-minimize), var(--nv-dark-window-minimize)); + --nv-color-window-zoom: light-dark(var(--nv-light-window-zoom), var(--nv-dark-window-zoom)); + --nv-color-window-inactive: light-dark(var(--nv-light-window-inactive), var(--nv-dark-window-inactive)); + --nv-color-window-control-ring: light-dark(var(--nv-light-window-control-ring), var(--nv-dark-window-control-ring)); + --nv-color-window-glyph: light-dark(var(--nv-light-window-glyph), var(--nv-dark-window-glyph)); + /* --- Interaction --------------------------------------------------------- */ --nv-color-focus-ring: light-dark(var(--nv-light-focus-ring), var(--nv-dark-focus-ring)); --nv-color-focus-ring-contrast: light-dark(var(--nv-light-focus-ring-contrast), var(--nv-dark-focus-ring-contrast)); diff --git a/docs/Website/niveus/niveus/tokens/elevation.css b/docs/Website/niveus/niveus/tokens/elevation.css index 66c00d9..d185b73 100644 --- a/docs/Website/niveus/niveus/tokens/elevation.css +++ b/docs/Website/niveus/niveus/tokens/elevation.css @@ -22,34 +22,35 @@ --nv-elevation-1: 0 1px 1px var(--nv-color-shadow-ambient), - 0 1px 3px -1px var(--nv-color-shadow-penumbra); + 0 2px 4px -1px var(--nv-color-shadow-penumbra), + 0 6px 14px -8px var(--nv-color-shadow-umbra); --nv-elevation-2: 0 1px 1px var(--nv-color-shadow-ambient), - 0 2px 4px -1px var(--nv-color-shadow-penumbra), - 0 4px 8px -4px var(--nv-color-shadow-umbra); + 0 3px 6px -2px var(--nv-color-shadow-penumbra), + 0 10px 20px -10px var(--nv-color-shadow-umbra); --nv-elevation-3: - 0 1px 1px var(--nv-color-shadow-ambient), - 0 4px 8px -2px var(--nv-color-shadow-penumbra), - 0 10px 20px -6px var(--nv-color-shadow-umbra); + 0 1px 2px var(--nv-color-shadow-ambient), + 0 6px 12px -4px var(--nv-color-shadow-penumbra), + 0 18px 36px -12px var(--nv-color-shadow-umbra); --nv-elevation-4: 0 1px 2px var(--nv-color-shadow-ambient), - 0 8px 16px -4px var(--nv-color-shadow-penumbra), - 0 20px 40px -10px var(--nv-color-shadow-umbra); + 0 10px 20px -6px var(--nv-color-shadow-penumbra), + 0 30px 60px -16px var(--nv-color-shadow-umbra); --nv-elevation-5: - 0 1px 2px var(--nv-color-shadow-ambient), - 0 14px 28px -6px var(--nv-color-shadow-penumbra), - 0 32px 64px -14px var(--nv-color-shadow-umbra); + 0 2px 3px var(--nv-color-shadow-ambient), + 0 16px 32px -8px var(--nv-color-shadow-penumbra), + 0 44px 88px -20px var(--nv-color-shadow-umbra); /* --- Inset --------------------------------------------------------------- Recessed surfaces: fields, wells, tracks, pressed controls. */ --nv-elevation-inset-sm: inset 0 1px 2px var(--nv-color-shadow-inset); --nv-elevation-inset-md: inset 0 1px 1px var(--nv-color-shadow-inset), - inset 0 2px 5px -1px var(--nv-color-shadow-inset); + inset 0 3px 6px -2px var(--nv-color-shadow-inset); --nv-elevation-inset-lg: inset 0 2px 3px var(--nv-color-shadow-inset), inset 0 6px 12px -3px var(--nv-color-shadow-inset); @@ -64,6 +65,29 @@ inset 0 1px 0 var(--nv-color-highlight), inset 0 0 0 1px var(--nv-color-border-subtle); + /* --- Bevel ----------------------------------------------------------------- + The highlight's other half. A raised face is lit along its top edge and + shaded along its bottom one; together they are the bevel, and the bevel is + what makes a Niveus control look like something a finger could press. + Raised controls and surfaces take the full bevel; the highlight alone is + for things that sit flush. */ + --nv-lowlight-bottom: inset 0 -1px 0 var(--nv-color-lowlight); + --nv-bevel: + inset 0 1px 0 var(--nv-color-highlight), + inset 0 -1px 0 var(--nv-color-lowlight); + + /* --- Outline --------------------------------------------------------------- + The contact line: a hairline just outside a raised surface, where it meets + whatever it rests on. In dark it is near-black and does most of the + separating; in light it only softens the border beside it. */ + --nv-outline: 0 0 0 1px var(--nv-color-outline); + + /* --- Ledge ----------------------------------------------------------------- + A recess catches light on its *lower* lip, the edge facing the viewer. + Paired with an inset shadow, it turns a field from a dark rectangle into + a well cut into the surface. */ + --nv-ledge: 0 1px 0 var(--nv-color-highlight); + /* --- Focus --------------------------------------------------------------- A contrast ring sits under the accent ring so focus stays visible on both light and dark surfaces, and on accent-filled controls. */ @@ -71,4 +95,30 @@ --nv-focus-ring: 0 0 0 1px var(--nv-color-focus-ring-contrast), 0 0 0 calc(1px + var(--nv-focus-ring-width)) var(--nv-color-focus-ring); + + /* --- Glow ------------------------------------------------------------------ + Light a surface emits rather than the shade it casts. Glow is for things + that are *on* — a live status, the selected item in a showcase, a lit key — + and it always sits beside an elevation, never instead of one: a glowing + surface still rests on something. + + The geometry is a scale like elevation. The color is the component's: a + tinted surface glows in its own tint, mixed down to --nv-glow-strength. */ + --nv-glow-strength: 40%; + --nv-glow-blur-sm: 12px; + --nv-glow-spread-sm: -2px; + --nv-glow-blur-md: 28px; + --nv-glow-spread-md: -6px; + --nv-glow-blur-lg: 64px; + --nv-glow-spread-lg: -12px; +} + +/* The accent glows are declared on every branded subtree as well as the root: + a var() inside a custom property resolves where the property is declared, so + a root-only declaration would glow in the root brand everywhere. */ +:root, +[data-nv-brand] { + --nv-glow-sm: 0 0 var(--nv-glow-blur-sm) var(--nv-glow-spread-sm) var(--nv-color-glow); + --nv-glow-md: 0 0 var(--nv-glow-blur-md) var(--nv-glow-spread-md) var(--nv-color-glow); + --nv-glow-lg: 0 0 var(--nv-glow-blur-lg) var(--nv-glow-spread-lg) var(--nv-color-glow); } diff --git a/docs/Website/niveus/niveus/tokens/materials.css b/docs/Website/niveus/niveus/tokens/materials.css index 2da6487..c718859 100644 --- a/docs/Website/niveus/niveus/tokens/materials.css +++ b/docs/Website/niveus/niveus/tokens/materials.css @@ -8,29 +8,50 @@ solid opaque, stable. The default. Content lives here. elevated solid with depth and a top highlight. Separated from context. inset recessed into its parent. Fields, wells, tracks. - frosted translucent, heavily blurred. Chrome that sits over content: - navigation bars, toolbars, sticky headers. - glass more translucent, less blurred. Contextual layers where the + frosted translucent, moderately blurred, bevelled. Chrome that sits + over content: bars, docks, launchers, notifications, menus. + glass more translucent, lightly blurred. Contextual layers where the content behind should stay legible: overlays, hover cards. - Translucency has a cost — it tints whatever is behind it and forces the GPU - to composite. Use frosted and glass for layers that move over content, not - for content itself. + Niveus is a solid system with glass used sparingly. Windows, cards, dialogs + and everything that holds content are solid; translucency is reserved for + the chrome that floats over them. Translucency has a cost — it tints + whatever is behind it and forces the GPU to composite — and used everywhere + it stops meaning "this floats". + + Sheen is the light falling across a raised face. It is laid over a + surface's own color as background-image, so a surface keeps its role color + and gains the gradient; anything that does not draw gradients simply shows + the role color. ========================================================================== */ :root { /* --- Blur ---------------------------------------------------------------- */ - --nv-blur-sm: 8px; - --nv-blur-md: 16px; - --nv-blur-lg: 28px; - --nv-blur-xl: 44px; + --nv-blur-sm: 12px; + --nv-blur-md: 24px; + --nv-blur-lg: 36px; + --nv-blur-xl: 56px; /* Backdrops lose saturation when blurred; a touch of saturate() puts the - color back so frosted surfaces do not read as gray films. */ + color back so frosted surfaces do not read as gray films. Moderate on + purpose: the blur says "this floats", it is not the look itself. */ --nv-backdrop-saturate: 180%; - --nv-backdrop-frosted: blur(var(--nv-blur-lg)) saturate(var(--nv-backdrop-saturate)); - --nv-backdrop-glass: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate)); - --nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(120%); + --nv-backdrop-frosted: blur(var(--nv-blur-md)) saturate(var(--nv-backdrop-saturate)); + --nv-backdrop-glass: blur(var(--nv-blur-sm)) saturate(var(--nv-backdrop-saturate)); + + /* --- Sheen ------------------------------------------------------------------ + raised neutral controls: buttons, the selected tab, a titlebar + filled saturated fills: primary and danger buttons, checked marks + surface large surfaces: windows, dialogs, cards. Brightest along the + top edge, easing through the middle and settling into a faint + shade at the foot, so a panel reads as a curved, lit body + rather than as a flat sheet. + knob a round thumb: lit from above, shaded towards its rim */ + --nv-sheen-raised: linear-gradient(var(--nv-color-sheen-top), var(--nv-color-sheen-bottom)); + --nv-sheen-filled: linear-gradient(var(--nv-color-sheen-strong-top), var(--nv-color-sheen-strong-bottom)); + --nv-sheen-surface: linear-gradient(var(--nv-color-sheen-top), transparent 7rem, transparent 55%, var(--nv-color-sheen-bottom)); + --nv-sheen-knob: radial-gradient(circle at 50% 22%, var(--nv-color-highlight-strong), transparent 45%), radial-gradient(circle at 50% 30%, transparent 40%, var(--nv-color-knob-shade)); + --nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(140%); /* --- Material recipes ----------------------------------------------------- Each recipe is the full set of properties that defines the material. @@ -41,19 +62,19 @@ --nv-material-elevated-bg: var(--nv-color-surface-elevated); --nv-material-elevated-border: var(--nv-color-border-subtle); - --nv-material-elevated-shadow: var(--nv-highlight-top), var(--nv-elevation-2); + --nv-material-elevated-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2); --nv-material-inset-bg: var(--nv-color-surface-inset); --nv-material-inset-border: var(--nv-color-border-subtle); - --nv-material-inset-shadow: var(--nv-elevation-inset-md); + --nv-material-inset-shadow: var(--nv-elevation-inset-md), var(--nv-ledge); --nv-material-frosted-bg: var(--nv-color-material-frosted); --nv-material-frosted-border: var(--nv-color-material-border); - --nv-material-frosted-shadow: var(--nv-highlight-top), var(--nv-elevation-3); + --nv-material-frosted-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-3); --nv-material-glass-bg: var(--nv-color-material-glass); --nv-material-glass-border: var(--nv-color-material-border); - --nv-material-glass-shadow: var(--nv-highlight-top-strong), var(--nv-elevation-4); + --nv-material-glass-shadow: var(--nv-highlight-top-strong), var(--nv-outline), var(--nv-elevation-4); } /* Where translucency cannot be honoured — reduced transparency, or a browser diff --git a/docs/Website/niveus/niveus/tokens/motion.css b/docs/Website/niveus/niveus/tokens/motion.css index b9075d8..b271e56 100644 --- a/docs/Website/niveus/niveus/tokens/motion.css +++ b/docs/Website/niveus/niveus/tokens/motion.css @@ -82,6 +82,15 @@ --nv-travel-sm: 4px; --nv-travel-md: 8px; --nv-travel-lg: 16px; + + /* --- Sequence --------------------------------------------------------------- + The gap between siblings arriving one after another. Short enough that a + group reads as one movement, long enough that the eye sees an order. */ + --nv-stagger: 60ms; + + /* A loading shimmer is a slow, constant sweep — slow enough to read as + "waiting", never fast enough to read as an alert. */ + --nv-duration-shimmer: 1600ms; } /* Reduced motion is a system-wide contract, not a per-component opt-in: cut @@ -103,5 +112,6 @@ --nv-travel-sm: 0px; --nv-travel-md: 0px; --nv-travel-lg: 0px; + --nv-stagger: 0ms; } } diff --git a/docs/Website/niveus/niveus/tokens/radius.css b/docs/Website/niveus/niveus/tokens/radius.css index 13a2143..927416b 100644 --- a/docs/Website/niveus/niveus/tokens/radius.css +++ b/docs/Website/niveus/niveus/tokens/radius.css @@ -2,18 +2,19 @@ Niveus — Radius -------------------------------------------------------------------------- Corner softness carries as much of the Niveus character as shadow does. - Radii grow with the surface: controls stay modest, containers get rounder, - and only pills and avatars go fully round. + Radii grow with the surface: controls stay modest and machined, containers + get noticeably rounder — a window or a popover is a soft object, a button + is a key — and only pills, knobs and avatars go fully round. ========================================================================== */ :root { --nv-radius-none: 0; --nv-radius-xs: 0.25rem; /* 4px — tags, swatches, nested chips */ --nv-radius-sm: 0.5rem; /* 8px — small controls, menu items */ - --nv-radius-md: 0.75rem; /* 12px — buttons, inputs, default control radius */ - --nv-radius-lg: 1rem; /* 16px — cards, panels */ - --nv-radius-xl: 1.375rem; /* 22px — dialogs, sheets, large containers */ - --nv-radius-2xl: 1.75rem; /* 28px — hero surfaces */ + --nv-radius-md: 0.625rem; /* 10px — buttons, inputs, default control radius */ + --nv-radius-lg: 0.875rem; /* 14px — cards, panels, menus */ + --nv-radius-xl: 1.25rem; /* 20px — windows, dialogs, sheets, popovers */ + --nv-radius-2xl: 1.5rem; /* 24px — docks, hero surfaces */ --nv-radius-full: 999rem; /* — pills, avatars, switches */ /* Nested radius: an inner surface inset by N should be N smaller than its diff --git a/docs/Website/niveus/niveus/tokens/typography.css b/docs/Website/niveus/niveus/tokens/typography.css index 476c09b..caabfef 100644 --- a/docs/Website/niveus/niveus/tokens/typography.css +++ b/docs/Website/niveus/niveus/tokens/typography.css @@ -7,11 +7,13 @@ :root { /* --- Families ----------------------------------------------------------- */ + /* Both ship with Niveus (fonts.css); the rest of each stack only covers the + moment before they load, or a context that cannot load them. */ --nv-font-family-sans: - "Inter var", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", - "Helvetica Neue", Arial, sans-serif; + "Instrument Sans", "Inter var", "Inter", -apple-system, BlinkMacSystemFont, + "Segoe UI", "Helvetica Neue", Arial, sans-serif; --nv-font-family-mono: - "SF Mono", "JetBrains Mono", "IBM Plex Mono", ui-monospace, "Menlo", + "JetBrains Mono", "SF Mono", "IBM Plex Mono", ui-monospace, "Menlo", "Consolas", monospace; /* --- Size scale ---------------------------------------------------------- */ @@ -46,6 +48,8 @@ --nv-letter-spacing-normal: 0em; --nv-letter-spacing-wide: 0.02em; --nv-letter-spacing-wider: 0.06em; + /* Engraved labels: small mono capitals, spaced like markings on a panel. */ + --nv-letter-spacing-engraved: 0.1em; /* --- Composite roles ------------------------------------------------------ Applied as classes in base.css (.nv-display, .nv-heading, …). Kept as @@ -65,6 +69,11 @@ var(--nv-line-height-normal) var(--nv-font-family-sans); --nv-font-code: var(--nv-font-weight-regular) var(--nv-font-size-sm) / var(--nv-line-height-normal) var(--nv-font-family-mono); + /* The engraved label: section markers, the clock, anything that names a + region rather than being read. Paired with uppercase and the engraved + tracking (see .nv-overline in base.css). */ + --nv-font-engraved: var(--nv-font-weight-medium) var(--nv-font-size-2xs) / + var(--nv-line-height-snug) var(--nv-font-family-mono); /* --- Measure --------------------------------------------------------------- */ --nv-measure-narrow: 44ch; diff --git a/docs/Website/niveus/tools/niveus_css.py b/docs/Website/niveus/tools/niveus_css.py new file mode 100644 index 0000000..d3fee97 --- /dev/null +++ b/docs/Website/niveus/tools/niveus_css.py @@ -0,0 +1,944 @@ +"""Evaluate the subset of CSS that Niveus's token layer is written in. + +Niveus derives its entire color system from one input with relative color +syntax, `color-mix()` and `calc()`. None of that survives a copy-paste into +another platform's resources (Android XML, a Qt header, GTK CSS), and +re-stating the results by hand would fork the system: the CSS would change and +the port would quietly keep the old values. + +So this module implements the language instead, once, here — every port's +generator imports it rather than carrying its own copy. + +It is deliberately narrow — it covers exactly what `niveus/tokens/*.css` and +`niveus/themes/*.css` use: + + #rrggbb / #rgb / #rrggbbaa rgb(r g b / a) transparent + oklch(L C H) oklch(from L C H [/ A]) + color-mix(in oklab, , ) + light-dark(, ) var(--x[, fallback]) + calc(...) clamp(min,v,max) min(...) max(...) + +Inside `oklch(from X ...)` the identifiers `l`, `c`, `h` and `alpha` are bound +to X's own OKLCH channels, which is what makes one brand value re-colour +everything. + +Color math follows CSS Color 4: sRGB <-> linear sRGB <-> OKLab <-> OKLCH, and +out-of-gamut results are gamut-mapped by chroma reduction against deltaEOK, +the same approach browsers take. `color-mix()` mixes premultiplied, in OKLab. +""" + +from __future__ import annotations + +import math +import re +from dataclasses import dataclass + +# --------------------------------------------------------------------------- +# Color space conversions (CSS Color 4) +# --------------------------------------------------------------------------- + +def _srgb_to_linear(v: float) -> float: + return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 + + +def _linear_to_srgb(v: float) -> float: + return v * 12.92 if v <= 0.0031308 else 1.055 * (v ** (1 / 2.4)) - 0.055 + + +# linear sRGB -> LMS -> OKLab, per Björn Ottosson. +_LSRGB_TO_LMS = ( + (0.4122214708, 0.5363325363, 0.0514459929), + (0.2119034982, 0.6806995451, 0.1073969566), + (0.0883024619, 0.2817188376, 0.6299787005), +) +_LMS_TO_OKLAB = ( + (0.2104542553, 0.7936177850, -0.0040720468), + (1.9779984951, -2.4285922050, 0.4505937099), + (0.0259040371, 0.7827717662, -0.8086757660), +) +_OKLAB_TO_LMS = ( + (1.0, 0.3963377774, 0.2158037573), + (1.0, -0.1055613458, -0.0638541728), + (1.0, -0.0894841775, -1.2914855480), +) +_LMS_TO_LSRGB = ( + (4.0767416621, -3.3077115913, 0.2309699292), + (-1.2684380046, 2.6097574011, -0.3413193965), + (-0.0041960863, -0.7034186147, 1.7076147010), +) + + +def _mul(m, v): + return tuple(sum(r[i] * v[i] for i in range(3)) for r in m) + + +def srgb_to_oklab(r: float, g: float, b: float) -> tuple[float, float, float]: + lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b)) + lms = _mul(_LSRGB_TO_LMS, lin) + lms_ = tuple(math.copysign(abs(x) ** (1 / 3), x) for x in lms) + return _mul(_LMS_TO_OKLAB, lms_) + + +def oklab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]: + lms_ = _mul(_OKLAB_TO_LMS, (L, a, b)) + lms = tuple(x ** 3 for x in lms_) + lin = _mul(_LMS_TO_LSRGB, lms) + return tuple(_linear_to_srgb(x) for x in lin) + + +def oklab_to_oklch(L: float, a: float, b: float) -> tuple[float, float, float]: + c = math.hypot(a, b) + # Below this chroma the hue is meaningless; CSS treats it as `none`, and + # carrying a garbage angle through a derivation produces garbage hues. + h = 0.0 if c < 1e-6 else math.degrees(math.atan2(b, a)) % 360.0 + return (L, c, h) + + +def oklch_to_oklab(L: float, c: float, h: float) -> tuple[float, float, float]: + rad = math.radians(h) + return (L, c * math.cos(rad), c * math.sin(rad)) + + +# --------------------------------------------------------------------------- +# CIELAB, for the Android system palette +# --------------------------------------------------------------------------- +# +# Niveus works in OKLab, but Android's tonal palette is defined in terms of +# *tone*, which is CIE L* — the same scale Material You's HCT uses for its T +# channel. Generating those ramps in OKLab lightness would put every tone at a +# slightly wrong luminance, so the palette gets its own, correct, colour space +# rather than an approximation. + +# sRGB (D65) -> XYZ +_LRGB_TO_XYZ = ( + (0.4123907993, 0.3575843394, 0.1804807884), + (0.2126390059, 0.7151686788, 0.0721923154), + (0.0193308187, 0.1191947798, 0.9505321522), +) +_XYZ_TO_LRGB = ( + (3.2409699419, -1.5373831776, -0.4986107603), + (-0.9692436363, 1.8759675015, 0.0415550574), + (0.0556300797, -0.2039769589, 1.0569715142), +) +# D65 white point +_WHITE = (0.9504559271, 1.0, 1.0890577508) + +_EPS = 216 / 24389 +_KAPPA = 24389 / 27 + + +def _f(t: float) -> float: + return t ** (1 / 3) if t > _EPS else (_KAPPA * t + 16) / 116 + + +def _f_inv(t: float) -> float: + t3 = t ** 3 + return t3 if t3 > _EPS else (116 * t - 16) / _KAPPA + + +def srgb_to_lab(r: float, g: float, b: float) -> tuple[float, float, float]: + """sRGB to CIE L*a*b*. L* is 0-100 and is what Android calls tone.""" + lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b)) + x, y, z = _mul(_LRGB_TO_XYZ, lin) + fx, fy, fz = _f(x / _WHITE[0]), _f(y / _WHITE[1]), _f(z / _WHITE[2]) + return (116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)) + + +def lab_to_srgb(L: float, a: float, b: float) -> tuple[float, float, float]: + fy = (L + 16) / 116 + fx = fy + a / 500 + fz = fy - b / 200 + xyz = (_f_inv(fx) * _WHITE[0], _f_inv(fy) * _WHITE[1], _f_inv(fz) * _WHITE[2]) + return tuple(_linear_to_srgb(v) for v in _mul(_XYZ_TO_LRGB, xyz)) + + +def lch_to_srgb_clamped(L: float, C: float, H: float) -> tuple[float, float, float]: + """A tone at a given hue and chroma, reduced until it fits sRGB. + + Tone is fixed — the whole point of a tonal palette is that tone 40 is the + same lightness in every ramp — so chroma is what gives way, never L*. + """ + rad = math.radians(H) + lo, hi = 0.0, max(C, 0.0) + best = lab_to_srgb(L, 0.0, 0.0) + for _ in range(28): + mid = (lo + hi) / 2 + candidate = lab_to_srgb(L, mid * math.cos(rad), mid * math.sin(rad)) + if all(-1e-4 <= v <= 1 + 1e-4 for v in candidate): + best = candidate + lo = mid + else: + hi = mid + return tuple(min(1.0, max(0.0, v)) for v in best) + + +# --------------------------------------------------------------------------- +# Values +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class Num: + value: float + unit: str = "" + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return f"Num({self.value}{self.unit})" + + +@dataclass(frozen=True) +class Color: + """A color held in OKLab plus alpha. `none` means fully transparent.""" + + L: float + a: float + b: float + alpha: float = 1.0 + + @staticmethod + def from_srgb(r: float, g: float, bl: float, alpha: float = 1.0) -> "Color": + L, a, b = srgb_to_oklab(r, g, bl) + return Color(L, a, b, alpha) + + @property + def oklch(self) -> tuple[float, float, float]: + return oklab_to_oklch(self.L, self.a, self.b) + + def to_srgb(self) -> tuple[float, float, float]: + return oklab_to_srgb(self.L, self.a, self.b) + + # -- gamut mapping ---------------------------------------------------- + def _in_gamut(self, eps: float = 1e-4) -> bool: + return all(-eps <= x <= 1 + eps for x in self.to_srgb()) + + def gamut_mapped(self) -> tuple[float, float, float]: + """sRGB triple, chroma-reduced into gamut (CSS Color 4 §13.2).""" + if self._in_gamut(): + return tuple(min(1.0, max(0.0, x)) for x in self.to_srgb()) + + L, C, H = self.oklch + if L >= 1.0: + return (1.0, 1.0, 1.0) + if L <= 0.0: + return (0.0, 0.0, 0.0) + + lo, hi = 0.0, C + clipped = None + for _ in range(32): + mid = (lo + hi) / 2 + candidate = Color(*oklch_to_oklab(L, mid, H), self.alpha) + raw = candidate.to_srgb() + if candidate._in_gamut(): + lo = mid + clipped = tuple(min(1.0, max(0.0, x)) for x in raw) + continue + clip = tuple(min(1.0, max(0.0, x)) for x in raw) + # deltaEOK between the candidate and its clipped form: once the + # clip is imperceptible we accept it rather than desaturating more. + cl = Color.from_srgb(*clip) + if math.dist((candidate.L, candidate.a, candidate.b), (cl.L, cl.a, cl.b)) < 0.02: + return clip + hi = mid + if clipped is not None: + return clipped + return tuple(min(1.0, max(0.0, x)) for x in self.to_srgb()) + + # -- output ----------------------------------------------------------- + def hex8(self) -> str: + """#AARRGGBB — Android's order.""" + r, g, b = self.gamut_mapped() + a = min(1.0, max(0.0, self.alpha)) + return "#{:02X}{:02X}{:02X}{:02X}".format( + round(a * 255), round(r * 255), round(g * 255), round(b * 255) + ) + + def hex_rgb(self) -> str: + r, g, b = self.gamut_mapped() + return "#{:02X}{:02X}{:02X}".format(round(r * 255), round(g * 255), round(b * 255)) + + def argb_int(self) -> int: + r, g, b = self.gamut_mapped() + a = min(1.0, max(0.0, self.alpha)) + return ( + (round(a * 255) << 24) + | (round(r * 255) << 16) + | (round(g * 255) << 8) + | round(b * 255) + ) + + def relative_luminance(self) -> float: + r, g, b = self.gamut_mapped() + lin = (_srgb_to_linear(r), _srgb_to_linear(g), _srgb_to_linear(b)) + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] + + +TRANSPARENT = Color(0.0, 0.0, 0.0, 0.0) + + +def contrast_ratio(fg: Color, bg: Color) -> float: + """WCAG 2.1 contrast, compositing `fg` over `bg` if it is translucent.""" + if fg.alpha < 1.0: + fr, fg_, fb = fg.gamut_mapped() + br, bg_, bb = bg.gamut_mapped() + a = fg.alpha + fg = Color.from_srgb( + fr * a + br * (1 - a), fg_ * a + bg_ * (1 - a), fb * a + bb * (1 - a) + ) + l1, l2 = fg.relative_luminance(), bg.relative_luminance() + if l1 < l2: + l1, l2 = l2, l1 + return (l1 + 0.05) / (l2 + 0.05) + + +# --------------------------------------------------------------------------- +# Tokenizer / parser +# --------------------------------------------------------------------------- + +_TOKEN = re.compile( + r""" + (?P\s+) + | (?P\#[0-9a-fA-F]{3,8}) + | (?P[a-zA-Z_][-a-zA-Z0-9_]*(?=\()) + # A leading `-` starts an identifier only when a digit does not follow, + # or `-0.022em` would lex as the identifier `-0`. + | (?P-{1,2}(?![0-9])[-a-zA-Z0-9_]+|[a-zA-Z_][-a-zA-Z0-9_]*) + | (?P[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?(?:%|[a-zA-Z]+)?) + | (?P[(),/*+\-]) + """, + re.VERBOSE, +) + + +def _lex(s: str) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + i = 0 + while i < len(s): + m = _TOKEN.match(s, i) + if not m: + raise ValueError(f"cannot lex at {s[i:i+30]!r} in {s!r}") + i = m.end() + kind = m.lastgroup + if kind == "ws": + continue + out.append((kind, m.group())) + return out + + +class _Parser: + """Recursive descent over the component-value grammar Niveus uses.""" + + def __init__(self, tokens: list[tuple[str, str]], ev: "Evaluator"): + self.t = tokens + self.i = 0 + self.ev = ev + + # -- plumbing --------------------------------------------------------- + def peek(self) -> tuple[str, str] | None: + return self.t[self.i] if self.i < len(self.t) else None + + def next(self) -> tuple[str, str]: + tok = self.t[self.i] + self.i += 1 + return tok + + def expect(self, text: str) -> None: + tok = self.next() + if tok[1] != text: + raise ValueError(f"expected {text!r}, got {tok[1]!r}") + + def at(self, text: str) -> bool: + tok = self.peek() + return tok is not None and tok[1] == text + + def args(self) -> list[list[tuple[str, str]]]: + """Consume `( a, b, c )` and return each argument's raw tokens.""" + self.expect("(") + groups: list[list[tuple[str, str]]] = [[]] + depth = 0 + while True: + tok = self.peek() + if tok is None: + raise ValueError("unterminated (") + if tok[1] == "(": + depth += 1 + elif tok[1] == ")": + if depth == 0: + self.next() + break + depth -= 1 + elif tok[1] == "," and depth == 0: + self.next() + groups.append([]) + continue + groups.append(groups.pop() + [self.next()]) + return [g for g in groups if g] + + # -- arithmetic ------------------------------------------------------- + def sum_(self) -> Num: + left = self.product() + while True: + tok = self.peek() + if tok is None: + break + if tok[1] in ("+", "-"): + op = self.next()[1] + left, right = _same_unit(left, self.product()) + unit = left.unit or right.unit + left = Num( + left.value + right.value if op == "+" else left.value - right.value, unit + ) + continue + # `calc(l -0.045)` lexes the operand as one signed number, so the + # sign has to be read back as the operator it was written as. + if tok[0] == "num" and tok[1][0] in "+-": + left, right = _same_unit(left, self.product()) + left = Num(left.value + right.value, left.unit or right.unit) + continue + break + return left + + def product(self) -> Num: + left = self.term() + while self.peek() and self.peek()[1] in ("*", "/"): + op = self.next()[1] + right = self.term() + unit = left.unit or right.unit + if op == "*": + left = Num(left.value * right.value, unit) + else: + left = Num(left.value / right.value, left.unit) + return left + + def term(self) -> Num: + tok = self.peek() + if tok is None: + raise ValueError("unexpected end of expression") + if tok[1] == "(": + self.next() + v = self.sum_() + self.expect(")") + return v + if tok[1] == "-": + self.next() + v = self.term() + return Num(-v.value, v.unit) + if tok[1] == "+": + self.next() + return self.term() + val = self.value() + if isinstance(val, Num): + return val + raise ValueError(f"expected a number, got {val!r}") + + # -- values ----------------------------------------------------------- + def value(self): + tok = self.next() + kind, text = tok + + if kind == "hex": + return _parse_hex(text) + + if kind == "num": + return _parse_num(text) + + if kind == "func": + name = text.lower() + if name == "calc": + inner = self.args() + return _Parser(inner[0], self.ev).sum_() + if name in ("clamp", "min", "max"): + vals = [_Parser(g, self.ev).sum_() for g in self.args()] + if name == "clamp": + lo, v, hi = vals + unit = v.unit or lo.unit or hi.unit + return Num(min(max(v.value, lo.value), hi.value), unit) + pick = min if name == "min" else max + chosen = pick(vals, key=lambda n: n.value) + return chosen + if name == "var": + groups = self.args() + varname = "".join(t[1] for t in groups[0]) + fallback = groups[1] if len(groups) > 1 else None + return self.ev.resolve(varname, fallback) + if name == "rgb" or name == "rgba": + return _parse_rgb(self.args(), self.ev) + if name == "oklch": + return _parse_oklch(self.args(), self.ev) + if name == "oklab": + return _parse_oklab(self.args(), self.ev) + if name == "color-mix": + return _parse_color_mix(self.args(), self.ev) + if name == "light-dark": + groups = self.args() + pick = groups[0] if self.ev.appearance == "light" else groups[1] + return _Parser(pick, self.ev).value() + raise ValueError(f"unsupported function {name}()") + + if kind == "ident": + low = text.lower() + if low == "transparent": + return TRANSPARENT + if low in ("none", "currentcolor"): + return TRANSPARENT + if low in _NAMED: + return _NAMED[low] + if low in self.ev.bindings: + return Num(self.ev.bindings[low]) + raise ValueError(f"unknown identifier {text!r}") + + raise ValueError(f"unexpected token {text!r}") + + +# The initial value of the root font size. Tokens never mix units, but +# component rules do (`calc(var(--_track-block) - 6px)` with a rem on one +# side), and adding the numbers without converting is silently wrong. +ROOT_FONT_SIZE_PX = 16.0 + + +def _same_unit(a: Num, b: Num) -> tuple[Num, Num]: + """Bring two lengths to a common unit before adding them.""" + if a.unit == b.unit or not a.unit or not b.unit: + return a, b + lengths = {"px": 1.0, "rem": ROOT_FONT_SIZE_PX, "em": ROOT_FONT_SIZE_PX} + if a.unit in lengths and b.unit in lengths: + return (Num(a.value * lengths[a.unit], "px"), Num(b.value * lengths[b.unit], "px")) + raise ValueError(f"cannot add {a.unit} to {b.unit}") + + +_NAMED = { + "white": Color.from_srgb(1, 1, 1), + "black": Color.from_srgb(0, 0, 0), +} + + +def _parse_hex(text: str) -> Color: + h = text[1:] + if len(h) in (3, 4): + h = "".join(ch * 2 for ch in h) + if len(h) == 6: + r, g, b = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)) + return Color.from_srgb(r, g, b) + if len(h) == 8: + r, g, b, a = (int(h[i : i + 2], 16) / 255 for i in (0, 2, 4, 6)) + return Color.from_srgb(r, g, b, a) + raise ValueError(f"bad hex color {text!r}") + + +def _parse_num(text: str) -> Num: + m = re.fullmatch(r"([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)(%|[a-zA-Z]*)", text) + if not m: + raise ValueError(f"bad number {text!r}") + v = float(m.group(1)) + unit = m.group(2) + if unit == "%": + return Num(v / 100.0, "%") + return Num(v, unit) + + +def _flat(groups: list[list[tuple[str, str]]]) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for i, g in enumerate(groups): + if i: + out.append(("punct", ",")) + out.extend(g) + return out + + +def _split_slash(tokens: list[tuple[str, str]]) -> tuple[list, list | None]: + depth = 0 + for i, (_, text) in enumerate(tokens): + if text == "(": + depth += 1 + elif text == ")": + depth -= 1 + elif text == "/" and depth == 0: + return tokens[:i], tokens[i + 1 :] + return tokens, None + + +def _components(tokens: list[tuple[str, str]], ev: "Evaluator", n: int) -> list[Num]: + """Read `n` space-separated numeric components from a token run.""" + p = _Parser(tokens, ev) + out: list[Num] = [] + for _ in range(n): + out.append(p.sum_()) + return out + + +def _parse_rgb(groups, ev) -> Color: + tokens = _flat(groups) + # Both `rgb(r g b / a)` and the legacy `rgb(r, g, b, a)` appear in the wild. + body, alpha_tokens = _split_slash(tokens) + body = [t for t in body if t[1] != ","] + nums = _components(body, ev, 3) + vals = [] + for n in nums: + vals.append(n.value if n.unit == "%" else n.value / 255.0) + alpha = 1.0 + if alpha_tokens: + alpha = _Parser(alpha_tokens, ev).sum_().value + return Color.from_srgb(*vals, alpha) + + +def _oklch_channels(tokens, ev, base: Color | None): + body, alpha_tokens = _split_slash(tokens) + bindings = {} + if base is not None: + L, C, H = base.oklch + bindings = {"l": L, "c": C, "h": H, "alpha": base.alpha} + with ev.bound(bindings): + nums = _components(body, ev, 3) + alpha = base.alpha if base is not None else 1.0 + if alpha_tokens: + alpha = _Parser(alpha_tokens, ev).sum_().value + return nums, alpha + + +def _parse_oklch(groups, ev) -> Color: + tokens = _flat(groups) + base = None + if tokens and tokens[0][0] == "ident" and tokens[0][1].lower() == "from": + rest = tokens[1:] + p = _Parser(rest, ev) + base = p.value() + if not isinstance(base, Color): + raise ValueError("oklch(from …) needs a color") + tokens = rest[p.i :] + nums, alpha = _oklch_channels(tokens, ev, base) + L, C, H = (n.value for n in nums) + # A percentage lightness is 0–1; `oklch(50% …)` and `oklch(0.5 …)` agree. + return Color(*oklch_to_oklab(L, C, H), alpha) + + +def _parse_oklab(groups, ev) -> Color: + tokens = _flat(groups) + base = None + if tokens and tokens[0][0] == "ident" and tokens[0][1].lower() == "from": + rest = tokens[1:] + p = _Parser(rest, ev) + base = p.value() + tokens = rest[p.i :] + body, alpha_tokens = _split_slash(tokens) + bindings = {} + if base is not None: + bindings = {"l": base.L, "a": base.a, "b": base.b, "alpha": base.alpha} + with ev.bound(bindings): + nums = _components(body, ev, 3) + alpha = base.alpha if base is not None else 1.0 + if alpha_tokens: + alpha = _Parser(alpha_tokens, ev).sum_().value + return Color(nums[0].value, nums[1].value, nums[2].value, alpha) + + +def _parse_color_mix(groups, ev) -> Color: + # color-mix(in oklab, [pct], [pct]) + space_tokens = groups[0] + space = " ".join(t[1] for t in space_tokens).replace("in ", "").strip() + if space not in ("oklab", "oklch", "srgb"): + raise ValueError(f"unsupported color-mix space {space!r}") + + parsed: list[tuple[Color, float | None]] = [] + for g in groups[1:3]: + p = _Parser(g, ev) + col = p.value() + if not isinstance(col, Color): + raise ValueError("color-mix needs colors") + pct = None + if p.peek() is not None: + pct = p.sum_().value + parsed.append((col, pct)) + + (ca, pa), (cb, pb) = parsed + if pa is None and pb is None: + pa = pb = 0.5 + elif pa is None: + pa = 1.0 - pb + elif pb is None: + pb = 1.0 - pa + total = pa + pb + if total == 0: + raise ValueError("color-mix percentages sum to zero") + pa, pb = pa / total, pb / total + + # CSS mixes premultiplied by alpha, then un-premultiplies. + alpha = ca.alpha * pa + cb.alpha * pb + if space == "srgb": + ra, ga, ba = ca.gamut_mapped() + rb, gb, bb = cb.gamut_mapped() + comps = [ + (x * ca.alpha * pa + y * cb.alpha * pb) for x, y in ((ra, rb), (ga, gb), (ba, bb)) + ] + if alpha > 0: + comps = [x / alpha for x in comps] + return Color.from_srgb(*comps, alpha) + + if space == "oklch": + La, Ca, Ha = ca.oklch + Lb, Cb, Hb = cb.oklch + # shorter hue arc + if abs(Hb - Ha) > 180: + if Hb > Ha: + Ha += 360 + else: + Hb += 360 + L = La * ca.alpha * pa + Lb * cb.alpha * pb + C = Ca * ca.alpha * pa + Cb * cb.alpha * pb + H = Ha * ca.alpha * pa + Hb * cb.alpha * pb + if alpha > 0: + L, C, H = L / alpha, C / alpha, H / alpha + return Color(*oklch_to_oklab(L, C, H % 360), alpha) + + L = ca.L * ca.alpha * pa + cb.L * cb.alpha * pb + a = ca.a * ca.alpha * pa + cb.a * cb.alpha * pb + b = ca.b * ca.alpha * pa + cb.b * cb.alpha * pb + if alpha > 0: + L, a, b = L / alpha, a / alpha, b / alpha + return Color(L, a, b, alpha) + + +# --------------------------------------------------------------------------- +# Evaluator +# --------------------------------------------------------------------------- + +class _Bound: + def __init__(self, ev: "Evaluator", extra: dict): + self.ev = ev + self.extra = extra + + def __enter__(self): + self.saved = self.ev.bindings + self.ev.bindings = {**self.ev.bindings, **self.extra} + + def __exit__(self, *exc): + self.ev.bindings = self.saved + return False + + +class Evaluator: + """Resolves `--nv-*` custom properties for one appearance.""" + + def __init__(self, declarations: dict[str, str], appearance: str = "light"): + self.declarations = declarations + self.appearance = appearance + self.bindings: dict[str, float] = {} + self._cache: dict[tuple[str, str], object] = {} + self._stack: list[str] = [] + + def bound(self, extra: dict) -> _Bound: + return _Bound(self, extra) + + def override(self, name: str, raw: str) -> None: + self.declarations[name] = raw + self._cache.clear() + + def resolve(self, name: str, fallback=None): + key = (name, self.appearance) + if key in self._cache: + return self._cache[key] + if name not in self.declarations: + if fallback is not None: + return _Parser(fallback, self).value() + raise KeyError(f"undefined custom property {name}") + if name in self._stack: + raise ValueError(f"cyclic token reference: {' -> '.join(self._stack + [name])}") + self._stack.append(name) + # Bindings belong to the oklch(from …) that created them, not to a + # token resolved from inside it. + saved, self.bindings = self.bindings, {} + try: + value = self.eval(self.declarations[name]) + finally: + self.bindings = saved + self._stack.pop() + self._cache[key] = value + return value + + def eval(self, raw: str): + return _Parser(_lex(raw.strip()), self).value() + + def color(self, name: str) -> Color: + v = self.resolve(name) + if not isinstance(v, Color): + raise TypeError(f"{name} is not a color: {v!r}") + return v + + +# --------------------------------------------------------------------------- +# Reading declarations out of the CSS +# --------------------------------------------------------------------------- + +_COMMENT = re.compile(r"/\*.*?\*/", re.S) +_DECL = re.compile(r"(--[-a-zA-Z0-9_]+)\s*:\s*([^;}]+)") + + +def read_declarations(paths, skip_at_rules=True) -> dict[str, str]: + """Collect every `--nv-*: value` declaration, last one winning. + + At-rule bodies (`@media (prefers-reduced-motion)`, `@supports not …`) are + skipped: they are alternate appearances of a token, and baking a + reduced-motion duration into the default build would be wrong. + """ + out: dict[str, str] = {} + for path in paths: + with open(path, encoding="utf-8") as fh: + css = _COMMENT.sub("", fh.read()) + if skip_at_rules: + css = _strip_at_rules(css) + for name, value in _DECL.findall(css): + out[name] = " ".join(value.split()) + return out + + +def _strip_at_rules(css: str) -> str: + out = [] + i = 0 + while i < len(css): + at = css.find("@", i) + if at == -1: + out.append(css[i:]) + break + out.append(css[i:at]) + # Skip to the matching close brace of the at-rule block. + brace = css.find("{", at) + if brace == -1: + break + depth = 0 + j = brace + while j < len(css): + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + i = j + 1 + return "".join(out) + + +def read_at_rule_declarations(paths, condition: str) -> dict[str, str]: + """Declarations from at-rule blocks whose prelude contains `condition`.""" + out: dict[str, str] = {} + for path in paths: + with open(path, encoding="utf-8") as fh: + css = _COMMENT.sub("", fh.read()) + for prelude, body in _iter_at_rules(css): + if condition in prelude: + for name, value in _DECL.findall(body): + out[name] = " ".join(value.split()) + return out + + +def _iter_at_rules(css: str): + i = 0 + while True: + at = css.find("@", i) + if at == -1: + return + brace = css.find("{", at) + if brace == -1: + return + depth = 0 + j = brace + while j < len(css): + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + yield css[at:brace], css[brace + 1 : j] + i = j + 1 + + +# --------------------------------------------------------------------------- +# Reading component rules +# --------------------------------------------------------------------------- +# +# The token layer is custom properties on :root, which read_declarations +# covers. A port that draws controls natively (a Qt style, Compose) also needs +# the component layer: which role a secondary button's background is, which +# shadow it gains on hover, what its disabled border becomes. Those live in +# ordinary rules in components/*.css, and restating them in a port would fork +# the system just as surely as restating a colour. So they are read here too. + +def _split_selectors(prelude: str) -> list[str]: + """`a, b:not(c, d)` -> [`a`, `b:not(c, d)`], whitespace collapsed.""" + out, depth, cur = [], 0, [] + for ch in prelude: + if ch in "([": + depth += 1 + elif ch in ")]": + depth -= 1 + if ch == "," and depth == 0: + out.append(" ".join("".join(cur).split())) + cur = [] + continue + cur.append(ch) + tail = " ".join("".join(cur).split()) + if tail: + out.append(tail) + return out + + +def _parse_block_declarations(body: str) -> dict[str, str]: + out: dict[str, str] = {} + depth, cur = 0, [] + for ch in body + ";": + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if ch == ";" and depth == 0: + decl = "".join(cur).strip() + cur = [] + if ":" in decl: + name, value = decl.split(":", 1) + out[name.strip()] = " ".join(value.split()) + continue + cur.append(ch) + return out + + +def read_rules(paths, media: tuple[str, ...] = ("hover: hover",)) -> dict[str, dict[str, str]]: + """Every style rule, as selector -> {property: value}, last one winning. + + A grouped selector (`a, b { … }`) is recorded under each of its members. + Rules inside `@media` blocks whose condition contains one of `media` are + read as if they were top level: `(hover: hover)` only says the device has a + pointer, which a desktop always does. Every other at-rule is skipped; + those are alternate states (reduced motion, missing features, entry + animations) rather than the component's normal appearance. + """ + rules: dict[str, dict[str, str]] = {} + + def walk(css: str) -> None: + i = 0 + while True: + brace = css.find("{", i) + if brace == -1: + return + prelude = css[i:brace].strip() + depth, j = 0, brace + while j < len(css): + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + if depth == 0: + break + j += 1 + body = css[brace + 1 : j] + if prelude.startswith("@"): + if prelude.startswith("@media") and any(m in prelude for m in media): + walk(body) + else: + decls = _parse_block_declarations(body) + for sel in _split_selectors(prelude): + rules.setdefault(sel, {}).update(decls) + i = j + 1 + + for path in paths: + with open(path, encoding="utf-8") as fh: + walk(_COMMENT.sub("", fh.read())) + return rules