mirror of
https://github.com/Nexoniarz/Niveus.git
synced 2026-11-03 20:54:25 +00:00
Niveus: a tactile, adaptive interface language
Bevelled controls with light falling across them, fields as wells, solid windows, and glass kept for the chrome that floats over everything else. Warm graphite in dark, soft paper in light, one brand color driving both. Ships its own fonts (Instrument Sans, JetBrains Mono; OFL). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
23b8dd376d
51 changed files with 15664 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
__pycache__/
|
||||||
201
LICENSE
Normal file
201
LICENSE
Normal file
|
|
@ -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.
|
||||||
465
README.md
Normal file
465
README.md
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
# Niveus
|
||||||
|
|
||||||
|
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
|
||||||
|
<link rel="stylesheet" href="dist/niveus.css"> <!-- with fonts/ beside dist/ -->
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<html data-theme="dark"> <!-- omit data-theme to follow the system -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `index.html` for the full specimen: materials, the elevation scale, every
|
||||||
|
semantic color, the type scale, and each component in both appearances.
|
||||||
|
`examples/` holds four complete products built with it.
|
||||||
|
|
||||||
|
**[REFERENCE.md](REFERENCE.md) is the complete API** — every class, token,
|
||||||
|
attribute and function, each listed beside the name the rest of the industry
|
||||||
|
uses for it. It is written to be read by a person *or* handed to a model on its
|
||||||
|
own: an assistant that has only that file can write correct Niveus markup
|
||||||
|
without seeing a line of the CSS. `tools/check.py` fails if a class exists in
|
||||||
|
the CSS and is missing from it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
Reference the system, never restate it:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* No */
|
||||||
|
.thing { background: #ffffff; border-radius: 12px; padding: 10px 16px; }
|
||||||
|
|
||||||
|
/* Yes */
|
||||||
|
.thing {
|
||||||
|
background: var(--nv-color-surface-elevated);
|
||||||
|
border-radius: var(--nv-radius-md);
|
||||||
|
padding: var(--nv-space-2-5) var(--nv-space-4);
|
||||||
|
box-shadow: var(--nv-highlight-top), var(--nv-elevation-2);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Application-specific values are fine when they describe *your* content and
|
||||||
|
layout — a sidebar that happens to be 15rem wide, an aspect ratio, a grid
|
||||||
|
template. They are not fine when they restate the design language.
|
||||||
|
|
||||||
|
Everything ships inside the `niveus` cascade layer, so unlayered application
|
||||||
|
styles always win. Overriding a Niveus component never requires `!important`
|
||||||
|
or a longer selector.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
niveus/
|
||||||
|
├── tokens/ the values, appearance-agnostic
|
||||||
|
│ ├── color.css palette + semantic roles
|
||||||
|
│ ├── typography.css families, scale, weights, composite roles
|
||||||
|
│ ├── spacing.css 4px rhythm + control sizing
|
||||||
|
│ ├── radius.css corner scale
|
||||||
|
│ ├── elevation.css the 0–5 depth scale, inset, highlight, focus ring
|
||||||
|
│ ├── motion.css durations, easings, displacement
|
||||||
|
│ └── materials.css blur, backdrop recipes, material definitions
|
||||||
|
│
|
||||||
|
├── themes/ what those values become in each appearance
|
||||||
|
│ ├── light.css
|
||||||
|
│ └── dark.css
|
||||||
|
│
|
||||||
|
├── base.css elements, before any component
|
||||||
|
├── continuity.css how one state becomes the next
|
||||||
|
├── components/ surface, button, input, card, dialog, navigation,
|
||||||
|
│ menu, disclosure, scroller, bed, badge, table
|
||||||
|
├── utilities.css the short list
|
||||||
|
├── niveus.css the entry point
|
||||||
|
└── niveus.js optional — continuity for changes CSS cannot animate
|
||||||
|
|
||||||
|
examples/ four complete interfaces built from the system
|
||||||
|
├── ai-landing/ marketing page
|
||||||
|
├── ai-chat/ chat web app
|
||||||
|
├── it-shop/ hardware shop
|
||||||
|
└── dashboard/ monitoring dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
`niveus/niveus.css` is the authored entry point and works directly over HTTP.
|
||||||
|
`dist/niveus.css` is the same thing with the `@import` chain flattened into one
|
||||||
|
request — use it for anything shipped.
|
||||||
|
|
||||||
|
## Theming
|
||||||
|
|
||||||
|
A component never names a color. It names a **role**, and the appearance decides
|
||||||
|
what that role is made of:
|
||||||
|
|
||||||
|
`background` · `surface` · `surface-elevated` · `surface-inset` ·
|
||||||
|
`text-primary` · `text-secondary` · `text-tertiary` · `border` · `accent` ·
|
||||||
|
`success` · `warning` · `danger`
|
||||||
|
|
||||||
|
Each role resolves through `light-dark()`, pulling from `--nv-light-*` in
|
||||||
|
`themes/light.css` or `--nv-dark-*` in `themes/dark.css`. The appearance is
|
||||||
|
selected by `color-scheme`, which the theme files bind to `[data-theme]`:
|
||||||
|
|
||||||
|
| `data-theme` | Result |
|
||||||
|
| ------------ | ----------------------------- |
|
||||||
|
| *(absent)* | follows the operating system |
|
||||||
|
| `light` | light, always |
|
||||||
|
| `dark` | dark, always |
|
||||||
|
|
||||||
|
Dark is not light inverted. Surfaces get *lighter* as they rise, shadows deepen
|
||||||
|
rather than disappear, the accent moves up its ramp to stay legible, and the top
|
||||||
|
highlight thins to a faint rim.
|
||||||
|
|
||||||
|
## Materials and depth
|
||||||
|
|
||||||
|
**Material** answers what light does when it hits a surface. **Elevation**
|
||||||
|
answers how far that surface is from the canvas. They are independent.
|
||||||
|
|
||||||
|
| Material | Use for |
|
||||||
|
| ---------- | ---------------------------------------------- |
|
||||||
|
| `solid` | content. The default. |
|
||||||
|
| `elevated` | surfaces that must be read before their context |
|
||||||
|
| `inset` | fields, wells, tracks — anything recessed |
|
||||||
|
| `frosted` | chrome that content passes under: bars, toolbars |
|
||||||
|
| `glass` | contextual layers where content behind must stay legible |
|
||||||
|
|
||||||
|
| Elevation | Relationship |
|
||||||
|
| --------- | ------------------------------- |
|
||||||
|
| 0 | flush — painted on the canvas |
|
||||||
|
| 1 | resting — cards, rows, panels |
|
||||||
|
| 2 | raised — anything that reacts |
|
||||||
|
| 3 | floating — popovers, menus |
|
||||||
|
| 4 | lifted — dialogs, sheets |
|
||||||
|
| 5 | overlay — topmost, used sparingly |
|
||||||
|
|
||||||
|
Applied as classes: `.nv-material-frosted`, `.nv-elevation-3`. A shadow that is
|
||||||
|
on neither scale is a shadow that means nothing.
|
||||||
|
|
||||||
|
## Continuity
|
||||||
|
|
||||||
|
Every interaction should read as a continuation of the state before it. When
|
||||||
|
something moves, grows, collapses or changes appearance, the change itself
|
||||||
|
should be perceptible — where it came from, where it went, and why. The goal is
|
||||||
|
not to animate everything. It is that nothing pops into a new state the user
|
||||||
|
could have watched it reach.
|
||||||
|
|
||||||
|
| Change | How it stays continuous |
|
||||||
|
| ------ | ----------------------- |
|
||||||
|
| Selection moves between items | The marker itself travels. It is a pseudo-element with a shared `view-transition-name`, so the browser interpolates its old box into its new one. |
|
||||||
|
| Switching tabs | Panels cross-fade inside one box and slide with the direction of travel — forward one way, back the other. |
|
||||||
|
| Content expanding | `interpolate-size` lets `block-size: auto` animate, so content grows from the row that revealed it. Nothing is measured. |
|
||||||
|
| Menus and dialogs | A menu is anchored to its trigger and scales out of its edge; a dialog expands away from the control that opened it. |
|
||||||
|
| Theme change | The whole surface cross-fades. Surfaces also transition their own colors, so it glides even with no script. |
|
||||||
|
| Hover, press, focus, select | All have a duration. Marks grow from their centre, the switch thumb travels its track. |
|
||||||
|
|
||||||
|
Most of this is plain CSS. The three cases CSS cannot do alone — a selection
|
||||||
|
moving *between two elements*, one panel replacing another, and the whole page
|
||||||
|
changing appearance — need a view transition, which needs one line of script:
|
||||||
|
|
||||||
|
```js
|
||||||
|
nv.transition(() => { // or document.startViewTransition directly
|
||||||
|
previous.removeAttribute("aria-current");
|
||||||
|
next.setAttribute("aria-current", "page");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`niveus.js` is optional and dependency-free. Without it every component still
|
||||||
|
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.
|
||||||
|
|
||||||
|
### One rule worth knowing
|
||||||
|
|
||||||
|
A `view-transition-name` must be unique among all rendered elements, or the
|
||||||
|
browser skips the transition entirely. Each selection group therefore needs its
|
||||||
|
own marker name:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<nav class="nv-nav" style="--nv-selection-name: nv-sel-sidebar"> … </nav>
|
||||||
|
<nav class="nv-nav-tabs" style="--nv-selection-name: nv-sel-tabs"> … </nav>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tab strips default to `nv-tabs-selection` and everything else to
|
||||||
|
`nv-selection`, which is enough for one of each per page.
|
||||||
|
|
||||||
|
### Motion under a transition
|
||||||
|
|
||||||
|
Transition layers always paint above the page, so a travelling marker cannot
|
||||||
|
stay behind the labels it passes the way it does at rest. Rather than let an
|
||||||
|
opaque marker wipe them, Niveus thins it while it is in flight: it stays
|
||||||
|
visible the whole way — it can still be followed — but reads as having lifted
|
||||||
|
off the surface to travel.
|
||||||
|
|
||||||
|
`prefers-reduced-motion` collapses every duration at the token level and skips
|
||||||
|
view transitions entirely. The change still happens; it just arrives.
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
The whole color system has one input:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root { --nv-brand: #d92e3f; }
|
||||||
|
```
|
||||||
|
|
||||||
|
From it the system derives the accent and its hover and press states, the text
|
||||||
|
that lands on it, the link color, the focus ring, the selection highlight, the
|
||||||
|
tint in the greys, and how far the status colors lean — for both appearances,
|
||||||
|
with nothing else to set. No component is involved: they all name roles, and
|
||||||
|
the roles re-derive.
|
||||||
|
|
||||||
|
Three things make a single input actually work rather than just recolor a
|
||||||
|
button:
|
||||||
|
|
||||||
|
**The label is derived, not chosen.** Every filled color computes its own
|
||||||
|
foreground by reading its lightness: below a threshold it resolves to
|
||||||
|
near-white, above it to near-black. A yellow brand gets dark labels and a navy
|
||||||
|
one light labels, and no one has to remember to update a second token.
|
||||||
|
|
||||||
|
**The color is moved until its label is readable.** A mid-tone is the one place
|
||||||
|
where neither white nor black reaches 4.5:1, so a brand that lands there is
|
||||||
|
darkened until white works — or, if already light, lightened until black does.
|
||||||
|
Hue and chroma are untouched; only lightness moves, and only as far as it must.
|
||||||
|
This is why a mid-tone green arrives slightly deeper than the swatch you set.
|
||||||
|
|
||||||
|
**The rest of the palette follows.** Status colors lean towards the brand by
|
||||||
|
`--nv-brand-harmony` (12%) — red still reads as danger, it just stops looking
|
||||||
|
borrowed from another system — and the greys carry `--nv-brand-tint` (4%) of
|
||||||
|
it, which is what stops an accent from looking stuck on. Set either to `0%` for
|
||||||
|
literal status colors and strictly neutral greys.
|
||||||
|
|
||||||
|
| Input | Default | What it does |
|
||||||
|
| ----- | ------- | ------------ |
|
||||||
|
| `--nv-brand` | `#4f6cff` | the one color everything derives from |
|
||||||
|
| `--nv-brand-tint` | `4%` | how much brand bleeds into the greys |
|
||||||
|
| `--nv-brand-harmony` | `12%` | how far status colors lean towards it |
|
||||||
|
| `--nv-contrast-pivot` | `0.7` | where "too mid-toned to carry a label" begins |
|
||||||
|
| `--nv-contrast-dark` / `-light` | `0.53` / `0.78` | how far either side a color is pushed |
|
||||||
|
|
||||||
|
### Branding a subtree
|
||||||
|
|
||||||
|
`data-nv-brand` re-derives the whole set for one region, so a section can carry
|
||||||
|
its own color without a second theme:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<section data-nv-brand style="--nv-brand: #0d9488">
|
||||||
|
<button class="nv-button nv-button--primary">Upgrade</button>
|
||||||
|
</section>
|
||||||
|
```
|
||||||
|
|
||||||
|
The attribute is required, not decoration: `var()` inside a custom property is
|
||||||
|
substituted where that property is *declared*, so a `--nv-brand` set on a bare
|
||||||
|
element would never reach the roles. `[data-nv-brand]` is the selector that
|
||||||
|
re-runs the derivation.
|
||||||
|
|
||||||
|
### Overriding a role outright
|
||||||
|
|
||||||
|
Deriving is the default, not a requirement. Any role can be set directly, for
|
||||||
|
the whole page or for a subtree:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.danger-zone { --nv-color-accent: var(--nv-color-danger); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything inside follows — buttons, focus rings, links, switches — because
|
||||||
|
none of them ever named a color.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
`examples/` holds four complete interfaces. Open `examples/index.html`, or go
|
||||||
|
straight to one:
|
||||||
|
|
||||||
|
| | | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Lumen** | `examples/ai-landing/` | AI product landing page — hero, features, a scrolling row, pricing, FAQ |
|
||||||
|
| **Lumen Chat** | `examples/ai-chat/` | chat web app — sidebar, transcript, composer, settings dialog with tabs |
|
||||||
|
| **Northwire** | `examples/it-shop/` | hardware shop — filters, product grid, stock states, basket |
|
||||||
|
| **Northwire Ops** | `examples/dashboard/` | monitoring dashboard — stat tiles, a chart, a service table, incidents |
|
||||||
|
|
||||||
|
They look like four different products. None of them overrides a component,
|
||||||
|
forks a token, or writes a color. What differs between them is one declaration:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root { --nv-brand: #ea580c; } /* the shop */
|
||||||
|
```
|
||||||
|
|
||||||
|
Each links exactly two files from the system — `dist/niveus.css` and the
|
||||||
|
optional `niveus/niveus.js` — and then its own `app.css`, which contains grids,
|
||||||
|
widths and breakpoints and **not a single hex value**. `tools/check.py` enforces
|
||||||
|
that: a color anywhere in an example, other than `--nv-brand` itself, fails the
|
||||||
|
build. An example that quietly reaches for a hex value is no longer
|
||||||
|
demonstrating the system; it is demonstrating how to work around one.
|
||||||
|
|
||||||
|
Where an example needed something the system did not have, the answer was to
|
||||||
|
add it to the system: `.nv-badge` and `.nv-table` exist because these pages
|
||||||
|
asked for them.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
Niveus is not a folder of CSS. It is a set of decisions that have already been
|
||||||
|
made, so that building an interface stops being a series of small aesthetic
|
||||||
|
arguments.
|
||||||
|
|
||||||
|
**Niveus is not about making interfaces look physical. It is about making
|
||||||
|
digital surfaces feel understandable.** Depth, material and motion are here to
|
||||||
|
answer questions the user is already asking — what is this, is it above or
|
||||||
|
below, where did it come from, did my click register — and for nothing else. An
|
||||||
|
effect that answers no question is decoration, and decoration is what makes an
|
||||||
|
interface tiring.
|
||||||
|
|
||||||
|
### The principles, and what each one costs
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| **Clarity** | Every element has a purpose. Decoration never competes with content. |
|
||||||
|
| **Soft physicality** | Depth comes from shadow, highlight and inset — not from ornament. |
|
||||||
|
| **Material** | Surfaces are made of something. Solid, elevated, inset, frosted, glass. |
|
||||||
|
| **Restraint** | No effect exists because it is possible. Translucency and shadow are spent, not sprinkled. |
|
||||||
|
| **Consistency** | The same control behaves the same way everywhere, including under a pointer. |
|
||||||
|
| **Adaptability** | Nothing is defined in terms of an appearance or a color. |
|
||||||
|
| **Continuity** | Nothing pops into a new state the user could have watched it reach. |
|
||||||
|
|
||||||
|
They constrain each other on purpose. Continuity says to animate the change;
|
||||||
|
restraint says most changes do not need it. Material says to use translucency;
|
||||||
|
restraint says only for layers that pass over content. When two pull in
|
||||||
|
different directions, restraint wins — a quiet interface with one thing moving
|
||||||
|
reads clearly, and a busy one does not.
|
||||||
|
|
||||||
|
### Where the line runs
|
||||||
|
|
||||||
|
Niveus decides **how the application looks and behaves**. The application
|
||||||
|
decides **what it does**. In practice:
|
||||||
|
|
||||||
|
- A color, a shadow, a radius, a duration, a control height → the system.
|
||||||
|
- A sidebar that happens to be 15rem wide, an aspect ratio, a grid template →
|
||||||
|
the application.
|
||||||
|
- A value that is *about* your content is yours. A value that restates the
|
||||||
|
design language is the system's, and hard-coding it is how a system quietly
|
||||||
|
stops being one.
|
||||||
|
|
||||||
|
### Adding to the system
|
||||||
|
|
||||||
|
Three questions, in order:
|
||||||
|
|
||||||
|
1. **Can it be expressed with what already exists?** Most new requirements are
|
||||||
|
an existing component with a modifier, or a pattern — a composition of
|
||||||
|
components that needs no new CSS at all. The patterns on the specimen page
|
||||||
|
introduce nothing.
|
||||||
|
2. **Is it a rule, or is it this once?** A component earns its place when it
|
||||||
|
can be stated as a reusable principle. "Selection emphasis travels" is a
|
||||||
|
rule. "This menu is 14px from its button" is not.
|
||||||
|
3. **Does it hold in both appearances, at every elevation, and under a
|
||||||
|
pointer?** If it only works on white, or only when nothing is behind it, it
|
||||||
|
is not finished.
|
||||||
|
|
||||||
|
A new token follows the same test. If a value appears three times and means the
|
||||||
|
same thing each time, it is a token. If it appears three times and means
|
||||||
|
something different each time, three different things were given the same
|
||||||
|
number by accident.
|
||||||
|
|
||||||
|
### Working on it
|
||||||
|
|
||||||
|
Every rule in this repo is checked rather than trusted:
|
||||||
|
|
||||||
|
- `tools/check.py` fails on a role defined for one appearance but not the
|
||||||
|
other, a token referenced but never defined, any hard-coded color in a
|
||||||
|
component *or an example*, and any class that exists in the CSS but is
|
||||||
|
missing from `REFERENCE.md`.
|
||||||
|
- Contrast is measured, not assumed — the derived label on every filled color
|
||||||
|
clears 4.5:1 for any brand.
|
||||||
|
- `tools/build.py --check` fails if `dist/` has drifted from the sources.
|
||||||
|
|
||||||
|
If a change cannot be made without breaking one of those, the change is
|
||||||
|
probably not the problem the system has.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| Class | Notes |
|
||||||
|
| -------------- | --------------------------------------------------------- |
|
||||||
|
| `.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-dialog` | native `<dialog>`; `--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` |
|
||||||
|
| `.nv-disclosure` | native `<details>`; `.nv-disclosure-group`, plus `.nv-collapse` for other markup |
|
||||||
|
| `.nv-scroller` | snapping row inside `.nv-scroller-frame`, with edge fades and optional controls |
|
||||||
|
| `.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-surface` | materials and elevation as utilities |
|
||||||
|
|
||||||
|
Variants are defined by reassigning private `--_*` properties. Interaction —
|
||||||
|
the lift on hover, the sink on press, the focus ring — is written once per
|
||||||
|
component, so a new variant cannot drift from how the rest of the system
|
||||||
|
responds to a pointer.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- One focus treatment system-wide: a contrast ring under an accent ring, so it
|
||||||
|
stays visible on any surface including accent-filled controls.
|
||||||
|
- Controls shorter than 44px still get a 44px pointer target.
|
||||||
|
- `prefers-reduced-motion` collapses duration and displacement to zero at the
|
||||||
|
token level — no component opts in individually.
|
||||||
|
- `prefers-reduced-transparency`, and browsers without `backdrop-filter`, fall
|
||||||
|
back from frosted and glass to their solid equivalents.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/build.py # flatten the @import chain into dist/niveus.css
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Browser support
|
||||||
|
|
||||||
|
Niveus uses `light-dark()`, cascade layers, `:has()`, `@starting-style` and
|
||||||
|
`allow-discrete` transitions — all baseline in current Chrome, Safari, Firefox
|
||||||
|
and Edge. `backdrop-filter` degrades to a solid surface where it is missing.
|
||||||
|
|
||||||
|
The continuity layer builds on newer platform features, each of which degrades
|
||||||
|
to the state change simply happening rather than travelling:
|
||||||
|
|
||||||
|
| Feature | Used for | Without it |
|
||||||
|
| ------- | -------- | ---------- |
|
||||||
|
| View transitions | travelling selection, tab panels, theme cross-fade | the change applies instantly |
|
||||||
|
| `interpolate-size` | disclosure height | content appears at full height (`.nv-collapse` still animates) |
|
||||||
|
| Anchor positioning | menus tied to their trigger | the menu centres itself |
|
||||||
|
| `popover` | menus, light dismiss | needs the application's own toggle |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 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**.
|
||||||
430
REFERENCE.md
Normal file
430
REFERENCE.md
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
# Niveus — reference
|
||||||
|
|
||||||
|
Every class, token, attribute and function the system exposes, with the name
|
||||||
|
the rest of the industry uses for it.
|
||||||
|
|
||||||
|
Two audiences:
|
||||||
|
|
||||||
|
- **People.** Find the thing you mean by its usual name, then use the class
|
||||||
|
beside it.
|
||||||
|
- **Models.** This file is the whole API. Hand it over on its own and an
|
||||||
|
assistant can build correct Niveus markup without seeing the CSS: it does not
|
||||||
|
need to know *how* `.nv-nav-tabs` works, only that a tab list is spelled that
|
||||||
|
way here. Nothing below is aspirational — `tools/check.py` fails if a class
|
||||||
|
exists in the CSS and is missing from this file.
|
||||||
|
|
||||||
|
Everything ships inside the `niveus` cascade layer, so unlayered application
|
||||||
|
styles win without `!important`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="dist/niveus.css"> <!-- keep fonts/ beside dist/ -->
|
||||||
|
<script src="niveus/niveus.js" defer></script> <!-- optional -->
|
||||||
|
<html data-theme="dark"> <!-- omit to follow the OS -->
|
||||||
|
<style>:root { --nv-brand: #d92e3f; }</style> <!-- the whole identity -->
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Button
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-button` | button, secondary button | Raised surface. The default; use it for anything that is not *the* action. |
|
||||||
|
| `.nv-button--primary` | filled / CTA button | The single affirmative action in a view. |
|
||||||
|
| `.nv-button--subtle` | tonal button | Accent-tinted fill, no elevation. Mid-emphasis. |
|
||||||
|
| `.nv-button--ghost` | text / quiet button | No material until hovered. Lowest emphasis. |
|
||||||
|
| `.nv-button--danger` | destructive button | Irreversible actions only. |
|
||||||
|
| `.nv-button--frosted` | glass button | For use over imagery or frosted chrome. |
|
||||||
|
| `.nv-button--float` | FAB, overlay control | Floats over content: grows on hover instead of lifting, and keeps its own positioning via `--nv-button-offset`. |
|
||||||
|
| `.nv-button--sm` `.nv-button--lg` | size variants | 28px and 44px; default is 36px. |
|
||||||
|
| `.nv-button--icon` | icon button | Square. Requires `aria-label`. |
|
||||||
|
| `.nv-button--pill` | pill button | Fully rounded. |
|
||||||
|
| `.nv-button--block` | full-width button | Fills its container. |
|
||||||
|
| `.nv-button__icon` | button icon slot | Sizes an inline SVG to the label. |
|
||||||
|
| `.nv-button-group` | segmented control, button group | Buttons sharing edges; only outer corners round. |
|
||||||
|
|
||||||
|
States come from the element: `disabled`, `aria-disabled`, `aria-busy="true"`
|
||||||
|
(shows a spinner and keeps the width), `aria-pressed` for toggles.
|
||||||
|
|
||||||
|
## Form controls
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-field` | form field, form group | Label + control + hint, correctly spaced. |
|
||||||
|
| `.nv-field__label` | field label | Add `data-optional="(optional)"` to append a muted suffix. |
|
||||||
|
| `.nv-field__hint` | helper text | Guidance below the control. |
|
||||||
|
| `.nv-field__error` | validation message | Pair with `aria-invalid="true"` + `aria-describedby`. |
|
||||||
|
| `.nv-input` | text field, form control | `input`, `textarea` and `select`. Inset material. |
|
||||||
|
| `.nv-input--sm` `.nv-input--lg` | size variants | Match the button sizes. |
|
||||||
|
| `.nv-input--color` | color well / swatch input | `input[type=color]` as a square swatch. |
|
||||||
|
| `.nv-input-group` | input with adornment | Wrapper for a field with a prefix or suffix. |
|
||||||
|
| `.nv-input-group__affix` | prefix / suffix adornment | Sits inside the field's recess. First child = leading, last = trailing. |
|
||||||
|
| `.nv-select` | select, dropdown | Wrapper that draws the chevron around a native `select.nv-input`. |
|
||||||
|
| `.nv-checkbox` | checkbox | Native input, custom mark. Supports `:indeterminate`. |
|
||||||
|
| `.nv-radio` | radio button | Native input, custom mark. |
|
||||||
|
| `.nv-switch` | toggle switch | Inset track, elevated thumb that travels. |
|
||||||
|
| `.nv-choice` | control with inline label | `<label>` wrapping a control and its text. |
|
||||||
|
|
||||||
|
## Card
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-card` | card, panel | Resting container at elevation 1. |
|
||||||
|
| `.nv-card--elevated` | raised card | Elevation 2 — read before its surroundings. |
|
||||||
|
| `.nv-card--floating` | floating card | Elevation 3. |
|
||||||
|
| `.nv-card--inset` | well, sunken panel | Recessed into its parent. |
|
||||||
|
| `.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__header` | card header | |
|
||||||
|
| `.nv-card__title` | card title | |
|
||||||
|
| `.nv-card__subtitle` | card subtitle, supporting text | |
|
||||||
|
| `.nv-card__body` | card content | |
|
||||||
|
| `.nv-card__footer` | card actions | |
|
||||||
|
| `.nv-card__footer--plain` | undivided card actions | Drops the divider above the footer. |
|
||||||
|
| `.nv-card__media` | card media | 16:9 image slot. |
|
||||||
|
| `.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. |
|
||||||
|
|
||||||
|
## Dialog
|
||||||
|
|
||||||
|
Built on native `<dialog>`; open with `showModal()` or `nv.openDialog()`.
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-dialog` | modal dialog | Elevation 4 over a blurred scrim. Focus trap and Escape are the platform's. |
|
||||||
|
| `.nv-dialog--sheet` | bottom sheet | Enters from the block end, flush to the edges. |
|
||||||
|
| `.nv-dialog--compact` `.nv-dialog--wide` | width variants | 24rem / 44rem; default 30rem. |
|
||||||
|
| `.nv-dialog__header` | dialog header | |
|
||||||
|
| `.nv-dialog__title` | dialog title | Point `aria-labelledby` at it. |
|
||||||
|
| `.nv-dialog__description` | dialog supporting text | |
|
||||||
|
| `.nv-dialog__body` | dialog content | The only scrolling region. |
|
||||||
|
| `.nv-dialog__footer` | dialog actions | |
|
||||||
|
| `.nv-dialog__footer--divided` | grounded dialog actions | For a footer sitting over scrollable content. |
|
||||||
|
| `.nv-dialog__close` | close button | Optical alignment for a ghost icon button. |
|
||||||
|
|
||||||
|
## Menu
|
||||||
|
|
||||||
|
Built on the `popover` attribute and anchor positioning. Trigger and menu name
|
||||||
|
the same anchor through `--nv-anchor`.
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-menu` | dropdown menu, popover menu | Frosted surface anchored to its trigger. |
|
||||||
|
| `.nv-menu-trigger` | menu button | Sets `anchor-name` from `--nv-anchor`. |
|
||||||
|
| `.nv-menu__item` | menu item | `aria-checked="true"` draws a tick. |
|
||||||
|
| `.nv-menu__item--danger` | destructive menu item | |
|
||||||
|
| `.nv-menu__label` | menu section label / group heading | |
|
||||||
|
| `.nv-menu__separator` | menu divider | |
|
||||||
|
| `.nv-menu__shortcut` | keyboard shortcut hint, accelerator | Trailing muted text. |
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-nav` | app bar, navigation bar, header | Frosted, sticky chrome. |
|
||||||
|
| `.nv-nav--vertical` | sidebar, side navigation | Full-height, solid; drops the translucency. |
|
||||||
|
| `.nv-nav--plain` | solid bar | Opaque instead of frosted. |
|
||||||
|
| `.nv-nav--static` | non-sticky bar | |
|
||||||
|
| `.nv-nav--inline` | inline nav group | A navigation used inside content, not as chrome. |
|
||||||
|
| `.nv-nav__brand` | brand / logo lockup | |
|
||||||
|
| `.nv-nav__list` | nav list | |
|
||||||
|
| `.nv-nav__item` | nav item, nav link | Selected via `aria-current`, `aria-selected` or `--active`. |
|
||||||
|
| `.nv-nav__item--active` | active nav item | For when no ARIA state fits. |
|
||||||
|
| `.nv-nav__section` | nav section heading | |
|
||||||
|
| `.nv-nav__icon` | nav icon slot | |
|
||||||
|
| `.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 | Hands over to the next panel inside the same box when `nv.selectTab` switches tabs. |
|
||||||
|
| `.nv-breadcrumbs` | breadcrumbs | |
|
||||||
|
|
||||||
|
## Disclosure
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-disclosure` | disclosure, accordion item | A `<details>` whose height animates open and closed. |
|
||||||
|
| `.nv-disclosure--plain` | bare disclosure | No surface of its own. |
|
||||||
|
| `.nv-disclosure__summary` | disclosure trigger | The `<summary>`. |
|
||||||
|
| `.nv-disclosure__chevron` | chevron, caret | Rotates rather than swapping glyphs. |
|
||||||
|
| `.nv-disclosure__body` | disclosure content | |
|
||||||
|
| `.nv-disclosure-group` | accordion | Stacked disclosures sharing hairlines. |
|
||||||
|
| `.nv-collapse` | collapsible region | Same relationship for markup that cannot be a `<details>`. Toggle `data-open`. |
|
||||||
|
|
||||||
|
## Scroller
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-scroller-frame` | carousel frame | Positions the controls; holds the track. |
|
||||||
|
| `.nv-scroller` | carousel, snap scroller, rail | Horizontal row with scroll snapping and fading edges. |
|
||||||
|
| `.nv-scroller--auto` | content-width items | Items size to content instead of the track. |
|
||||||
|
|
||||||
|
Set `--nv-scroller-item` for item width, `--nv-scroller-fade` for the edge fade.
|
||||||
|
|
||||||
|
## Badge
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-badge` | badge, chip, tag, status pill | Non-interactive statement of state. |
|
||||||
|
| `.nv-badge--accent` | accent badge | |
|
||||||
|
| `.nv-badge--success` `.nv-badge--warning` `.nv-badge--danger` | status badges | Tone follows the semantic role. |
|
||||||
|
| `.nv-badge--solid` | filled badge | For the one badge that must be seen first. |
|
||||||
|
| `.nv-badge--count` | count badge | Tabular, borderless, min two digits. |
|
||||||
|
| `.nv-badge--square` | square badge | |
|
||||||
|
| `.nv-badge__dot` | status dot | |
|
||||||
|
|
||||||
|
## Table
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-table-frame` | table wrapper | Scrolls sideways so the page does not have to. |
|
||||||
|
| `.nv-table` | data table | |
|
||||||
|
| `.nv-table--compact` | dense table | |
|
||||||
|
| `.nv-table--interactive` | clickable rows | |
|
||||||
|
| `.nv-table__number` | numeric cell | End-aligned, tabular figures. |
|
||||||
|
| `.nv-table__actions` | row actions cell | Shrinks to content. |
|
||||||
|
|
||||||
|
## Bed
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-bed` | showcase surface, patterned backdrop | Ground that is visibly not ordinary background: for regions the user should look *at*. Gives translucent materials something to be translucent against. |
|
||||||
|
| `.nv-bed--quiet` | subdued bed | No hatch, softer blooms. |
|
||||||
|
| `.nv-bed--vivid` | emphatic bed | |
|
||||||
|
| `.nv-bed--single` | single-hue bed | One color instead of three. |
|
||||||
|
| `.nv-bed--flush` | full-bleed bed | Square, no side borders. |
|
||||||
|
| `.nv-bed__label` | bed label, eyebrow | |
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-surface` | surface, panel | Plain container on the solid material. |
|
||||||
|
| `.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 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 |
|
||||||
|
| `.nv-elevation-inset-sm` `.nv-elevation-inset-md` `.nv-elevation-inset-lg` | inner shadow | Recessed surfaces. |
|
||||||
|
| `.nv-divider` | divider, separator, rule | |
|
||||||
|
| `.nv-divider--vertical` | column rule | Stretches to its flex row. |
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-display` | display, hero text | 38px |
|
||||||
|
| `.nv-heading` | heading | 24px |
|
||||||
|
| `.nv-title` | title, subheading | 17px |
|
||||||
|
| `.nv-body` | body copy | 15px |
|
||||||
|
| `.nv-label` | label, UI text | 13px |
|
||||||
|
| `.nv-caption` | caption, secondary text | 12px |
|
||||||
|
| `.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 | |
|
||||||
|
|
||||||
|
## Layout utilities
|
||||||
|
|
||||||
|
| Class | Known as | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.nv-container` | page container, wrapper | Max width + gutters. |
|
||||||
|
| `.nv-stack` | stack, vertical rhythm | Column with a gap. |
|
||||||
|
| `.nv-stack--tight` `.nv-stack--loose` | stack density | |
|
||||||
|
| `.nv-cluster` | cluster, wrap row | Wrapping row, vertically centred. |
|
||||||
|
| `.nv-cluster--tight` | dense cluster | |
|
||||||
|
| `.nv-cluster--between` | spread cluster | Pushes children to both ends. |
|
||||||
|
| `.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` | 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. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
Names are stable; values are not. Never state a color, shadow, radius, size or
|
||||||
|
duration directly — reference the token.
|
||||||
|
|
||||||
|
### Color roles
|
||||||
|
|
||||||
|
`--nv-color-` +
|
||||||
|
|
||||||
|
```
|
||||||
|
background background-subtle surface surface-elevated surface-inset
|
||||||
|
surface-hover surface-active scrim
|
||||||
|
|
||||||
|
text-primary text-secondary text-tertiary text-disabled text-inverted
|
||||||
|
border-subtle border border-strong
|
||||||
|
|
||||||
|
accent accent-hover accent-active accent-subtle accent-subtle-hover
|
||||||
|
accent-border accent-text on-accent
|
||||||
|
|
||||||
|
success success-hover success-subtle success-border success-text on-success
|
||||||
|
warning warning-hover warning-subtle warning-border warning-text on-warning
|
||||||
|
danger danger-hover danger-active danger-subtle danger-border
|
||||||
|
danger-text on-danger
|
||||||
|
|
||||||
|
shadow-ambient shadow-penumbra shadow-umbra shadow-inset
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
`on-*` is the text that goes on top of that color, and is derived — never set
|
||||||
|
it by hand.
|
||||||
|
|
||||||
|
### Color inputs
|
||||||
|
|
||||||
|
| Token | Default | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--nv-brand` | `#4f6cff` | The one color everything derives from. |
|
||||||
|
| `--nv-brand-tint` | `4%` | How much brand bleeds into the greys. |
|
||||||
|
| `--nv-brand-harmony` | `12%` | How far status colors lean towards the brand. |
|
||||||
|
| `--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}` and
|
||||||
|
`--nv-palette-window-{close|minimize|zoom}` are raw values. Components must
|
||||||
|
never reference them.
|
||||||
|
|
||||||
|
### Scales
|
||||||
|
|
||||||
|
| Family | Steps |
|
||||||
|
| --- | --- |
|
||||||
|
| `--nv-space-` | `0 px 0-5 1 1-5 2 2-5 3 4 5 6 7 8 10 12 14 16 20 24 32` (suffix = px ÷ 4) |
|
||||||
|
| `--nv-radius-` | `none xs sm md lg xl 2xl full`, plus `nested-md` `nested-lg` |
|
||||||
|
| `--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 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}` |
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
|
||||||
|
| Family | Steps |
|
||||||
|
| --- | --- |
|
||||||
|
| `--nv-duration-` | `instant fast quick normal slow slower travel expand theme` |
|
||||||
|
| `--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` |
|
||||||
|
|
||||||
|
`prefers-reduced-motion` collapses every duration and displacement at the token
|
||||||
|
level. Components never opt in individually.
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
`--nv-focus-ring` · `--nv-focus-ring-width` · `--nv-border-width` ·
|
||||||
|
`--nv-border-width-strong` · `--nv-target-min` · `--nv-layout-gutter` ·
|
||||||
|
`--nv-layout-max-width` · `--nv-selection-name` · `--nv-anchor` ·
|
||||||
|
`--nv-button-offset` · `--nv-bed-tint` · `--nv-scroller-item` ·
|
||||||
|
`--nv-scroller-fade` · `--nv-content-name`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JavaScript
|
||||||
|
|
||||||
|
`niveus.js` is optional and dependency-free; it exposes `window.nv`. Without it
|
||||||
|
everything still renders — state changes simply arrive instead of travelling.
|
||||||
|
|
||||||
|
| Function | Signature | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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 `<html>`. |
|
||||||
|
| `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) => 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. Runs once on load; call again after injecting markup. |
|
||||||
|
| `nv.prefersReducedMotion` | `() => boolean` | |
|
||||||
|
|
||||||
|
### Data attributes
|
||||||
|
|
||||||
|
| Attribute | On | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `data-theme` | `<html>` | `light` / `dark`. Absent follows the OS. |
|
||||||
|
| `data-nv-brand` | any element | Re-derives the whole color set for that subtree. |
|
||||||
|
| `data-nv-theme` | button | Sets the appearance when clicked. |
|
||||||
|
| `data-nv-dialog` | button | Opens the dialog with that id, from itself. |
|
||||||
|
| `data-nv-close` | button | Closes its dialog or popover. |
|
||||||
|
| `data-nv-tabs` | tablist | Wires `[role=tab]` children to their `aria-controls` panels, with arrow keys. |
|
||||||
|
| `data-nv-scroller` | `.nv-scroller-frame` | Wires its `[data-nv-scroll="prev"\|"next"]` controls. |
|
||||||
|
| `data-scrolled` | `.nv-nav` | Set by the application on scroll; gives the bar its shadow. |
|
||||||
|
| `data-nv-direction` | `<html>` | Set during a transition by `nv.transition`. |
|
||||||
|
| `data-nv-transition` | `<html>` | `"theme"` during an appearance change. |
|
||||||
|
| `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. 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. **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
|
||||||
|
`.nv-button--float` and `--nv-button-offset`, or the hover lift overwrites
|
||||||
|
its position.
|
||||||
|
6. **Icon-only buttons need `aria-label`.** The system gives them a 44px target
|
||||||
|
but cannot give them a name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Made by **Nexoniarz**.
|
||||||
4161
dist/niveus.css
vendored
Normal file
4161
dist/niveus.css
vendored
Normal file
File diff suppressed because it is too large
Load diff
234
examples/ai-chat/app.css
Normal file
234
examples/ai-chat/app.css
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Lumen Chat — app layout
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Layout only. The shell is a two-column grid pinned to the viewport, with
|
||||||
|
exactly one scrolling region; everything visual comes from Niveus.
|
||||||
|
|
||||||
|
The avatars below are the one place this app extends the system rather than
|
||||||
|
consuming it — and even then they are built from tokens, not from values
|
||||||
|
invented here. That is the correct way to extend: new shapes, old language.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
block-size: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden; /* the transcript scrolls, the page does not */
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 17rem 1fr;
|
||||||
|
block-size: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 52rem) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Sidebar ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.app__sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-3);
|
||||||
|
min-block-size: 0;
|
||||||
|
padding: var(--nv-space-4) var(--nv-space-3);
|
||||||
|
background-color: var(--nv-color-background-subtle);
|
||||||
|
border-inline-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__threads {
|
||||||
|
flex: 1;
|
||||||
|
min-block-size: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-inline: calc(var(--nv-space-1) * -1);
|
||||||
|
padding-inline: var(--nv-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread__title {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thread__meta {
|
||||||
|
display: block;
|
||||||
|
font: var(--nv-font-caption);
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
font-weight: var(--nv-font-weight-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* This sidebar is a plain aside rather than .nv-nav--vertical, so the list
|
||||||
|
keeps its default row direction until told otherwise. */
|
||||||
|
.app__threads .nv-nav__list {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--nv-space-0-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A nav item holding two lines needs to stop centring them. */
|
||||||
|
.app__threads .nv-nav__item {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Main ------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.app__main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
min-block-size: 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__transcript {
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: var(--nv-space-6) var(--nv-layout-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript {
|
||||||
|
max-inline-size: 48rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Messages ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--nv-space-3);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message--from-user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__body {
|
||||||
|
max-inline-size: 42rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message--from-user .message__body {
|
||||||
|
padding: var(--nv-space-3) var(--nv-space-4);
|
||||||
|
background-color: var(--nv-color-accent-subtle);
|
||||||
|
border: var(--nv-border-width) solid var(--nv-color-accent-border);
|
||||||
|
border-radius: var(--nv-radius-lg);
|
||||||
|
border-end-end-radius: var(--nv-radius-xs);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message__body > * + * {
|
||||||
|
margin-block-start: var(--nv-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
inline-size: var(--nv-space-8);
|
||||||
|
block-size: var(--nv-space-8);
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
font: var(--nv-font-caption);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
|
||||||
|
box-shadow: var(--nv-highlight-top), var(--nv-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar--agent {
|
||||||
|
background-color: var(--nv-color-accent);
|
||||||
|
color: var(--nv-color-on-accent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar--user {
|
||||||
|
background-color: var(--nv-color-surface-elevated);
|
||||||
|
color: var(--nv-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
display: block;
|
||||||
|
padding: var(--nv-space-4);
|
||||||
|
border-radius: var(--nv-radius-md);
|
||||||
|
background-color: var(--nv-color-surface-inset);
|
||||||
|
border: var(--nv-border-width) solid var(--nv-color-border-subtle);
|
||||||
|
box-shadow: var(--nv-elevation-inset-sm);
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The three dots while an answer is still arriving. */
|
||||||
|
.thinking {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: var(--nv-space-1);
|
||||||
|
align-items: center;
|
||||||
|
padding-block: var(--nv-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking span {
|
||||||
|
inline-size: var(--nv-space-1-5);
|
||||||
|
block-size: var(--nv-space-1-5);
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
background-color: var(--nv-color-text-tertiary);
|
||||||
|
animation: thinking 1.2s var(--nv-ease-standard) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thinking span:nth-child(2) { animation-delay: 0.15s; }
|
||||||
|
.thinking span:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
|
||||||
|
@keyframes thinking {
|
||||||
|
0%, 60%, 100% { opacity: 0.25; translate: 0 0; }
|
||||||
|
30% { opacity: 1; translate: 0 calc(var(--nv-travel-sm) * -0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.thinking span { animation: none; opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Composer ----------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
padding: var(--nv-space-4) var(--nv-layout-gutter) var(--nv-space-5);
|
||||||
|
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
|
||||||
|
background-color: var(--nv-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__inner {
|
||||||
|
max-inline-size: 48rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-2-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__row .nv-input {
|
||||||
|
min-block-size: var(--nv-control-height-lg);
|
||||||
|
max-block-size: 9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__hints {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
}
|
||||||
485
examples/ai-chat/index.html
Normal file
485
examples/ai-chat/index.html
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Lumen Chat — an AI chat app built with Niveus</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="../../dist/niveus.css">
|
||||||
|
<script src="../../niveus/niveus.js" defer></script>
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem("lumen-theme");
|
||||||
|
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
|
||||||
|
} catch (error) {}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root { --nv-brand: #6d5efc; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<aside class="app__sidebar" style="--nv-selection-name: nv-chat-threads">
|
||||||
|
<a class="nv-nav__brand" href="#" style="padding-inline: var(--nv-space-2)">
|
||||||
|
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 3v18M3 12h18M6 6l12 12M18 6L6 18" stroke="currentColor"
|
||||||
|
stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Lumen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--block" id="new-thread">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
New conversation
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<nav class="app__threads" aria-label="Conversations">
|
||||||
|
<span class="nv-nav__section">Today</span>
|
||||||
|
<ul class="nv-nav__list" id="threads" data-nv-travel>
|
||||||
|
<li>
|
||||||
|
<button class="nv-nav__item" data-thread="latency" aria-current="page">
|
||||||
|
<span class="thread__title">Checkout latency after #4412</span>
|
||||||
|
<span class="thread__meta">12 messages</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="nv-nav__item" data-thread="migration">
|
||||||
|
<span class="thread__title">Postgres 15 migration plan</span>
|
||||||
|
<span class="thread__meta">4 messages</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<span class="nv-nav__section">Earlier</span>
|
||||||
|
<ul class="nv-nav__list">
|
||||||
|
<li>
|
||||||
|
<button class="nv-nav__item" data-thread="audit">
|
||||||
|
<span class="thread__title">Dependency audit — Q3</span>
|
||||||
|
<span class="thread__meta">31 messages</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="nv-nav__item" data-thread="onboarding">
|
||||||
|
<span class="thread__title">Why does billing own the cart?</span>
|
||||||
|
<span class="thread__meta">8 messages</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<hr class="nv-divider" style="margin-block: var(--nv-space-2)">
|
||||||
|
|
||||||
|
<button class="nv-nav__item nv-menu-trigger" popovertarget="account-menu"
|
||||||
|
style="--nv-anchor: --account">
|
||||||
|
<span class="avatar avatar--user" aria-hidden="true">RK</span>
|
||||||
|
<span class="thread__title">Robin Kade</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="nv-menu" popover id="account-menu" style="--nv-anchor: --account">
|
||||||
|
<span class="nv-menu__label">Robin Kade</span>
|
||||||
|
<button class="nv-menu__item" data-nv-dialog="settings">
|
||||||
|
Settings<span class="nv-menu__shortcut">⌘,</span>
|
||||||
|
</button>
|
||||||
|
<button class="nv-menu__item">Keyboard shortcuts</button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<main class="app__main">
|
||||||
|
|
||||||
|
<nav class="nv-nav nv-nav--static" aria-label="Conversation">
|
||||||
|
<h1 class="nv-title nv-truncate" id="thread-heading">Checkout latency after #4412</h1>
|
||||||
|
|
||||||
|
<span class="nv-nav__spacer"></span>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="model-menu"
|
||||||
|
style="--nv-anchor: --model">
|
||||||
|
<span id="model-label">Lumen 2</span>
|
||||||
|
<span class="nv-disclosure__chevron" aria-hidden="true"
|
||||||
|
style="margin-inline-start: var(--nv-space-1)"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="nv-menu" popover id="model-menu" style="--nv-anchor: --model">
|
||||||
|
<span class="nv-menu__label">Model</span>
|
||||||
|
<button class="nv-menu__item" aria-checked="true" data-model="Lumen 2">
|
||||||
|
Lumen 2<span class="nv-menu__shortcut">Balanced</span>
|
||||||
|
</button>
|
||||||
|
<button class="nv-menu__item" data-model="Lumen 2 Pro">
|
||||||
|
Lumen 2 Pro<span class="nv-menu__shortcut">Deeper</span>
|
||||||
|
</button>
|
||||||
|
<button class="nv-menu__item" data-model="Lumen Mini">
|
||||||
|
Lumen Mini<span class="nv-menu__shortcut">Fastest</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--sm nv-button--ghost">Share</button>
|
||||||
|
<button class="nv-button nv-button--sm nv-button--ghost nv-button--icon"
|
||||||
|
data-nv-dialog="settings" aria-label="Settings">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M12 3v2.2M12 18.8V21M21 12h-2.2M5.2 12H3M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6M18.4 18.4l-1.6-1.6M7.2 7.2L5.6 5.6"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="app__transcript" id="transcript-scroll">
|
||||||
|
<div class="transcript nv-continuous" id="transcript">
|
||||||
|
<!-- rendered from the thread data below -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<form class="composer__inner" id="composer">
|
||||||
|
<div class="composer__row">
|
||||||
|
<button class="nv-button nv-button--lg nv-button--icon" type="button" aria-label="Attach">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M18 11l-6.5 6.5a4 4 0 01-5.6-5.6l7-7a2.8 2.8 0 013.9 4L9.6 16a1.6 1.6 0 01-2.3-2.3l6.2-6.2"
|
||||||
|
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label class="nv-visually-hidden" for="prompt">Message Lumen</label>
|
||||||
|
<textarea class="nv-input" id="prompt" rows="1"
|
||||||
|
placeholder="Ask about this codebase…"></textarea>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--lg nv-button--primary nv-button--icon"
|
||||||
|
type="submit" aria-label="Send">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M5 12h13M13 6l6 6-6 6" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer__hints">
|
||||||
|
<label class="nv-choice nv-caption">
|
||||||
|
<input class="nv-switch" type="checkbox" checked>
|
||||||
|
Search the repository
|
||||||
|
</label>
|
||||||
|
<label class="nv-choice nv-caption">
|
||||||
|
<input class="nv-switch" type="checkbox">
|
||||||
|
Propose changes
|
||||||
|
</label>
|
||||||
|
<span class="nv-nav__spacer"></span>
|
||||||
|
<span class="nv-caption">Enter to send · Shift + Enter for a new line</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================================================================== -->
|
||||||
|
<dialog class="nv-dialog nv-dialog--wide" id="settings" aria-labelledby="settings-title">
|
||||||
|
<div class="nv-dialog__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-dialog__title" id="settings-title">Settings</h2>
|
||||||
|
<p class="nv-dialog__description">Applies to this workspace.</p>
|
||||||
|
</div>
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
|
||||||
|
aria-label="Close" data-nv-close>
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-dialog__body">
|
||||||
|
<div class="nv-nav-tabs" role="tablist" data-nv-tabs aria-label="Settings sections"
|
||||||
|
style="--nv-selection-name: nv-chat-settings-tabs">
|
||||||
|
<button class="nv-nav__item" role="tab" id="tab-general"
|
||||||
|
aria-controls="panel-general" aria-selected="true">General</button>
|
||||||
|
<button class="nv-nav__item" role="tab" id="tab-model"
|
||||||
|
aria-controls="panel-model" aria-selected="false" tabindex="-1">Model</button>
|
||||||
|
<button class="nv-nav__item" role="tab" id="tab-privacy"
|
||||||
|
aria-controls="panel-privacy" aria-selected="false" tabindex="-1">Privacy</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-tab-panel" id="panel-general" role="tabpanel" aria-labelledby="tab-general"
|
||||||
|
style="padding-block-start: var(--nv-space-5)">
|
||||||
|
<div class="nv-stack">
|
||||||
|
<div class="nv-field">
|
||||||
|
<label class="nv-field__label" for="ws-name">Workspace name</label>
|
||||||
|
<input class="nv-input" id="ws-name" value="Northwire Platform">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-field">
|
||||||
|
<span class="nv-field__label">Appearance</span>
|
||||||
|
<div class="nv-button-group" role="group" aria-label="Appearance">
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">System</button>
|
||||||
|
</div>
|
||||||
|
<span class="nv-field__hint">The whole interface cross-fades between the two.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="nv-choice">
|
||||||
|
<input class="nv-switch" type="checkbox" checked>
|
||||||
|
Send on Enter
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-tab-panel" id="panel-model" role="tabpanel" aria-labelledby="tab-model"
|
||||||
|
style="padding-block-start: var(--nv-space-5)" hidden>
|
||||||
|
<div class="nv-stack">
|
||||||
|
<div class="nv-field">
|
||||||
|
<label class="nv-field__label" for="default-model">Default model</label>
|
||||||
|
<div class="nv-select">
|
||||||
|
<select class="nv-input" id="default-model">
|
||||||
|
<option selected>Lumen 2 — balanced</option>
|
||||||
|
<option>Lumen 2 Pro — deeper reasoning</option>
|
||||||
|
<option>Lumen Mini — fastest</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span class="nv-field__hint">Individual conversations can override this.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-field">
|
||||||
|
<label class="nv-field__label" for="context">Context window</label>
|
||||||
|
<div class="nv-input-group">
|
||||||
|
<input class="nv-input" id="context" value="200000">
|
||||||
|
<span class="nv-input-group__affix">tokens</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="nv-choice">
|
||||||
|
<input class="nv-checkbox" type="checkbox" checked>
|
||||||
|
Cite sources in every answer
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-tab-panel" id="panel-privacy" role="tabpanel" aria-labelledby="tab-privacy"
|
||||||
|
style="padding-block-start: var(--nv-space-5)" hidden>
|
||||||
|
<div class="nv-stack">
|
||||||
|
<div class="nv-bed nv-bed--quiet nv-bed--single">
|
||||||
|
<p class="nv-body">
|
||||||
|
Repository contents are used to answer this workspace's questions
|
||||||
|
and nothing else.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="nv-choice">
|
||||||
|
<input class="nv-switch" type="checkbox">
|
||||||
|
Keep transcripts after 30 days
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="nv-field">
|
||||||
|
<span class="nv-field__label">Danger zone</span>
|
||||||
|
<div class="nv-cluster">
|
||||||
|
<button class="nv-button nv-button--danger nv-button--sm">Delete all conversations</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-dialog__footer nv-dialog__footer--divided">
|
||||||
|
<button class="nv-button" data-nv-close>Cancel</button>
|
||||||
|
<button class="nv-button nv-button--primary" data-nv-close>Save changes</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.documentElement.addEventListener("nv:theme", (event) => {
|
||||||
|
try { localStorage.setItem("lumen-theme", event.detail.theme); } catch {}
|
||||||
|
});
|
||||||
|
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
Canned content. A real client would fetch this; what matters here is
|
||||||
|
that switching threads is a content change inside one surface, so it
|
||||||
|
goes through nv.transition() and cross-fades in place.
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
const threads = {
|
||||||
|
latency: {
|
||||||
|
title: "Checkout latency after #4412",
|
||||||
|
messages: [
|
||||||
|
{ from: "user", html: "Why did checkout latency double after Tuesday's deploy?" },
|
||||||
|
{ from: "agent", html:
|
||||||
|
`<p>The <code class="nv-code">pricing</code> service began resolving tax rules
|
||||||
|
per line item instead of per cart in <code class="nv-code">#4412</code>.
|
||||||
|
Carts with more than two items take the slow path.</p>
|
||||||
|
<div class="nv-cluster nv-cluster--tight">
|
||||||
|
<span class="nv-badge nv-badge--warning">p95 +118%</span>
|
||||||
|
<span class="nv-badge">3 files</span>
|
||||||
|
<span class="nv-badge">pricing/tax.ts</span>
|
||||||
|
</div>` },
|
||||||
|
{ from: "user", html: "Show me the hot path." },
|
||||||
|
{ from: "agent", html:
|
||||||
|
`<p>Here is the loop that moved:</p>
|
||||||
|
<code class="code-block nv-code">for (const line of cart.lines) {
|
||||||
|
// was: resolved once per cart
|
||||||
|
const rules = await taxRules.resolve(line.destination);
|
||||||
|
line.tax = rules.apply(line);
|
||||||
|
}</code>
|
||||||
|
<p><code class="nv-code">taxRules.resolve</code> is a network call. Hoisting
|
||||||
|
it above the loop restores the previous shape.</p>` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
migration: {
|
||||||
|
title: "Postgres 15 migration plan",
|
||||||
|
messages: [
|
||||||
|
{ from: "user", html: "What breaks if we go to Postgres 15 next sprint?" },
|
||||||
|
{ from: "agent", html:
|
||||||
|
`<p>Two things in this codebase, and one in the deploy pipeline.</p>
|
||||||
|
<div class="nv-cluster nv-cluster--tight">
|
||||||
|
<span class="nv-badge nv-badge--danger">1 blocking</span>
|
||||||
|
<span class="nv-badge nv-badge--warning">2 to review</span>
|
||||||
|
</div>
|
||||||
|
<p>The blocking one is the <code class="nv-code">reporting</code> service's use
|
||||||
|
of implicit integer casts in <code class="nv-code">?column?</code> aliases,
|
||||||
|
removed in 15.</p>` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
title: "Dependency audit — Q3",
|
||||||
|
messages: [
|
||||||
|
{ from: "user", html: "Which advisories actually reach our code?" },
|
||||||
|
{ from: "agent", html:
|
||||||
|
`<p>Four of the thirty-one. The rest are in transitive packages whose
|
||||||
|
affected entry points you never call.</p>
|
||||||
|
<div class="nv-cluster nv-cluster--tight">
|
||||||
|
<span class="nv-badge nv-badge--danger">2 high</span>
|
||||||
|
<span class="nv-badge nv-badge--warning">2 moderate</span>
|
||||||
|
<span class="nv-badge nv-badge--success">27 unreachable</span>
|
||||||
|
</div>` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
onboarding: {
|
||||||
|
title: "Why does billing own the cart?",
|
||||||
|
messages: [
|
||||||
|
{ from: "user", html: "Why does billing own the cart model?" },
|
||||||
|
{ from: "agent", html:
|
||||||
|
`<p>It did not, until <code class="nv-code">#2180</code> moved it there so
|
||||||
|
promotions could be priced in one place. The commit message and the
|
||||||
|
review thread both say this was meant to be temporary.</p>` },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const transcript = document.getElementById("transcript");
|
||||||
|
const scroller = document.getElementById("transcript-scroll");
|
||||||
|
const heading = document.getElementById("thread-heading");
|
||||||
|
|
||||||
|
function messageHTML(message) {
|
||||||
|
const isUser = message.from === "user";
|
||||||
|
return `
|
||||||
|
<div class="message message--from-${message.from}">
|
||||||
|
<span class="avatar avatar--${isUser ? "user" : "agent"}" aria-hidden="true">${isUser ? "RK" : "L"}</span>
|
||||||
|
<div class="message__body">${message.html}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(key) {
|
||||||
|
const thread = threads[key];
|
||||||
|
heading.textContent = thread.title;
|
||||||
|
transcript.innerHTML = thread.messages.map(messageHTML).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
render("latency");
|
||||||
|
|
||||||
|
/* Switching threads: the selection travels in the sidebar and the
|
||||||
|
transcript cross-fades, because both are one state change. */
|
||||||
|
const list = document.getElementById("threads");
|
||||||
|
document.querySelectorAll("[data-thread]").forEach((item) => {
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
if (item.hasAttribute("aria-current")) return;
|
||||||
|
nv.transition(() => {
|
||||||
|
document.querySelectorAll("[data-thread][aria-current]")
|
||||||
|
.forEach((el) => el.removeAttribute("aria-current"));
|
||||||
|
item.setAttribute("aria-current", "page");
|
||||||
|
render(item.dataset.thread);
|
||||||
|
}, { scope: list });
|
||||||
|
scroller.scrollTop = 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Choosing a model updates the menu's own checked state. */
|
||||||
|
document.querySelectorAll("[data-model]").forEach((option) => {
|
||||||
|
option.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll("[data-model]").forEach((other) => {
|
||||||
|
other.setAttribute("aria-checked", String(other === option));
|
||||||
|
});
|
||||||
|
document.getElementById("model-label").textContent = option.dataset.model;
|
||||||
|
document.getElementById("model-menu").hidePopover();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Sending: append, show that something is happening, then answer. The
|
||||||
|
point is the states, not the reply. */
|
||||||
|
const form = document.getElementById("composer");
|
||||||
|
const prompt = document.getElementById("prompt");
|
||||||
|
|
||||||
|
function append(html) {
|
||||||
|
transcript.insertAdjacentHTML("beforeend", html);
|
||||||
|
scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = prompt.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
append(messageHTML({ from: "user", html: text.replace(/[<>&]/g, (c) =>
|
||||||
|
({ "<": "<", ">": ">", "&": "&" }[c])) }));
|
||||||
|
prompt.value = "";
|
||||||
|
|
||||||
|
append(`<div class="message message--from-agent" id="pending">
|
||||||
|
<span class="avatar avatar--agent" aria-hidden="true">L</span>
|
||||||
|
<div class="message__body">
|
||||||
|
<span class="thinking" role="status" aria-label="Thinking">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById("pending")?.remove();
|
||||||
|
append(messageHTML({ from: "agent", html:
|
||||||
|
`<p>This example has no model behind it — it exists to show the states
|
||||||
|
a chat interface moves through: the message landing, something
|
||||||
|
visibly happening, and the answer arriving in the same column.</p>` }));
|
||||||
|
}, 1400);
|
||||||
|
});
|
||||||
|
|
||||||
|
prompt.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("new-thread").addEventListener("click", () => {
|
||||||
|
nv.transition(() => {
|
||||||
|
document.querySelectorAll("[data-thread][aria-current]")
|
||||||
|
.forEach((el) => el.removeAttribute("aria-current"));
|
||||||
|
heading.textContent = "New conversation";
|
||||||
|
transcript.innerHTML = `
|
||||||
|
<div class="nv-bed nv-bed--quiet" style="text-align: center">
|
||||||
|
<p class="nv-title">Ask about this codebase</p>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-2)">
|
||||||
|
Lumen has indexed 1,284 files across 3 repositories.
|
||||||
|
</p>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
prompt.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
186
examples/ai-landing/app.css
Normal file
186
examples/ai-landing/app.css
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Lumen — page layout
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Everything in this file is *layout*: where things sit, how wide they get,
|
||||||
|
when they stack. Not one color, shadow, radius, type size or duration is
|
||||||
|
stated here — those all come from Niveus, which is the whole point of the
|
||||||
|
split. If you find yourself writing a hex value in a file like this, the
|
||||||
|
system is missing a token.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-inline-size: var(--nv-layout-max-width);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: var(--nv-layout-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding-block: var(--nv-space-20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section__head {
|
||||||
|
max-inline-size: var(--nv-measure);
|
||||||
|
margin-block-end: var(--nv-space-8);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section__head--center {
|
||||||
|
margin-inline: auto;
|
||||||
|
text-align: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Hero ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--nv-space-12);
|
||||||
|
padding-block: var(--nv-space-16) var(--nv-space-8);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 62rem) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--nv-space-16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-5);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__title {
|
||||||
|
font-size: clamp(var(--nv-font-size-4xl), 6vw, var(--nv-font-size-5xl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Preview ---------------------------------------------------------------
|
||||||
|
A mock of the product, sitting on a bed so the frosted panel above it has
|
||||||
|
something to be frosted against. */
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-3);
|
||||||
|
max-inline-size: 30rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
padding: var(--nv-space-3) var(--nv-space-4);
|
||||||
|
border-radius: var(--nv-radius-lg);
|
||||||
|
max-inline-size: 85%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble--from-user {
|
||||||
|
align-self: flex-end;
|
||||||
|
border-end-end-radius: var(--nv-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble--from-agent {
|
||||||
|
align-self: flex-start;
|
||||||
|
border-end-start-radius: var(--nv-radius-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Logos ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.logos {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nv-space-8);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-block: var(--nv-space-8);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Features ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.features {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
|
||||||
|
gap: var(--nv-space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature__icon {
|
||||||
|
inline-size: var(--nv-space-10);
|
||||||
|
block-size: var(--nv-space-10);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
margin-block-end: var(--nv-space-4);
|
||||||
|
border-radius: var(--nv-radius-md);
|
||||||
|
background-color: var(--nv-color-accent-subtle);
|
||||||
|
color: var(--nv-color-accent-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature__icon svg {
|
||||||
|
inline-size: var(--nv-space-5);
|
||||||
|
block-size: var(--nv-space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Pricing ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
.pricing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
|
||||||
|
gap: var(--nv-space-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
margin-block: var(--nv-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price__amount {
|
||||||
|
font-size: var(--nv-font-size-4xl);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
letter-spacing: var(--nv-letter-spacing-tighter);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-features {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-2-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-features li {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--nv-space-2-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-features svg {
|
||||||
|
inline-size: 1em;
|
||||||
|
block-size: 1em;
|
||||||
|
margin-block-start: 0.25em;
|
||||||
|
flex: none;
|
||||||
|
color: var(--nv-color-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Closing ------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.closing {
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nv-space-5);
|
||||||
|
padding-block: var(--nv-space-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
padding-block: var(--nv-space-8) var(--nv-space-12);
|
||||||
|
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
|
||||||
|
}
|
||||||
468
examples/ai-landing/index.html
Normal file
468
examples/ai-landing/index.html
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Lumen — an AI product landing page built with Niveus</title>
|
||||||
|
|
||||||
|
<!-- The system, and its optional continuity helpers. -->
|
||||||
|
<link rel="stylesheet" href="../../dist/niveus.css">
|
||||||
|
<script src="../../niveus/niveus.js" defer></script>
|
||||||
|
|
||||||
|
<!-- This page's own layout. No colors, no shadows, no type sizes. -->
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/* Applied before first paint so the page never renders in the wrong
|
||||||
|
appearance and then corrects itself. */
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem("lumen-theme");
|
||||||
|
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
|
||||||
|
} catch (error) { /* storage unavailable — follow the system */ }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- The entire identity of this site is one declaration. -->
|
||||||
|
<style>
|
||||||
|
:root { --nv-brand: #6d5efc; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="nv-nav" id="topnav" aria-label="Main" style="--nv-selection-name: nv-lumen-nav">
|
||||||
|
<a class="nv-nav__brand" href="#top">
|
||||||
|
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 3v18M3 12h18M6 6l12 12M18 6L6 18" stroke="currentColor"
|
||||||
|
stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Lumen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="nv-nav__list" data-nv-travel>
|
||||||
|
<li><a class="nv-nav__item" href="#features" aria-current="page">Features</a></li>
|
||||||
|
<li><a class="nv-nav__item" href="#build">Build</a></li>
|
||||||
|
<li><a class="nv-nav__item" href="#pricing">Pricing</a></li>
|
||||||
|
<li><a class="nv-nav__item" href="#faq">FAQ</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<span class="nv-nav__spacer"></span>
|
||||||
|
|
||||||
|
<div class="nv-button-group" role="group" aria-label="Appearance">
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="nv-button nv-button--sm nv-button--primary" href="#pricing">Get started</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<header class="shell hero">
|
||||||
|
<div class="hero__copy">
|
||||||
|
<span class="nv-badge nv-badge--accent">
|
||||||
|
<span class="nv-badge__dot"></span>
|
||||||
|
Lumen 2 is available
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 class="nv-display hero__title">The model that reads your whole codebase.</h1>
|
||||||
|
|
||||||
|
<p class="nv-prose">
|
||||||
|
Lumen answers questions about the systems you actually run — not a
|
||||||
|
snapshot of them. Point it at a repository and it keeps up as the code
|
||||||
|
changes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="nv-cluster">
|
||||||
|
<a class="nv-button nv-button--primary nv-button--lg" href="#pricing">Start free</a>
|
||||||
|
<button class="nv-button nv-button--lg" data-nv-dialog="demo">Watch a demo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="nv-caption">No card required · 14-day trial · SOC 2 Type II</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-bed nv-bed--single">
|
||||||
|
<span class="nv-bed__label">Live preview</span>
|
||||||
|
|
||||||
|
<div class="preview">
|
||||||
|
<div class="bubble bubble--from-user nv-material-elevated">
|
||||||
|
Why did checkout latency double after Tuesday's deploy?
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-card nv-card--frosted bubble bubble--from-agent"
|
||||||
|
style="max-inline-size: 100%; padding: var(--nv-space-4)">
|
||||||
|
<p class="nv-body">
|
||||||
|
The <code class="nv-code">pricing</code> service started resolving
|
||||||
|
tax rules per line item instead of per cart in
|
||||||
|
<code class="nv-code">#4412</code>. Two of three carts hit the slow
|
||||||
|
path.
|
||||||
|
</p>
|
||||||
|
<div class="nv-cluster nv-cluster--tight" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="nv-badge nv-badge--warning">p95 +118%</span>
|
||||||
|
<span class="nv-badge">3 files</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bubble bubble--from-user nv-material-elevated">
|
||||||
|
Open a PR that reverts just that change.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<section class="shell">
|
||||||
|
<div class="logos">
|
||||||
|
<span class="nv-overline">Northwire</span>
|
||||||
|
<span class="nv-overline">Harbour</span>
|
||||||
|
<span class="nv-overline">Greenhouse Labs</span>
|
||||||
|
<span class="nv-overline">Quarry</span>
|
||||||
|
<span class="nv-overline">Signal Systems</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<section class="shell section" id="features">
|
||||||
|
<div class="section__head">
|
||||||
|
<span class="nv-overline">Why teams switch</span>
|
||||||
|
<h2 class="nv-heading">Built for the code you already have.</h2>
|
||||||
|
<p class="nv-prose">
|
||||||
|
Three things matter when a model works on a real system: that it sees
|
||||||
|
the current state, that it explains itself, and that it cannot quietly
|
||||||
|
do something irreversible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="features">
|
||||||
|
<article class="nv-card">
|
||||||
|
<div class="nv-card__body">
|
||||||
|
<span class="feature__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<h3 class="nv-title">Always current</h3>
|
||||||
|
<p style="margin-block-start: var(--nv-space-2)">
|
||||||
|
Indexes on every push, so answers describe the branch you are on
|
||||||
|
rather than the one it was trained on.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card">
|
||||||
|
<div class="nv-card__body">
|
||||||
|
<span class="feature__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M16 16l4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<h3 class="nv-title">Shows its work</h3>
|
||||||
|
<p style="margin-block-start: var(--nv-space-2)">
|
||||||
|
Every claim links to the lines it came from. If it cannot cite a
|
||||||
|
file, it says so instead of guessing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card">
|
||||||
|
<div class="nv-card__body">
|
||||||
|
<span class="feature__icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<h3 class="nv-title">Asks before it acts</h3>
|
||||||
|
<p style="margin-block-start: var(--nv-space-2)">
|
||||||
|
Writes, merges and deploys are proposals until a human approves
|
||||||
|
them. Read access is the default.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<section class="shell section" id="build">
|
||||||
|
<div class="section__head">
|
||||||
|
<span class="nv-overline">What teams build</span>
|
||||||
|
<h2 class="nv-heading">One model, several jobs.</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-scroller-frame" data-nv-scroller>
|
||||||
|
<div class="nv-scroller">
|
||||||
|
<article class="nv-card nv-card--interactive">
|
||||||
|
<div class="nv-card__header"><div>
|
||||||
|
<h3 class="nv-card__title">Incident triage</h3>
|
||||||
|
<p class="nv-card__subtitle">Platform teams</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
Correlates a regression with the change that caused it, and drafts
|
||||||
|
the revert.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--interactive">
|
||||||
|
<div class="nv-card__header"><div>
|
||||||
|
<h3 class="nv-card__title">Review assistant</h3>
|
||||||
|
<p class="nv-card__subtitle">Every repository</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
Reads the diff in the context of the whole system, not just the
|
||||||
|
files that changed.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--interactive">
|
||||||
|
<div class="nv-card__header"><div>
|
||||||
|
<h3 class="nv-card__title">Migration planning</h3>
|
||||||
|
<p class="nv-card__subtitle">Staff engineers</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
Finds every call site, groups them by risk, and sequences the work.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--interactive">
|
||||||
|
<div class="nv-card__header"><div>
|
||||||
|
<h3 class="nv-card__title">Onboarding</h3>
|
||||||
|
<p class="nv-card__subtitle">New joiners</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
Answers "why is this here" with the commit, the review and the
|
||||||
|
issue behind it.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--interactive">
|
||||||
|
<div class="nv-card__header"><div>
|
||||||
|
<h3 class="nv-card__title">Dependency audits</h3>
|
||||||
|
<p class="nv-card__subtitle">Security</p>
|
||||||
|
</div></div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
Separates the advisories that reach your code from the ones that
|
||||||
|
do not.
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="prev" aria-label="Previous">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M14 6l-6 6 6 6" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="next" aria-label="Next">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M10 6l6 6-6 6" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<section class="shell section" id="pricing">
|
||||||
|
<div class="section__head section__head--center">
|
||||||
|
<span class="nv-overline">Pricing</span>
|
||||||
|
<h2 class="nv-heading">Per seat, not per question.</h2>
|
||||||
|
<p class="nv-prose">
|
||||||
|
Usage limits make people ration the tool that is supposed to save them
|
||||||
|
time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pricing">
|
||||||
|
<article class="nv-card">
|
||||||
|
<div class="nv-card__body">
|
||||||
|
<h3 class="nv-title">Starter</h3>
|
||||||
|
<p class="nv-caption">For a single repository.</p>
|
||||||
|
<p class="price">
|
||||||
|
<span class="price__amount">$0</span>
|
||||||
|
<span class="nv-caption">forever</span>
|
||||||
|
</p>
|
||||||
|
<ul class="plan-features nv-caption">
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> One repository</li>
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Community support</li>
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Read-only access</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer">
|
||||||
|
<button class="nv-button nv-button--block">Start free</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--floating">
|
||||||
|
<div class="nv-card__body">
|
||||||
|
<div class="nv-cluster nv-cluster--between">
|
||||||
|
<h3 class="nv-title">Team</h3>
|
||||||
|
<span class="nv-badge nv-badge--accent nv-badge--solid">Most chosen</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption">For a codebase several people touch.</p>
|
||||||
|
<p class="price">
|
||||||
|
<span class="price__amount">$24</span>
|
||||||
|
<span class="nv-caption">per seat / month</span>
|
||||||
|
</p>
|
||||||
|
<ul class="plan-features nv-caption">
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Unlimited repositories</li>
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Pull request proposals</li>
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Shared context across the team</li>
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> SSO and audit log</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--block">Start 14-day trial</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card">
|
||||||
|
<div class="nv-card__body">
|
||||||
|
<h3 class="nv-title">Enterprise</h3>
|
||||||
|
<p class="nv-caption">For code that cannot leave your network.</p>
|
||||||
|
<p class="price">
|
||||||
|
<span class="price__amount">Talk</span>
|
||||||
|
<span class="nv-caption">to us</span>
|
||||||
|
</p>
|
||||||
|
<ul class="plan-features nv-caption">
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Self-hosted or VPC</li>
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Custom retention</li>
|
||||||
|
<li><svg viewBox="0 0 24 24" fill="none"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Named support engineer</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer">
|
||||||
|
<button class="nv-button nv-button--block">Contact sales</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<section class="shell section" id="faq">
|
||||||
|
<div class="section__head">
|
||||||
|
<span class="nv-overline">Questions</span>
|
||||||
|
<h2 class="nv-heading">Before you ask.</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-disclosure-group" style="max-inline-size: var(--nv-measure-wide)">
|
||||||
|
<details class="nv-disclosure" open>
|
||||||
|
<summary class="nv-disclosure__summary">
|
||||||
|
Do you train on our code?
|
||||||
|
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="nv-disclosure__body">
|
||||||
|
No. Repository contents are used to answer your team's questions and
|
||||||
|
nothing else. Enterprise plans can run entirely inside your own
|
||||||
|
network, where the question never leaves it either.
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="nv-disclosure">
|
||||||
|
<summary class="nv-disclosure__summary">
|
||||||
|
What happens when it is wrong?
|
||||||
|
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="nv-disclosure__body">
|
||||||
|
Every answer cites the files it drew on, so a wrong answer is
|
||||||
|
checkable rather than merely confident. Anything that writes is a
|
||||||
|
proposal until a person approves it.
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="nv-disclosure">
|
||||||
|
<summary class="nv-disclosure__summary">
|
||||||
|
How large a codebase can it handle?
|
||||||
|
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="nv-disclosure__body">
|
||||||
|
The largest repository we index in production is around eleven
|
||||||
|
million lines. Indexing is incremental, so size affects the first
|
||||||
|
run far more than the ones after it.
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ================================================================== -->
|
||||||
|
<section class="shell">
|
||||||
|
<div class="nv-bed nv-bed--vivid closing">
|
||||||
|
<h2 class="nv-heading">Point it at a repository.</h2>
|
||||||
|
<p class="nv-prose">
|
||||||
|
The first answer usually arrives before the tour would have finished.
|
||||||
|
</p>
|
||||||
|
<div class="nv-cluster">
|
||||||
|
<a class="nv-button nv-button--primary nv-button--lg" href="#pricing">Start free</a>
|
||||||
|
<button class="nv-button nv-button--lg nv-button--frosted" data-nv-dialog="demo">
|
||||||
|
Watch a demo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="shell site-footer">
|
||||||
|
<div class="nv-cluster nv-cluster--between">
|
||||||
|
<span class="nv-caption">Lumen — an example interface built with Niveus.</span>
|
||||||
|
<span class="nv-caption">Made by Nexoniarz</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog class="nv-dialog" id="demo" aria-labelledby="demo-title">
|
||||||
|
<div class="nv-dialog__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-dialog__title" id="demo-title">A two-minute tour</h2>
|
||||||
|
<p class="nv-dialog__description">
|
||||||
|
This dialog expanded from the button you pressed, and the page behind
|
||||||
|
it is blurred rather than hidden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
|
||||||
|
aria-label="Close" data-nv-close>
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="nv-dialog__body">
|
||||||
|
<div class="nv-bed nv-bed--quiet" style="display: grid; place-items: center; min-block-size: 12rem">
|
||||||
|
<span class="nv-caption">Where the video would be.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-dialog__footer">
|
||||||
|
<button class="nv-button" data-nv-close>Close</button>
|
||||||
|
<a class="nv-button nv-button--primary" href="#pricing" data-nv-close>Start free</a>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
/* niveus.js wires the appearance buttons; the page decides what
|
||||||
|
"remember it" means. */
|
||||||
|
document.documentElement.addEventListener("nv:theme", (event) => {
|
||||||
|
try { localStorage.setItem("lumen-theme", event.detail.theme); } catch {}
|
||||||
|
});
|
||||||
|
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
|
||||||
|
|
||||||
|
/* The nav's selection travels to whatever section you jump to. */
|
||||||
|
const nav = document.querySelector("[data-nv-travel]");
|
||||||
|
nav.addEventListener("click", (event) => {
|
||||||
|
const item = event.target.closest(".nv-nav__item");
|
||||||
|
if (!item || item.hasAttribute("aria-current")) return;
|
||||||
|
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. */
|
||||||
|
const topnav = document.getElementById("topnav");
|
||||||
|
const onScroll = () => topnav.toggleAttribute("data-scrolled", scrollY > 8);
|
||||||
|
addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
153
examples/dashboard/app.css
Normal file
153
examples/dashboard/app.css
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Northwire Ops — dashboard layout
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Layout only. A data-dense view is where a design system is tested hardest:
|
||||||
|
there are more things on screen than anywhere else, so any inconsistency in
|
||||||
|
spacing or weight shows up immediately. Nothing here restates the language —
|
||||||
|
even the chart draws itself in semantic colors.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
html, body { block-size: 100%; }
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 15rem 1fr;
|
||||||
|
min-block-size: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 62rem) {
|
||||||
|
.app { grid-template-columns: 1fr; }
|
||||||
|
.app__sidebar { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__main {
|
||||||
|
min-inline-size: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app__content {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--nv-space-6) var(--nv-layout-gutter) var(--nv-space-12);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-6);
|
||||||
|
max-inline-size: 80rem;
|
||||||
|
inline-size: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Stat tiles -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat__value {
|
||||||
|
display: block;
|
||||||
|
margin-block: var(--nv-space-2) var(--nv-space-1);
|
||||||
|
font-size: var(--nv-font-size-3xl);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
letter-spacing: var(--nv-letter-spacing-tighter);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Chart --------------------------------------------------------------------
|
||||||
|
Drawn in semantic colors, so it follows the brand and both appearances with
|
||||||
|
no second palette to maintain. */
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
inline-size: 100%;
|
||||||
|
block-size: 14rem;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart__grid {
|
||||||
|
stroke: var(--nv-color-border-subtle);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart__line {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--nv-color-accent);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart__line--compare {
|
||||||
|
stroke: var(--nv-color-border-strong);
|
||||||
|
stroke-dasharray: 4 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart__area {
|
||||||
|
fill: url(#chart-fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart__labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-block-start: var(--nv-space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Layout of the lower half --------------------------------------------------- */
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--nv-space-6);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 70rem) {
|
||||||
|
.split { grid-template-columns: 1.6fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity li {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--nv-space-3);
|
||||||
|
padding-block: var(--nv-space-3);
|
||||||
|
border-block-end: var(--nv-border-width) solid var(--nv-color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity li:last-child { border-block-end: 0; }
|
||||||
|
|
||||||
|
.activity__mark {
|
||||||
|
inline-size: var(--nv-space-2);
|
||||||
|
block-size: var(--nv-space-2);
|
||||||
|
border-radius: var(--nv-radius-full);
|
||||||
|
margin-block-start: 0.45em;
|
||||||
|
flex: none;
|
||||||
|
background-color: var(--nv-color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity__mark--danger { background-color: var(--nv-color-danger); }
|
||||||
|
.activity__mark--success { background-color: var(--nv-color-success); }
|
||||||
|
.activity__mark--accent { background-color: var(--nv-color-accent); }
|
||||||
|
.activity__mark--warning { background-color: var(--nv-color-warning); }
|
||||||
|
|
||||||
|
.service-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nv-space-2-5);
|
||||||
|
}
|
||||||
578
examples/dashboard/index.html
Normal file
578
examples/dashboard/index.html
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Northwire Ops — a dashboard built with Niveus</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="../../dist/niveus.css">
|
||||||
|
<script src="../../niveus/niveus.js" defer></script>
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem("ops-theme");
|
||||||
|
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
|
||||||
|
} catch (error) {}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root { --nv-brand: #0d9488; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
|
||||||
|
<aside class="nv-nav nv-nav--vertical app__sidebar" aria-label="Sections"
|
||||||
|
style="--nv-selection-name: nv-ops-sidebar">
|
||||||
|
<a class="nv-nav__brand" href="#">
|
||||||
|
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 18V9M10 18V5M16 18v-6M22 18H2" stroke="currentColor"
|
||||||
|
stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Northwire Ops
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<span class="nv-nav__section">Monitor</span>
|
||||||
|
<ul class="nv-nav__list" data-nv-travel="local">
|
||||||
|
<li><a class="nv-nav__item" href="#" aria-current="page">Overview</a></li>
|
||||||
|
<li><a class="nv-nav__item" href="#">Services <span class="nv-nav__badge">6</span></a></li>
|
||||||
|
<li><a class="nv-nav__item" href="#">Incidents <span class="nv-nav__badge">1</span></a></li>
|
||||||
|
<li><a class="nv-nav__item" href="#">Traces</a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<span class="nv-nav__section">Manage</span>
|
||||||
|
<ul class="nv-nav__list">
|
||||||
|
<li><a class="nv-nav__item" href="#">Deployments</a></li>
|
||||||
|
<li><a class="nv-nav__item" href="#">On-call</a></li>
|
||||||
|
<li><span class="nv-nav__item" aria-disabled="true">Cost (soon)</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<span class="nv-nav__spacer"></span>
|
||||||
|
|
||||||
|
<div class="nv-bed nv-bed--quiet nv-bed--single" style="padding: var(--nv-space-4)">
|
||||||
|
<p class="nv-label">Retention</p>
|
||||||
|
<p class="nv-caption" style="margin-block: var(--nv-space-1) var(--nv-space-3)">
|
||||||
|
14 of 30 days used.
|
||||||
|
</p>
|
||||||
|
<button class="nv-button nv-button--sm nv-button--primary nv-button--block">Upgrade plan</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="app__main">
|
||||||
|
|
||||||
|
<nav class="nv-nav nv-nav--static" aria-label="Toolbar">
|
||||||
|
<ol class="nv-breadcrumbs">
|
||||||
|
<li><a href="#">Northwire</a></li>
|
||||||
|
<li><a href="#">Platform</a></li>
|
||||||
|
<li aria-current="page">Overview</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<span class="nv-nav__spacer"></span>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="range-menu"
|
||||||
|
style="--nv-anchor: --range">
|
||||||
|
<span id="range-label">Last 24 hours</span>
|
||||||
|
<span class="nv-disclosure__chevron" aria-hidden="true"
|
||||||
|
style="margin-inline-start: var(--nv-space-1)"></span>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="range-menu" style="--nv-anchor: --range">
|
||||||
|
<button class="nv-menu__item" aria-checked="true" data-range="Last 24 hours">Last 24 hours</button>
|
||||||
|
<button class="nv-menu__item" data-range="Last 7 days">Last 7 days</button>
|
||||||
|
<button class="nv-menu__item" data-range="Last 30 days">Last 30 days</button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item" data-range="Custom range">Custom range…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--sm nv-button--ghost">Export</button>
|
||||||
|
|
||||||
|
<div class="nv-button-group" role="group" aria-label="Appearance">
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="app__content">
|
||||||
|
|
||||||
|
<div class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1 class="nv-heading">Platform overview</h1>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">
|
||||||
|
Six services · updated 40 seconds ago
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="nv-cluster">
|
||||||
|
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>1 incident open</span>
|
||||||
|
<button class="nv-button nv-button--sm nv-button--primary" data-nv-dialog="incident">
|
||||||
|
Review incident
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stat tiles ------------------------------------------------------ -->
|
||||||
|
<div class="stats">
|
||||||
|
<article class="nv-card"><div class="nv-card__body">
|
||||||
|
<span class="nv-overline">Requests</span>
|
||||||
|
<span class="stat__value">14.2k</span>
|
||||||
|
<span class="nv-badge nv-badge--success">+6.1% vs yesterday</span>
|
||||||
|
</div></article>
|
||||||
|
|
||||||
|
<article class="nv-card"><div class="nv-card__body">
|
||||||
|
<span class="nv-overline">Error rate</span>
|
||||||
|
<span class="stat__value">0.42%</span>
|
||||||
|
<span class="nv-badge nv-badge--danger">+0.29 pts</span>
|
||||||
|
</div></article>
|
||||||
|
|
||||||
|
<article class="nv-card"><div class="nv-card__body">
|
||||||
|
<span class="nv-overline">p95 latency</span>
|
||||||
|
<span class="stat__value">318 ms</span>
|
||||||
|
<span class="nv-badge nv-badge--warning">+118%</span>
|
||||||
|
</div></article>
|
||||||
|
|
||||||
|
<article class="nv-card"><div class="nv-card__body">
|
||||||
|
<span class="nv-overline">Uptime</span>
|
||||||
|
<span class="stat__value">99.71%</span>
|
||||||
|
<span class="nv-badge">30-day window</span>
|
||||||
|
</div></article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart ------------------------------------------------------------ -->
|
||||||
|
<article class="nv-card">
|
||||||
|
<div class="nv-card__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-card__title">p95 latency</h2>
|
||||||
|
<p class="nv-card__subtitle">Solid: today · dashed: same day last week</p>
|
||||||
|
</div>
|
||||||
|
<span class="nv-badge nv-badge--warning">Regression at 14:00</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-card__body">
|
||||||
|
<svg class="chart" viewBox="0 0 600 200" preserveAspectRatio="none"
|
||||||
|
role="img" aria-label="p95 latency over 24 hours, rising sharply after 14:00">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="var(--nv-color-accent)" stop-opacity="0.28"/>
|
||||||
|
<stop offset="100%" stop-color="var(--nv-color-accent)" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g class="chart__grid">
|
||||||
|
<line x1="0" y1="40" x2="600" y2="40"/>
|
||||||
|
<line x1="0" y1="90" x2="600" y2="90"/>
|
||||||
|
<line x1="0" y1="140" x2="600" y2="140"/>
|
||||||
|
<line x1="0" y1="190" x2="600" y2="190"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path class="chart__area"
|
||||||
|
d="M0,150 L60,146 L120,152 L180,143 L240,148 L300,140 L360,132 L420,60 L480,44 L540,52 L600,40 L600,190 L0,190 Z"/>
|
||||||
|
<polyline class="chart__line"
|
||||||
|
points="0,150 60,146 120,152 180,143 240,148 300,140 360,132 420,60 480,44 540,52 600,40"/>
|
||||||
|
<polyline class="chart__line chart__line--compare"
|
||||||
|
points="0,152 60,149 120,151 180,146 240,150 300,145 360,148 420,143 480,147 540,141 600,145"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="chart__labels nv-caption">
|
||||||
|
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>now</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- Services and activity -------------------------------------------- -->
|
||||||
|
<div class="split">
|
||||||
|
<article class="nv-card nv-card--flush">
|
||||||
|
<div class="nv-card__header" style="padding: var(--nv-space-5)">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-card__title">Services</h2>
|
||||||
|
<p class="nv-card__subtitle">Health across the platform</p>
|
||||||
|
</div>
|
||||||
|
<div class="nv-nav-tabs" role="tablist" data-nv-tabs aria-label="Service view"
|
||||||
|
style="--nv-selection-name: nv-ops-tabs">
|
||||||
|
<button class="nv-nav__item" role="tab" id="tab-all"
|
||||||
|
aria-controls="panel-all" aria-selected="true">All</button>
|
||||||
|
<button class="nv-nav__item" role="tab" id="tab-attention"
|
||||||
|
aria-controls="panel-attention" aria-selected="false" tabindex="-1">Needs attention</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-tab-panel" id="panel-all" role="tabpanel" aria-labelledby="tab-all">
|
||||||
|
<div class="nv-table-frame">
|
||||||
|
<table class="nv-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Service</th>
|
||||||
|
<th scope="col" class="nv-table__number">Uptime</th>
|
||||||
|
<th scope="col" class="nv-table__number">p95</th>
|
||||||
|
<th scope="col" class="nv-table__number">req/min</th>
|
||||||
|
<th scope="col"><span class="nv-visually-hidden">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="service-name">
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
|
||||||
|
Checkout API
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">99.98%</td>
|
||||||
|
<td class="nv-table__number">142 ms</td>
|
||||||
|
<td class="nv-table__number">2.4k</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
|
||||||
|
popovertarget="svc-menu-1" style="--nv-anchor: --svc1" aria-label="Options for Checkout API">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="svc-menu-1" style="--nv-anchor: --svc1">
|
||||||
|
<span class="nv-menu__label">Checkout API</span>
|
||||||
|
<button class="nv-menu__item">Open dashboard</button>
|
||||||
|
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="service-name">
|
||||||
|
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>Degraded</span>
|
||||||
|
Pricing
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">99.71%</td>
|
||||||
|
<td class="nv-table__number">318 ms</td>
|
||||||
|
<td class="nv-table__number">1.9k</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
|
||||||
|
popovertarget="svc-menu-2" style="--nv-anchor: --svc2" aria-label="Options for Pricing">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="svc-menu-2" style="--nv-anchor: --svc2">
|
||||||
|
<span class="nv-menu__label">Pricing</span>
|
||||||
|
<button class="nv-menu__item">Open dashboard</button>
|
||||||
|
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="service-name">
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
|
||||||
|
Identity
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">99.99%</td>
|
||||||
|
<td class="nv-table__number">88 ms</td>
|
||||||
|
<td class="nv-table__number">6.1k</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
|
||||||
|
popovertarget="svc-menu-3" style="--nv-anchor: --svc3" aria-label="Options for Identity">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="svc-menu-3" style="--nv-anchor: --svc3">
|
||||||
|
<span class="nv-menu__label">Identity</span>
|
||||||
|
<button class="nv-menu__item">Open dashboard</button>
|
||||||
|
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="service-name">
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
|
||||||
|
Search
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">99.95%</td>
|
||||||
|
<td class="nv-table__number">204 ms</td>
|
||||||
|
<td class="nv-table__number">3.3k</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
|
||||||
|
popovertarget="svc-menu-4" style="--nv-anchor: --svc4" aria-label="Options for Search">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="svc-menu-4" style="--nv-anchor: --svc4">
|
||||||
|
<span class="nv-menu__label">Search</span>
|
||||||
|
<button class="nv-menu__item">Open dashboard</button>
|
||||||
|
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="service-name">
|
||||||
|
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Failing</span>
|
||||||
|
Notifications
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">97.20%</td>
|
||||||
|
<td class="nv-table__number">—</td>
|
||||||
|
<td class="nv-table__number">0</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
|
||||||
|
popovertarget="svc-menu-5" style="--nv-anchor: --svc5" aria-label="Options for Notifications">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="svc-menu-5" style="--nv-anchor: --svc5">
|
||||||
|
<span class="nv-menu__label">Notifications</span>
|
||||||
|
<button class="nv-menu__item">Open dashboard</button>
|
||||||
|
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="service-name">
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>Healthy</span>
|
||||||
|
Reporting
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">99.90%</td>
|
||||||
|
<td class="nv-table__number">512 ms</td>
|
||||||
|
<td class="nv-table__number">410</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
|
||||||
|
popovertarget="svc-menu-6" style="--nv-anchor: --svc6" aria-label="Options for Reporting">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="svc-menu-6" style="--nv-anchor: --svc6">
|
||||||
|
<span class="nv-menu__label">Reporting</span>
|
||||||
|
<button class="nv-menu__item">Open dashboard</button>
|
||||||
|
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-tab-panel" id="panel-attention" role="tabpanel"
|
||||||
|
aria-labelledby="tab-attention" hidden>
|
||||||
|
<div class="nv-table-frame">
|
||||||
|
<table class="nv-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Service</th>
|
||||||
|
<th scope="col" class="nv-table__number">Uptime</th>
|
||||||
|
<th scope="col" class="nv-table__number">p95</th>
|
||||||
|
<th scope="col" class="nv-table__number">req/min</th>
|
||||||
|
<th scope="col"><span class="nv-visually-hidden">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="service-name">
|
||||||
|
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>Degraded</span>
|
||||||
|
Pricing
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">99.71%</td>
|
||||||
|
<td class="nv-table__number">318 ms</td>
|
||||||
|
<td class="nv-table__number">1.9k</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
|
||||||
|
popovertarget="svc-menu-2" style="--nv-anchor: --svc2" aria-label="Options for Pricing">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="svc-menu-2" style="--nv-anchor: --svc2">
|
||||||
|
<span class="nv-menu__label">Pricing</span>
|
||||||
|
<button class="nv-menu__item">Open dashboard</button>
|
||||||
|
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="service-name">
|
||||||
|
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Failing</span>
|
||||||
|
Notifications
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">97.20%</td>
|
||||||
|
<td class="nv-table__number">—</td>
|
||||||
|
<td class="nv-table__number">0</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm nv-menu-trigger"
|
||||||
|
popovertarget="svc-menu-5" style="--nv-anchor: --svc5" aria-label="Options for Notifications">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||||
|
<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="nv-menu" popover id="svc-menu-5" style="--nv-anchor: --svc5">
|
||||||
|
<span class="nv-menu__label">Notifications</span>
|
||||||
|
<button class="nv-menu__item">Open dashboard</button>
|
||||||
|
<button class="nv-menu__item">View logs<span class="nv-menu__shortcut">L</span></button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Restart service</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="nv-stack">
|
||||||
|
<article class="nv-card">
|
||||||
|
<div class="nv-card__header">
|
||||||
|
<div><h2 class="nv-card__title">Activity</h2></div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__body" style="padding-block-start: 0">
|
||||||
|
<ul class="activity nv-caption">
|
||||||
|
<li>
|
||||||
|
<span class="activity__mark activity__mark--danger"></span>
|
||||||
|
<span><strong class="nv-text-primary">Notifications</strong> stopped responding
|
||||||
|
<span style="display: block">14:12 · alert #881</span></span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="activity__mark activity__mark--warning"></span>
|
||||||
|
<span><strong class="nv-text-primary">Pricing</strong> p95 crossed 300 ms
|
||||||
|
<span style="display: block">14:04 · alert #880</span></span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="activity__mark activity__mark--accent"></span>
|
||||||
|
<span><strong class="nv-text-primary">#4412</strong> deployed to production
|
||||||
|
<span style="display: block">13:58 · by robin</span></span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="activity__mark activity__mark--success"></span>
|
||||||
|
<span><strong class="nv-text-primary">Search</strong> recovered
|
||||||
|
<span style="display: block">09:31 · alert #879</span></span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<div class="nv-disclosure-group">
|
||||||
|
<details class="nv-disclosure" open>
|
||||||
|
<summary class="nv-disclosure__summary">
|
||||||
|
What changed at 14:00?
|
||||||
|
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="nv-disclosure__body">
|
||||||
|
One deploy landed in the window: <code class="nv-code">#4412</code>,
|
||||||
|
which moved tax resolution inside the cart loop.
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details class="nv-disclosure">
|
||||||
|
<summary class="nv-disclosure__summary">
|
||||||
|
Who is on call?
|
||||||
|
<span class="nv-disclosure__chevron" aria-hidden="true"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="nv-disclosure__body">
|
||||||
|
Robin Kade until 18:00, then Sam Oyelaran. Escalation goes to
|
||||||
|
the platform channel after 10 minutes.
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="nv-cluster nv-cluster--between" style="padding-block-start: var(--nv-space-6)">
|
||||||
|
<span class="nv-caption">Northwire Ops — an example interface built with Niveus.</span>
|
||||||
|
<span class="nv-caption">Made by Nexoniarz</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog class="nv-dialog" id="incident" aria-labelledby="incident-title">
|
||||||
|
<div class="nv-dialog__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-dialog__title" id="incident-title">Incident #881</h2>
|
||||||
|
<p class="nv-dialog__description">Notifications stopped responding at 14:12.</p>
|
||||||
|
</div>
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
|
||||||
|
aria-label="Close" data-nv-close>
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="nv-dialog__body">
|
||||||
|
<div class="nv-stack nv-stack--tight">
|
||||||
|
<div class="nv-cluster nv-cluster--tight">
|
||||||
|
<span class="nv-badge nv-badge--danger nv-badge--solid">Sev 2</span>
|
||||||
|
<span class="nv-badge">27 minutes open</span>
|
||||||
|
<span class="nv-badge">Robin Kade</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-body">
|
||||||
|
The queue consumer exited after the pricing service began timing out,
|
||||||
|
and did not restart because the health check still reported ready.
|
||||||
|
</p>
|
||||||
|
<div class="nv-field">
|
||||||
|
<label class="nv-field__label" for="note">Add a note</label>
|
||||||
|
<textarea class="nv-input" id="note" rows="3"
|
||||||
|
placeholder="What did you find?"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-dialog__footer nv-dialog__footer--divided">
|
||||||
|
<button class="nv-button" data-nv-close>Close</button>
|
||||||
|
<button class="nv-button nv-button--danger">Page the team</button>
|
||||||
|
<button class="nv-button nv-button--primary" data-nv-close>Acknowledge</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.documentElement.addEventListener("nv:theme", (event) => {
|
||||||
|
try { localStorage.setItem("ops-theme", event.detail.theme); } catch {}
|
||||||
|
});
|
||||||
|
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
|
||||||
|
|
||||||
|
const sidebar = document.querySelector('[data-nv-travel="local"]');
|
||||||
|
sidebar.addEventListener("click", (event) => {
|
||||||
|
const item = event.target.closest(".nv-nav__item");
|
||||||
|
if (!item || item.hasAttribute("aria-current")) return;
|
||||||
|
event.preventDefault();
|
||||||
|
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) => {
|
||||||
|
option.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll("[data-range]").forEach((other) => {
|
||||||
|
other.setAttribute("aria-checked", String(other === option));
|
||||||
|
});
|
||||||
|
document.getElementById("range-label").textContent = option.dataset.range;
|
||||||
|
document.getElementById("range-menu").hidePopover();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
251
examples/index.html
Normal file
251
examples/index.html
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Niveus — examples</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="../dist/niveus.css">
|
||||||
|
<script src="../niveus/niveus.js" defer></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem("niveus-theme");
|
||||||
|
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
|
||||||
|
} catch (error) {}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Layout only — see each example's app.css for the same discipline. */
|
||||||
|
.shell {
|
||||||
|
max-inline-size: 64rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding: var(--nv-space-10) var(--nv-layout-gutter) var(--nv-space-20);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
max-inline-size: var(--nv-measure);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.examples {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(19rem, 100%), 1fr));
|
||||||
|
gap: var(--nv-space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each card previews its own brand by re-deriving the set for itself. */
|
||||||
|
.swatches {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--nv-space-1-5);
|
||||||
|
margin-block-start: var(--nv-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatches span {
|
||||||
|
inline-size: var(--nv-space-5);
|
||||||
|
block-size: var(--nv-space-5);
|
||||||
|
border-radius: var(--nv-radius-xs);
|
||||||
|
border: var(--nv-border-width) solid var(--nv-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
padding-block-start: var(--nv-space-8);
|
||||||
|
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="nv-nav" aria-label="Main">
|
||||||
|
<a class="nv-nav__brand" href="../index.html">
|
||||||
|
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="6" stroke="currentColor" stroke-width="1.6"/>
|
||||||
|
<path d="M8 15.5V8.5l8 7v-7" stroke="currentColor" stroke-width="1.6"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Niveus
|
||||||
|
</a>
|
||||||
|
<ul class="nv-nav__list">
|
||||||
|
<li><a class="nv-nav__item" href="../index.html">Specimen</a></li>
|
||||||
|
<li><span class="nv-nav__item" aria-current="page">Examples</span></li>
|
||||||
|
<li><a class="nv-nav__item" href="../REFERENCE.md">Reference</a></li>
|
||||||
|
</ul>
|
||||||
|
<span class="nv-nav__spacer"></span>
|
||||||
|
<div class="nv-button-group" role="group" aria-label="Appearance">
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="shell">
|
||||||
|
|
||||||
|
<header class="intro">
|
||||||
|
<span class="nv-overline">Examples</span>
|
||||||
|
<h1 class="nv-display">Four products, one system.</h1>
|
||||||
|
<p class="nv-prose">
|
||||||
|
Each of these is a complete interface built from Niveus and nothing else.
|
||||||
|
They look like different products because they are — but none of them
|
||||||
|
overrides a component, forks a token, or writes a color. What differs is
|
||||||
|
one declaration of <code class="nv-code">--nv-brand</code>, and the
|
||||||
|
layout each one needs for its own content.
|
||||||
|
</p>
|
||||||
|
<p class="nv-prose">
|
||||||
|
Open any <code class="nv-code">app.css</code> beside them: it contains
|
||||||
|
grids, widths and breakpoints, and not a single hex value. That split —
|
||||||
|
the system owns the language, the application owns the layout — is the
|
||||||
|
thing these examples are really demonstrating.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="examples">
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #6d5efc">
|
||||||
|
<div class="nv-card__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-card__title">
|
||||||
|
<a class="nv-card__link" href="ai-landing/index.html"
|
||||||
|
style="color: inherit; text-decoration: none">Lumen</a>
|
||||||
|
</h2>
|
||||||
|
<p class="nv-card__subtitle">AI product landing page</p>
|
||||||
|
</div>
|
||||||
|
<span class="nv-badge nv-badge--accent">Marketing</span>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
Hero, feature cards, a scrolling row, pricing and an FAQ. Shows the
|
||||||
|
bed as a showcase surface and the disclosure group.
|
||||||
|
<div class="swatches" aria-hidden="true">
|
||||||
|
<span style="background: var(--nv-color-accent)"></span>
|
||||||
|
<span style="background: var(--nv-color-accent-subtle)"></span>
|
||||||
|
<span style="background: var(--nv-color-surface)"></span>
|
||||||
|
<span style="background: var(--nv-color-background)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer">
|
||||||
|
<span class="nv-caption nv-code">--nv-brand: #6d5efc</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #6d5efc">
|
||||||
|
<div class="nv-card__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-card__title">
|
||||||
|
<a class="nv-card__link" href="ai-chat/index.html"
|
||||||
|
style="color: inherit; text-decoration: none">Lumen Chat</a>
|
||||||
|
</h2>
|
||||||
|
<p class="nv-card__subtitle">AI chat application</p>
|
||||||
|
</div>
|
||||||
|
<span class="nv-badge nv-badge--accent">Web app</span>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
A full app shell: sidebar with travelling selection, a transcript that
|
||||||
|
cross-fades between threads, a composer, and settings in a dialog with
|
||||||
|
tabs.
|
||||||
|
<div class="swatches" aria-hidden="true">
|
||||||
|
<span style="background: var(--nv-color-accent)"></span>
|
||||||
|
<span style="background: var(--nv-color-accent-subtle)"></span>
|
||||||
|
<span style="background: var(--nv-color-surface)"></span>
|
||||||
|
<span style="background: var(--nv-color-background)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer">
|
||||||
|
<span class="nv-caption nv-code">--nv-brand: #6d5efc</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #ea580c">
|
||||||
|
<div class="nv-card__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-card__title">
|
||||||
|
<a class="nv-card__link" href="it-shop/index.html"
|
||||||
|
style="color: inherit; text-decoration: none">Northwire</a>
|
||||||
|
</h2>
|
||||||
|
<p class="nv-card__subtitle">IT hardware shop</p>
|
||||||
|
</div>
|
||||||
|
<span class="nv-badge nv-badge--accent">Commerce</span>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
Filters, a product grid, stock states in the status colors, and a
|
||||||
|
basket in a dialog with a table. Same components, warm brand.
|
||||||
|
<div class="swatches" aria-hidden="true">
|
||||||
|
<span style="background: var(--nv-color-accent)"></span>
|
||||||
|
<span style="background: var(--nv-color-accent-subtle)"></span>
|
||||||
|
<span style="background: var(--nv-color-surface)"></span>
|
||||||
|
<span style="background: var(--nv-color-background)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer">
|
||||||
|
<span class="nv-caption nv-code">--nv-brand: #ea580c</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="nv-card nv-card--interactive" data-nv-brand style="--nv-brand: #0d9488">
|
||||||
|
<div class="nv-card__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-card__title">
|
||||||
|
<a class="nv-card__link" href="dashboard/index.html"
|
||||||
|
style="color: inherit; text-decoration: none">Northwire Ops</a>
|
||||||
|
</h2>
|
||||||
|
<p class="nv-card__subtitle">Monitoring dashboard</p>
|
||||||
|
</div>
|
||||||
|
<span class="nv-badge nv-badge--accent">Data app</span>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__body">
|
||||||
|
Stat tiles, a chart drawn in semantic colors, a service table with
|
||||||
|
per-row menus, and tabs that filter it. The densest test of the system.
|
||||||
|
<div class="swatches" aria-hidden="true">
|
||||||
|
<span style="background: var(--nv-color-accent)"></span>
|
||||||
|
<span style="background: var(--nv-color-accent-subtle)"></span>
|
||||||
|
<span style="background: var(--nv-color-surface)"></span>
|
||||||
|
<span style="background: var(--nv-color-background)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer">
|
||||||
|
<span class="nv-caption nv-code">--nv-brand: #0d9488</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-bed">
|
||||||
|
<span class="nv-bed__label">How to read these</span>
|
||||||
|
<div class="nv-stack" style="max-inline-size: var(--nv-measure)">
|
||||||
|
<p class="nv-body">
|
||||||
|
Every example links exactly two files from the system —
|
||||||
|
<code class="nv-code">dist/niveus.css</code> and the optional
|
||||||
|
<code class="nv-code">niveus/niveus.js</code> — then its own
|
||||||
|
<code class="nv-code">app.css</code> for layout. Nothing else is shared
|
||||||
|
between them.
|
||||||
|
</p>
|
||||||
|
<p class="nv-body">
|
||||||
|
Where one of them needed something the system did not have, the answer
|
||||||
|
was to add it to the system, not to the page: the badge and table
|
||||||
|
components exist because these examples asked for them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="nv-cluster nv-cluster--between">
|
||||||
|
<span class="nv-caption">Niveus — define the system once, implement it everywhere.</span>
|
||||||
|
<span class="nv-caption">Made by Nexoniarz</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.documentElement.addEventListener("nv:theme", (event) => {
|
||||||
|
try { localStorage.setItem("niveus-theme", event.detail.theme); } catch {}
|
||||||
|
});
|
||||||
|
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
145
examples/it-shop/app.css
Normal file
145
examples/it-shop/app.css
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Northwire — shop layout
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Layout only. Note what this shop did *not* need in order to look like a
|
||||||
|
different product from the AI site next door: a second stylesheet, an
|
||||||
|
override file, or a single color. It set --nv-brand and kept going.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
max-inline-size: var(--nv-layout-max-width);
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: var(--nv-layout-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding-block: var(--nv-space-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section__head {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
margin-block-end: var(--nv-space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Search sits inside the bar, so it must not grow forever --------------- */
|
||||||
|
|
||||||
|
.search {
|
||||||
|
flex: 1;
|
||||||
|
max-inline-size: 26rem;
|
||||||
|
min-inline-size: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Catalogue -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.catalogue {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--nv-space-8);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 60rem) {
|
||||||
|
.catalogue {
|
||||||
|
grid-template-columns: 15rem 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-6);
|
||||||
|
position: sticky;
|
||||||
|
inset-block-start: calc(var(--nv-control-height-lg) + var(--nv-space-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-2-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.products {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr));
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Product ------------------------------------------------------------------
|
||||||
|
The illustration tile is a bed: it gives the line art a ground that is
|
||||||
|
visibly not the card, without inventing a colour for it. */
|
||||||
|
|
||||||
|
.product__art {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: 0;
|
||||||
|
border-inline: 0;
|
||||||
|
border-block-start: 0;
|
||||||
|
padding: var(--nv-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product__art svg {
|
||||||
|
inline-size: 60%;
|
||||||
|
max-inline-size: 8rem;
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product__price {
|
||||||
|
font-size: var(--nv-font-size-lg);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product__was {
|
||||||
|
font: var(--nv-font-caption);
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Cart ---------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.cart-line__art {
|
||||||
|
inline-size: var(--nv-space-10);
|
||||||
|
block-size: var(--nv-space-10);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--nv-radius-sm);
|
||||||
|
background-color: var(--nv-color-surface-inset);
|
||||||
|
box-shadow: var(--nv-elevation-inset-sm);
|
||||||
|
color: var(--nv-color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-line__art svg {
|
||||||
|
inline-size: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-totals {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nv-space-2);
|
||||||
|
padding-block-start: var(--nv-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-totals__row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nv-space-4);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-totals__row--due {
|
||||||
|
padding-block-start: var(--nv-space-3);
|
||||||
|
border-block-start: var(--nv-border-width) solid var(--nv-color-border);
|
||||||
|
font-size: var(--nv-font-size-lg);
|
||||||
|
font-weight: var(--nv-font-weight-semibold);
|
||||||
|
color: var(--nv-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
padding-block: var(--nv-space-8) var(--nv-space-12);
|
||||||
|
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
|
||||||
|
}
|
||||||
519
examples/it-shop/index.html
Normal file
519
examples/it-shop/index.html
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Northwire — an IT shop built with Niveus</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="../../dist/niveus.css">
|
||||||
|
<script src="../../niveus/niveus.js" defer></script>
|
||||||
|
<link rel="stylesheet" href="app.css">
|
||||||
|
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem("northwire-theme");
|
||||||
|
if (saved && saved !== "system") document.documentElement.setAttribute("data-theme", saved);
|
||||||
|
} catch (error) {}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- A shop, not an AI product. One declaration is the whole difference. -->
|
||||||
|
<style>
|
||||||
|
:root { --nv-brand: #ea580c; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<nav class="nv-nav" id="topnav" aria-label="Main" style="--nv-selection-name: nv-shop-nav">
|
||||||
|
<a class="nv-nav__brand" href="#top">
|
||||||
|
<svg class="nv-nav__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 7h16l-1.5 12h-13z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 7V5.5a3 3 0 016 0V7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
Northwire
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="nv-input-group search">
|
||||||
|
<span class="nv-input-group__affix">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" style="inline-size: 1em; block-size: 1em">
|
||||||
|
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M16 16l4 4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<label class="nv-visually-hidden" for="search">Search products</label>
|
||||||
|
<input class="nv-input nv-input--sm" id="search" type="search" placeholder="Search 4,200 products…">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="nv-nav__spacer"></span>
|
||||||
|
|
||||||
|
<div class="nv-button-group" role="group" aria-label="Appearance">
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
|
||||||
|
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--sm nv-button--primary" data-nv-dialog="basket">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 7h16l-1.5 12h-13z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Basket
|
||||||
|
<span class="nv-badge nv-badge--count nv-badge--solid" id="basket-count">3</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
|
||||||
|
<section class="shell section">
|
||||||
|
<div class="nv-bed nv-bed--single">
|
||||||
|
<div class="nv-cluster nv-cluster--between">
|
||||||
|
<div style="max-inline-size: var(--nv-measure)">
|
||||||
|
<span class="nv-overline">Clearance</span>
|
||||||
|
<h1 class="nv-heading" style="margin-block: var(--nv-space-2)">
|
||||||
|
Ex-demo workstations, checked and warrantied.
|
||||||
|
</h1>
|
||||||
|
<p class="nv-prose">
|
||||||
|
Everything here has been bench-tested and comes with the same
|
||||||
|
two-year return window as new stock.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="nv-button nv-button--lg nv-button--primary">See all 74 deals</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="shell section">
|
||||||
|
<div class="section__head">
|
||||||
|
<div>
|
||||||
|
<span class="nv-overline">This week</span>
|
||||||
|
<h2 class="nv-heading">Reduced while stock lasts</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-scroller-frame" data-nv-scroller>
|
||||||
|
<div class="nv-scroller" style="--nv-scroller-item: 15rem">
|
||||||
|
<article class="nv-card nv-card--interactive nv-card--flush">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<h3 class="nv-label">Forge 14 Developer</h3>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Ryzen 9 · 32 GB · 1 TB NVMe</p>
|
||||||
|
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$1,849</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--interactive nv-card--flush">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4.5" width="18" height="12" rx="1.8" stroke="currentColor" stroke-width="1.6"/><path d="M9.5 19.5h5M12 16.5v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<h3 class="nv-label">Clarity 27 4K</h3>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">27″ · 3840×2160 · 144 Hz</p>
|
||||||
|
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$629</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--interactive nv-card--flush">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<h3 class="nv-label">Anvil TKL</h3>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Tactile · hot-swap · USB-C</p>
|
||||||
|
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$139</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--interactive nv-card--flush">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7.5" width="19" height="9" rx="1.6" stroke="currentColor" stroke-width="1.6"/><circle cx="8.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/><circle cx="15.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<h3 class="nv-label">Halo X900 16 GB</h3>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Triple fan · 2.6 GHz boost</p>
|
||||||
|
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$1,099</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--interactive nv-card--flush">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art" style="aspect-ratio: 16/9"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<h3 class="nv-label">Vault 4 TB NVMe</h3>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Gen4 · 7,400 MB/s read</p>
|
||||||
|
<p class="product__price" style="margin-block-start: var(--nv-space-2)">$289</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="prev" aria-label="Previous">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M14 6l-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="nv-button nv-button--icon nv-button--float" data-nv-scroll="next" aria-label="Next">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M10 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="shell section" id="catalogue">
|
||||||
|
<div class="catalogue">
|
||||||
|
|
||||||
|
<aside class="filters" aria-label="Filters">
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="nv-overline">Category</span>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox" checked> Workstations</label>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox" checked> Displays</label>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox"> Networking</label>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-checkbox" type="checkbox"> Peripherals</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="nv-divider" style="margin: 0">
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="nv-overline">Price</span>
|
||||||
|
<div class="nv-cluster nv-cluster--tight" style="flex-wrap: nowrap">
|
||||||
|
<div class="nv-input-group">
|
||||||
|
<span class="nv-input-group__affix">$</span>
|
||||||
|
<label class="nv-visually-hidden" for="min">Minimum price</label>
|
||||||
|
<input class="nv-input nv-input--sm" id="min" inputmode="numeric" value="0">
|
||||||
|
</div>
|
||||||
|
<span class="nv-caption">to</span>
|
||||||
|
<div class="nv-input-group">
|
||||||
|
<span class="nv-input-group__affix">$</span>
|
||||||
|
<label class="nv-visually-hidden" for="max">Maximum price</label>
|
||||||
|
<input class="nv-input nv-input--sm" id="max" inputmode="numeric" value="2000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="nv-divider" style="margin: 0">
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="nv-overline">Availability</span>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-switch" type="checkbox" checked> In stock only</label>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-switch" type="checkbox"> Ex-demo</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="nv-divider" style="margin: 0">
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<span class="nv-overline">Condition</span>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition" checked> Any</label>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition"> New</label>
|
||||||
|
<label class="nv-choice nv-caption"><input class="nv-radio" type="radio" name="condition"> Refurbished</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--sm">Clear filters</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="section__head">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-title">Workstations & displays</h2>
|
||||||
|
<p class="nv-caption">8 of 412 products</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="nv-button nv-button--sm nv-menu-trigger" popovertarget="sort-menu"
|
||||||
|
style="--nv-anchor: --sort">
|
||||||
|
Sort: <span id="sort-label">Relevance</span>
|
||||||
|
<span class="nv-disclosure__chevron" aria-hidden="true"
|
||||||
|
style="margin-inline-start: var(--nv-space-1)"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="nv-menu" popover id="sort-menu" style="--nv-anchor: --sort">
|
||||||
|
<button class="nv-menu__item" aria-checked="true" data-sort="Relevance">Relevance</button>
|
||||||
|
<button class="nv-menu__item" data-sort="Price, low to high">Price, low to high</button>
|
||||||
|
<button class="nv-menu__item" data-sort="Price, high to low">Price, high to low</button>
|
||||||
|
<button class="nv-menu__item" data-sort="Newest">Newest</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="products">
|
||||||
|
<article class="nv-card nv-card--flush nv-card--interactive">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
|
||||||
|
<h3 class="nv-label">Forge 14 Developer</h3>
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Ryzen 9 · 32 GB · 1 TB NVMe</p>
|
||||||
|
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="product__price">$1,849</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
|
||||||
|
data-add="Forge 14 Developer">Add to basket</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--flush nv-card--interactive">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="4.5" width="18" height="12" rx="1.8" stroke="currentColor" stroke-width="1.6"/><path d="M9.5 19.5h5M12 16.5v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
|
||||||
|
<h3 class="nv-label">Clarity 27 4K</h3>
|
||||||
|
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>4 left</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">27″ · 3840×2160 · 144 Hz</p>
|
||||||
|
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="product__price">$629</span>
|
||||||
|
<span class="product__was">$749</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
|
||||||
|
data-add="Clarity 27 4K">Add to basket</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--flush nv-card--interactive">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
|
||||||
|
<h3 class="nv-label">Anvil TKL</h3>
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Tactile · hot-swap · USB-C</p>
|
||||||
|
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="product__price">$139</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
|
||||||
|
data-add="Anvil TKL">Add to basket</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--flush nv-card--interactive">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7.5" width="19" height="9" rx="1.6" stroke="currentColor" stroke-width="1.6"/><circle cx="8.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/><circle cx="15.5" cy="12" r="2.4" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
|
||||||
|
<h3 class="nv-label">Halo X900 16 GB</h3>
|
||||||
|
<span class="nv-badge nv-badge--danger"><span class="nv-badge__dot"></span>Backorder</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Triple fan · 2.6 GHz boost</p>
|
||||||
|
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="product__price">$1,099</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
|
||||||
|
data-add="Halo X900 16 GB">Add to basket</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--flush nv-card--interactive">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
|
||||||
|
<h3 class="nv-label">Vault 4 TB NVMe</h3>
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Gen4 · 7,400 MB/s read</p>
|
||||||
|
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="product__price">$289</span>
|
||||||
|
<span class="product__was">$329</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
|
||||||
|
data-add="Vault 4 TB NVMe">Add to basket</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--flush nv-card--interactive">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="13" width="18" height="6" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M7 16.5h.01M10 16.5h.01M8 9.5a5 5 0 018 0M11 6.2a8.5 8.5 0 012 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
|
||||||
|
<h3 class="nv-label">Mesh Six Pro</h3>
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Wi-Fi 6E · 3-pack · 2.5 GbE</p>
|
||||||
|
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="product__price">$399</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
|
||||||
|
data-add="Mesh Six Pro">Add to basket</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--flush nv-card--interactive">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 15v-3a7 7 0 0114 0v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect x="3" y="14" width="4" height="6" rx="1.6" stroke="currentColor" stroke-width="1.6"/><rect x="17" y="14" width="4" height="6" rx="1.6" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
|
||||||
|
<h3 class="nv-label">Quiet Field 2</h3>
|
||||||
|
<span class="nv-badge nv-badge--warning"><span class="nv-badge__dot"></span>2 left</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">ANC · 40 h · dual link</p>
|
||||||
|
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="product__price">$219</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
|
||||||
|
data-add="Quiet Field 2">Add to basket</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="nv-card nv-card--flush nv-card--interactive">
|
||||||
|
<div class="nv-bed nv-bed--quiet product__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="9" width="18" height="7" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M7 12.5h2M11 12.5h2M15.5 12.5h2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||||
|
<div class="nv-card__body" style="padding: var(--nv-space-4)">
|
||||||
|
<div class="nv-cluster nv-cluster--between" style="gap: var(--nv-space-2)">
|
||||||
|
<h3 class="nv-label">Bridge 13-in-1</h3>
|
||||||
|
<span class="nv-badge nv-badge--success"><span class="nv-badge__dot"></span>In stock</span>
|
||||||
|
</div>
|
||||||
|
<p class="nv-caption" style="margin-block-start: var(--nv-space-1)">Thunderbolt 4 · 96 W PD</p>
|
||||||
|
<div class="nv-cluster" style="margin-block-start: var(--nv-space-3)">
|
||||||
|
<span class="product__price">$179</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nv-card__footer nv-card__footer--plain" style="padding: 0 var(--nv-space-4) var(--nv-space-4)">
|
||||||
|
<button class="nv-button nv-button--primary nv-button--sm nv-button--block"
|
||||||
|
data-add="Bridge 13-in-1">Add to basket</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="shell site-footer">
|
||||||
|
<div class="nv-cluster nv-cluster--between">
|
||||||
|
<span class="nv-caption">Northwire — an example interface built with Niveus.</span>
|
||||||
|
<span class="nv-caption">Made by Nexoniarz</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<dialog class="nv-dialog nv-dialog--wide" id="basket" aria-labelledby="basket-title">
|
||||||
|
<div class="nv-dialog__header">
|
||||||
|
<div>
|
||||||
|
<h2 class="nv-dialog__title" id="basket-title">Your basket</h2>
|
||||||
|
<p class="nv-dialog__description">Three items · delivered Thursday</p>
|
||||||
|
</div>
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-dialog__close"
|
||||||
|
aria-label="Close" data-nv-close>
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-dialog__body">
|
||||||
|
<div class="nv-table-frame">
|
||||||
|
<table class="nv-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Item</th>
|
||||||
|
<th scope="col" class="nv-table__number">Qty</th>
|
||||||
|
<th scope="col" class="nv-table__number">Price</th>
|
||||||
|
<th scope="col"><span class="nv-visually-hidden">Remove</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="nv-cluster" style="flex-wrap: nowrap">
|
||||||
|
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 6.5h14v9H5z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M3 18.5h18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||||
|
<span>
|
||||||
|
Forge 14 Developer
|
||||||
|
<span class="nv-caption" style="display: block">Ryzen 9 · 32 GB</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">1</td>
|
||||||
|
<td class="nv-table__number">$1,849</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Forge 14 Developer">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="nv-cluster" style="flex-wrap: nowrap">
|
||||||
|
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 10h8M8 14h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||||
|
<span>
|
||||||
|
Vault 4 TB NVMe
|
||||||
|
<span class="nv-caption" style="display: block">Gen4 NVMe</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">1</td>
|
||||||
|
<td class="nv-table__number">$289</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Vault 4 TB NVMe">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="nv-cluster" style="flex-wrap: nowrap">
|
||||||
|
<span class="cart-line__art"><svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="2.5" y="7" width="19" height="10" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M6 10.5h1M9 10.5h1M12 10.5h1M15 10.5h1M18 10.5h1M8 13.8h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||||
|
<span>
|
||||||
|
Anvil TKL
|
||||||
|
<span class="nv-caption" style="display: block">Tactile · hot-swap</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="nv-table__number">1</td>
|
||||||
|
<td class="nv-table__number">$139</td>
|
||||||
|
<td class="nv-table__actions">
|
||||||
|
<button class="nv-button nv-button--ghost nv-button--icon nv-button--sm" aria-label="Remove Anvil TKL">
|
||||||
|
<svg class="nv-button__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cart-totals">
|
||||||
|
<div class="cart-totals__row nv-caption"><span>Subtotal</span><span>$2,277.00</span></div>
|
||||||
|
<div class="cart-totals__row nv-caption"><span>Delivery</span><span>Free</span></div>
|
||||||
|
<div class="cart-totals__row nv-caption"><span>VAT (20%)</span><span>$455.40</span></div>
|
||||||
|
<div class="cart-totals__row cart-totals__row--due"><span>Due today</span><span>$2,732.40</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nv-dialog__footer nv-dialog__footer--divided">
|
||||||
|
<button class="nv-button" data-nv-close>Keep shopping</button>
|
||||||
|
<button class="nv-button nv-button--primary">Checkout</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
document.documentElement.addEventListener("nv:theme", (event) => {
|
||||||
|
try { localStorage.setItem("northwire-theme", event.detail.theme); } catch {}
|
||||||
|
});
|
||||||
|
nv.syncThemeControls(document.documentElement.getAttribute("data-theme") || "system");
|
||||||
|
|
||||||
|
/* Adding to the basket: the count changes, so it should be watchable. */
|
||||||
|
const count = document.getElementById("basket-count");
|
||||||
|
document.querySelectorAll("[data-add]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
nv.transition(() => {
|
||||||
|
count.textContent = String(Number(count.textContent) + 1);
|
||||||
|
});
|
||||||
|
button.textContent = "Added";
|
||||||
|
setTimeout(() => { button.textContent = "Add to basket"; }, 1200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-sort]").forEach((option) => {
|
||||||
|
option.addEventListener("click", () => {
|
||||||
|
document.querySelectorAll("[data-sort]").forEach((other) => {
|
||||||
|
other.setAttribute("aria-checked", String(other === option));
|
||||||
|
});
|
||||||
|
document.getElementById("sort-label").textContent = option.dataset.sort;
|
||||||
|
document.getElementById("sort-menu").hidePopover();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const topnav = document.getElementById("topnav");
|
||||||
|
const onScroll = () => topnav.toggleAttribute("data-scrolled", scrollY > 8);
|
||||||
|
addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
fonts/InstrumentSans-Italic.woff2
Normal file
BIN
fonts/InstrumentSans-Italic.woff2
Normal file
Binary file not shown.
BIN
fonts/InstrumentSans.woff2
Normal file
BIN
fonts/InstrumentSans.woff2
Normal file
Binary file not shown.
BIN
fonts/JetBrainsMono.woff2
Normal file
BIN
fonts/JetBrainsMono.woff2
Normal file
Binary file not shown.
93
fonts/OFL-InstrumentSans.txt
Normal file
93
fonts/OFL-InstrumentSans.txt
Normal file
|
|
@ -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.
|
||||||
93
fonts/OFL-JetBrainsMono.txt
Normal file
93
fonts/OFL-JetBrainsMono.txt
Normal file
|
|
@ -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.
|
||||||
1404
index.html
Normal file
1404
index.html
Normal file
File diff suppressed because it is too large
Load diff
198
niveus/base.css
Normal file
198
niveus/base.css
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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);
|
||||||
|
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); }
|
||||||
103
niveus/components/badge.css
Normal file
103
niveus/components/badge.css
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Badge
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<span class="nv-badge nv-badge--success">In stock</span>
|
||||||
|
<span class="nv-badge nv-badge--danger nv-badge--solid">3 failing</span>
|
||||||
|
<span class="nv-badge"><span class="nv-badge__dot"></span>Draft</span>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
101
niveus/components/bed.css
Normal file
101
niveus/components/bed.css
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Bed
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<div class="nv-bed"> … </div>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
296
niveus/components/button.css
Normal file
296
niveus/components/button.css
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
175
niveus/components/card.css
Normal file
175
niveus/components/card.css
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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__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 {
|
||||||
|
--_bg: var(--nv-color-surface);
|
||||||
|
--_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);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-inline-size: 0;
|
||||||
|
|
||||||
|
background-color: var(--_bg);
|
||||||
|
background-image: 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);
|
||||||
|
|
||||||
|
/* 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: var(--nv-color-surface-elevated);
|
||||||
|
--_shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-card--floating {
|
||||||
|
--_bg: var(--nv-color-surface-elevated);
|
||||||
|
--_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), 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(--nv-bevel), var(--nv-outline), var(--nv-elevation-3);
|
||||||
|
translate: 0 calc(var(--nv-lift-hover) * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nv-card--interactive:active {
|
||||||
|
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
191
niveus/components/dialog.css
Normal file
191
niveus/components/dialog.css
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Dialog
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Built on the native <dialog> element, so focus trapping, the top layer,
|
||||||
|
Escape-to-close and inertness come from the platform rather than from
|
||||||
|
script.
|
||||||
|
|
||||||
|
<dialog class="nv-dialog"> … </dialog> 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;
|
||||||
|
|
||||||
|
background-color: var(--nv-color-surface-elevated);
|
||||||
|
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-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;
|
||||||
|
}
|
||||||
131
niveus/components/disclosure.css
Normal file
131
niveus/components/disclosure.css
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Disclosure
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<details class="nv-disclosure">
|
||||||
|
<summary class="nv-disclosure__summary"> … </summary>
|
||||||
|
<div class="nv-disclosure__body"> … </div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
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 <details>.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.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 <details> 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;
|
||||||
|
}
|
||||||
407
niveus/components/input.css
Normal file
407
niveus/components/input.css
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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);
|
||||||
|
|
||||||
|
background-color: 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,
|
||||||
|
.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 -------------------------------------------------------------------
|
||||||
|
<div class="nv-input-group">
|
||||||
|
<span class="nv-input-group__affix">@</span>
|
||||||
|
<input class="nv-input">
|
||||||
|
</div>
|
||||||
|
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 ----------------------------------------------------------------------
|
||||||
|
<div class="nv-select"><select class="nv-input">…</select></div> */
|
||||||
|
|
||||||
|
.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;
|
||||||
|
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);
|
||||||
|
font: var(--nv-font-body);
|
||||||
|
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%;
|
||||||
|
}
|
||||||
175
niveus/components/menu.css
Normal file
175
niveus/components/menu.css
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Menu
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<button class="nv-button nv-menu-trigger" popovertarget="actions"
|
||||||
|
style="--nv-anchor: --actions">Actions</button>
|
||||||
|
|
||||||
|
<div class="nv-menu" popover id="actions" style="--nv-anchor: --actions">
|
||||||
|
<button class="nv-menu__item">Rename</button>
|
||||||
|
<hr class="nv-menu__separator">
|
||||||
|
<button class="nv-menu__item nv-menu__item--danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
358
niveus/components/navigation.css
Normal file
358
niveus/components/navigation.css
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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;
|
||||||
|
gap: 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);
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
padding: var(--nv-space-4) var(--nv-space-3);
|
||||||
|
|
||||||
|
background-color: var(--nv-color-background-subtle);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
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;
|
||||||
|
gap: var(--nv-space-0-5);
|
||||||
|
padding: var(--nv-space-1);
|
||||||
|
|
||||||
|
background-color: var(--nv-color-surface-inset);
|
||||||
|
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);
|
||||||
|
}
|
||||||
131
niveus/components/scroller.css
Normal file
131
niveus/components/scroller.css
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Scroller
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<div class="nv-scroller-frame" data-nv-scroller>
|
||||||
|
<div class="nv-scroller"> …cards… </div>
|
||||||
|
<button class="nv-button nv-button--icon" data-nv-scroll="prev">…</button>
|
||||||
|
<button class="nv-button nv-button--icon" data-nv-scroll="next">…</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
116
niveus/components/surface.css
Normal file
116
niveus/components/surface.css
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
95
niveus/components/table.css
Normal file
95
niveus/components/table.css
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — Table
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
<div class="nv-table-frame">
|
||||||
|
<table class="nv-table"> … </table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
189
niveus/components/window.css
Normal file
189
niveus/components/window.css
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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
|
||||||
|
|
||||||
|
<div class="nv-window">
|
||||||
|
<div class="nv-window__titlebar">
|
||||||
|
<div class="nv-window__controls">
|
||||||
|
<button class="nv-window__control nv-window__control--close" aria-label="Close"></button>
|
||||||
|
<button class="nv-window__control nv-window__control--minimize" aria-label="Minimize"></button>
|
||||||
|
<button class="nv-window__control nv-window__control--zoom" aria-label="Zoom"></button>
|
||||||
|
</div>
|
||||||
|
<span class="nv-window__title">Settings</span>
|
||||||
|
</div>
|
||||||
|
<div class="nv-window__body">…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
min-block-size: var(--nv-control-height-lg);
|
||||||
|
padding-inline: var(--nv-space-4);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
236
niveus/continuity.css
Normal file
236
niveus/continuity.css
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible region for markup that is not a <details>. 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
niveus/fonts.css
Normal file
42
niveus/fonts.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
63
niveus/niveus.css
Normal file
63
niveus/niveus.css
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — a tactile, adaptive interface language.
|
||||||
|
Made by Nexoniarz.
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
The single entry point. An application links this file and nothing else:
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="niveus/niveus.css">
|
||||||
|
<script src="niveus/niveus.js"></script> — optional, see below
|
||||||
|
<html data-theme="dark"> — 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 ---------- */
|
||||||
|
@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);
|
||||||
|
@import url("tokens/spacing.css") layer(niveus.tokens);
|
||||||
|
@import url("tokens/radius.css") layer(niveus.tokens);
|
||||||
|
@import url("tokens/elevation.css") layer(niveus.tokens);
|
||||||
|
@import url("tokens/motion.css") layer(niveus.tokens);
|
||||||
|
@import url("tokens/materials.css") layer(niveus.tokens);
|
||||||
|
|
||||||
|
/* --- Themes: what those values become in each appearance -------------------- */
|
||||||
|
@import url("themes/light.css") layer(niveus.theme);
|
||||||
|
@import url("themes/dark.css") layer(niveus.theme);
|
||||||
|
|
||||||
|
/* --- Base: elements, before any component ----------------------------------- */
|
||||||
|
@import url("base.css") layer(niveus.base);
|
||||||
|
|
||||||
|
/* --- Continuity: how one state becomes the next ----------------------------- */
|
||||||
|
@import url("continuity.css") layer(niveus.continuity);
|
||||||
|
|
||||||
|
/* --- Components -------------------------------------------------------------- */
|
||||||
|
@import url("components/surface.css") layer(niveus.components);
|
||||||
|
@import url("components/button.css") layer(niveus.components);
|
||||||
|
@import url("components/input.css") layer(niveus.components);
|
||||||
|
@import url("components/card.css") layer(niveus.components);
|
||||||
|
@import url("components/dialog.css") layer(niveus.components);
|
||||||
|
@import url("components/navigation.css") layer(niveus.components);
|
||||||
|
@import url("components/menu.css") layer(niveus.components);
|
||||||
|
@import url("components/disclosure.css") layer(niveus.components);
|
||||||
|
@import url("components/scroller.css") layer(niveus.components);
|
||||||
|
@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);
|
||||||
|
|
||||||
|
/* --- Utilities ----------------------------------------------------------------- */
|
||||||
|
@import url("utilities.css") layer(niveus.utilities);
|
||||||
353
niveus/niveus.js
Normal file
353
niveus/niveus.js
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
Niveus — continuity helpers
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Optional. Nothing in Niveus needs this file to render; every component works
|
||||||
|
without it. What it adds is continuity for the changes CSS cannot animate on
|
||||||
|
its own — a selection moving to a different element, a panel replacing
|
||||||
|
another, the whole surface changing appearance. All three are the same
|
||||||
|
problem: two states that exist at different moments, with no single element
|
||||||
|
transitioning between them. A view transition is the browser's answer, and
|
||||||
|
this file is the small amount of wiring it needs.
|
||||||
|
|
||||||
|
Everything here is a thin wrapper. An application that already owns its
|
||||||
|
state (a router, a framework) should call nv.transition() around its own
|
||||||
|
update and ignore the rest.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Markup wiring, applied automatically on load:
|
||||||
|
|
||||||
|
[data-nv-theme="dark"] button that sets the appearance
|
||||||
|
[data-nv-dialog="id"] button that opens a dialog from itself
|
||||||
|
[data-nv-tabs] tablist whose [role=tab] children switch
|
||||||
|
their aria-controls panels
|
||||||
|
[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
|
||||||
|
|
||||||
|
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.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var root = document.documentElement;
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return global.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canTransition() {
|
||||||
|
return typeof document.startViewTransition === "function" && !prefersReducedMotion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
* `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 || {};
|
||||||
|
|
||||||
|
if (!canTransition()) {
|
||||||
|
return Promise.resolve(update());
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return view.finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "light" | "dark" | "system" — system means: follow the environment. */
|
||||||
|
function setTheme(theme) {
|
||||||
|
var done = transition(function () {
|
||||||
|
if (theme === "system") {
|
||||||
|
root.removeAttribute("data-theme");
|
||||||
|
} else {
|
||||||
|
root.setAttribute("data-theme", theme);
|
||||||
|
}
|
||||||
|
syncThemeControls(theme);
|
||||||
|
}, { kind: "theme" });
|
||||||
|
|
||||||
|
root.dispatchEvent(new CustomEvent("nv:theme", { detail: { theme: theme } }));
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncThemeControls(theme) {
|
||||||
|
var controls = document.querySelectorAll("[data-nv-theme]");
|
||||||
|
for (var i = 0; i < controls.length; i++) {
|
||||||
|
controls[i].setAttribute("aria-pressed", String(controls[i].dataset.nvTheme === theme));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a <dialog> so it appears to come from `trigger`: the entrance scales
|
||||||
|
* away from the trigger's centre instead of from the middle of the screen.
|
||||||
|
*/
|
||||||
|
function openDialog(dialog, trigger) {
|
||||||
|
if (typeof dialog === "string") dialog = document.getElementById(dialog);
|
||||||
|
if (!dialog) return null;
|
||||||
|
|
||||||
|
dialog.showModal();
|
||||||
|
|
||||||
|
if (trigger) {
|
||||||
|
// showModal() has already laid the dialog out, so both boxes are final.
|
||||||
|
var from = trigger.getBoundingClientRect();
|
||||||
|
var to = dialog.getBoundingClientRect();
|
||||||
|
|
||||||
|
// A trigger far outside the viewport — one the page has scrolled past —
|
||||||
|
// would otherwise put the origin hundreds of pixels away and turn a
|
||||||
|
// 3% scale into a lunge across the screen. One dialog-length in any
|
||||||
|
// direction is enough to say where it came from.
|
||||||
|
var clamp = function (value, size) {
|
||||||
|
return Math.max(-size, Math.min(value, size * 2));
|
||||||
|
};
|
||||||
|
|
||||||
|
dialog.style.setProperty(
|
||||||
|
"--nv-origin",
|
||||||
|
clamp(from.left + from.width / 2 - to.left, to.width).toFixed(1) + "px " +
|
||||||
|
clamp(from.top + from.height / 2 - to.top, to.height).toFixed(1) + "px"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dialog.style.removeProperty("--nv-origin");
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsOf(tablist) {
|
||||||
|
return Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move the selection, and the panel, in the direction the user is going. */
|
||||||
|
function selectTab(tablist, tab) {
|
||||||
|
var tabs = tabsOf(tablist);
|
||||||
|
var next = tabs.indexOf(tab);
|
||||||
|
var current = -1;
|
||||||
|
|
||||||
|
for (var i = 0; i < tabs.length; i++) {
|
||||||
|
if (tabs[i].getAttribute("aria-selected") === "true") current = i;
|
||||||
|
}
|
||||||
|
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 = panelOf(candidate);
|
||||||
|
if (panel) panel.hidden = !selected;
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTabs(tablist) {
|
||||||
|
tablist.addEventListener("click", function (event) {
|
||||||
|
var tab = event.target.closest('[role="tab"]');
|
||||||
|
if (tab && tablist.contains(tab)) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectTab(tablist, tab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The ARIA tabs pattern: arrows move selection, Home and End jump to the
|
||||||
|
// ends, and only the selected tab is in the tab order.
|
||||||
|
tablist.addEventListener("keydown", function (event) {
|
||||||
|
var tabs = tabsOf(tablist);
|
||||||
|
var index = tabs.indexOf(document.activeElement);
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
var vertical = tablist.getAttribute("aria-orientation") === "vertical";
|
||||||
|
var back = vertical ? "ArrowUp" : "ArrowLeft";
|
||||||
|
var forward = vertical ? "ArrowDown" : "ArrowRight";
|
||||||
|
var target = null;
|
||||||
|
|
||||||
|
if (event.key === back) target = tabs[(index - 1 + tabs.length) % tabs.length];
|
||||||
|
else if (event.key === forward) target = tabs[(index + 1) % tabs.length];
|
||||||
|
else if (event.key === "Home") target = tabs[0];
|
||||||
|
else if (event.key === "End") target = tabs[tabs.length - 1];
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectTab(tablist, target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire a scroller's prev/next controls. Scrolling itself is the browser's;
|
||||||
|
* this only gives pointers without a horizontal axis somewhere to click, and
|
||||||
|
* hides a control once there is nothing left in that direction.
|
||||||
|
*/
|
||||||
|
function initScroller(frame) {
|
||||||
|
var track = frame.querySelector(".nv-scroller");
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
var controls = frame.querySelectorAll("[data-nv-scroll]");
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
var item = track.firstElementChild;
|
||||||
|
if (!item) return track.clientWidth * 0.8;
|
||||||
|
var gap = parseFloat(getComputedStyle(track).columnGap) || 0;
|
||||||
|
return item.getBoundingClientRect().width + gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
// A sub-pixel slack, or a fractional scrollWidth leaves a control
|
||||||
|
// enabled at an end it cannot move past.
|
||||||
|
var atStart = track.scrollLeft <= 1;
|
||||||
|
var atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
|
||||||
|
|
||||||
|
controls.forEach(function (control) {
|
||||||
|
control.disabled = control.dataset.nvScroll === "prev" ? atStart : atEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same two facts decide which edge is allowed to fade.
|
||||||
|
frame.toggleAttribute("data-nv-at-start", atStart);
|
||||||
|
frame.toggleAttribute("data-nv-at-end", atEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.forEach(function (control) {
|
||||||
|
control.addEventListener("click", function () {
|
||||||
|
var direction = control.dataset.nvScroll === "next" ? 1 : -1;
|
||||||
|
track.scrollBy({ left: direction * step(), behavior: "smooth" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
track.addEventListener("scroll", update, { passive: true });
|
||||||
|
if (typeof ResizeObserver === "function") new ResizeObserver(update).observe(track);
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 items. Items already
|
||||||
|
* carrying a name are left alone, so an application can assign its own.
|
||||||
|
*/
|
||||||
|
var carried = 0;
|
||||||
|
|
||||||
|
function liftLabels(scope) {
|
||||||
|
var items = scope.querySelectorAll(".nv-nav__item");
|
||||||
|
var named = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wire(scope) {
|
||||||
|
scope = scope || document;
|
||||||
|
|
||||||
|
scope.querySelectorAll("[data-nv-theme]").forEach(function (control) {
|
||||||
|
control.addEventListener("click", function () {
|
||||||
|
setTheme(control.dataset.nvTheme);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll("[data-nv-dialog]").forEach(function (trigger) {
|
||||||
|
trigger.addEventListener("click", function () {
|
||||||
|
openDialog(trigger.dataset.nvDialog, trigger);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
|
||||||
|
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
|
||||||
|
|
||||||
|
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
|
||||||
|
control.addEventListener("click", function () {
|
||||||
|
var owner = control.closest("dialog, [popover]");
|
||||||
|
if (!owner) return;
|
||||||
|
if (owner.tagName === "DIALOG") owner.close();
|
||||||
|
else owner.hidePopover();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A dialog closes when the scrim is clicked, but not when a drag merely
|
||||||
|
// ends there.
|
||||||
|
scope.querySelectorAll("dialog.nv-dialog").forEach(function (dialog) {
|
||||||
|
dialog.addEventListener("mousedown", function (event) {
|
||||||
|
if (event.target === dialog) dialog.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
global.nv = {
|
||||||
|
transition: transition,
|
||||||
|
setTheme: setTheme,
|
||||||
|
syncThemeControls: syncThemeControls,
|
||||||
|
openDialog: openDialog,
|
||||||
|
selectTab: selectTab,
|
||||||
|
initScroller: initScroller,
|
||||||
|
liftLabels: liftLabels,
|
||||||
|
wire: wire,
|
||||||
|
prefersReducedMotion: prefersReducedMotion,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", function () { wire(); });
|
||||||
|
} else {
|
||||||
|
wire();
|
||||||
|
}
|
||||||
|
})(window);
|
||||||
158
niveus/themes/dark.css
Normal file
158
niveus/themes/dark.css
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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, #121214, 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.11);
|
||||||
|
--nv-dark-highlight-strong: rgb(255 255 255 / 0.17);
|
||||||
|
|
||||||
|
/* 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.42);
|
||||||
|
--nv-dark-outline: rgb(0 0 0 / 0.72);
|
||||||
|
|
||||||
|
--nv-dark-sheen-top: rgb(255 255 255 / 0.06);
|
||||||
|
--nv-dark-sheen-bottom: rgb(0 0 0 / 0.09);
|
||||||
|
--nv-dark-sheen-strong-top: rgb(255 255 255 / 0.2);
|
||||||
|
--nv-dark-sheen-strong-bottom: rgb(0 0 0 / 0.12);
|
||||||
|
|
||||||
|
--nv-dark-knob: #f4f3ef;
|
||||||
|
--nv-dark-knob-shade: rgb(40 32 20 / 0.24);
|
||||||
|
|
||||||
|
/* 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-material-frosted: color-mix(in oklab, rgb(36 36 40 / 0.62), var(--nv-brand) var(--nv-brand-tint));
|
||||||
|
--nv-dark-material-glass: color-mix(in oklab, rgb(44 44 48 / 0.5), var(--nv-brand) var(--nv-brand-tint));
|
||||||
|
--nv-dark-material-border: rgb(255 255 255 / 0.1);
|
||||||
|
|
||||||
|
/* --- 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);
|
||||||
|
}
|
||||||
182
niveus/themes/light.css
Normal file
182
niveus/themes/light.css
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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, #e3e2dd, 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, #e8e5de, 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.09);
|
||||||
|
--nv-light-shadow-penumbra: rgb(40 32 20 / 0.12);
|
||||||
|
--nv-light-shadow-umbra: rgb(40 32 20 / 0.11);
|
||||||
|
--nv-light-shadow-inset: rgb(40 32 20 / 0.17);
|
||||||
|
--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.07);
|
||||||
|
--nv-light-outline: rgb(40 32 20 / 0.05);
|
||||||
|
|
||||||
|
--nv-light-sheen-top: rgb(255 255 255 / 0.75);
|
||||||
|
--nv-light-sheen-bottom: rgb(40 32 20 / 0.04);
|
||||||
|
--nv-light-sheen-strong-top: rgb(255 255 255 / 0.24);
|
||||||
|
--nv-light-sheen-strong-bottom: rgb(20 16 10 / 0.1);
|
||||||
|
|
||||||
|
--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(251 251 249 / 0.66);
|
||||||
|
--nv-light-material-glass: rgb(251 251 249 / 0.5);
|
||||||
|
--nv-light-material-border: rgb(255 255 255 / 0.6);
|
||||||
|
|
||||||
|
/* --- 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);
|
||||||
|
}
|
||||||
260
niveus/tokens/color.css
Normal file
260
niveus/tokens/color.css
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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));
|
||||||
|
|
||||||
|
/* 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));
|
||||||
|
}
|
||||||
97
niveus/tokens/elevation.css
Normal file
97
niveus/tokens/elevation.css
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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 1px 3px -1px var(--nv-color-shadow-penumbra);
|
||||||
|
|
||||||
|
--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);
|
||||||
|
|
||||||
|
--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);
|
||||||
|
|
||||||
|
--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);
|
||||||
|
|
||||||
|
--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);
|
||||||
|
|
||||||
|
/* --- 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);
|
||||||
|
--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);
|
||||||
|
}
|
||||||
97
niveus/tokens/materials.css
Normal file
97
niveus/tokens/materials.css
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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: 8px;
|
||||||
|
--nv-blur-md: 16px;
|
||||||
|
--nv-blur-lg: 28px;
|
||||||
|
--nv-blur-xl: 44px;
|
||||||
|
|
||||||
|
/* 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: 160%;
|
||||||
|
--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. The light fades out
|
||||||
|
within the first stretch, so it reads as a lit top edge
|
||||||
|
rather than as a gradient over the content.
|
||||||
|
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 6rem);
|
||||||
|
--nv-sheen-knob: radial-gradient(circle at 50% 30%, transparent 35%, var(--nv-color-knob-shade));
|
||||||
|
--nv-backdrop-scrim: blur(var(--nv-blur-sm)) saturate(120%);
|
||||||
|
|
||||||
|
/* --- 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
107
niveus/tokens/motion.css
Normal file
107
niveus/tokens/motion.css
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
niveus/tokens/radius.css
Normal file
24
niveus/tokens/radius.css
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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));
|
||||||
|
}
|
||||||
54
niveus/tokens/spacing.css
Normal file
54
niveus/tokens/spacing.css
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
82
niveus/tokens/typography.css
Normal file
82
niveus/tokens/typography.css
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
64
niveus/utilities.css
Normal file
64
niveus/utilities.css
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
/* ==========================================================================
|
||||||
|
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);
|
||||||
|
}
|
||||||
113
tools/build.py
Executable file
113
tools/build.py
Executable file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Bundle the Niveus sources into a single stylesheet.
|
||||||
|
|
||||||
|
`niveus/niveus.css` is the authored entry point and works as-is over HTTP: the
|
||||||
|
browser resolves the @import chain itself. That costs a request per file and,
|
||||||
|
more importantly, blocks rendering until the whole chain has loaded — so for
|
||||||
|
anything shipped, this script flattens the chain into `dist/niveus.css`.
|
||||||
|
|
||||||
|
Each `@import url("…") layer(name)` becomes a `@layer name { … }` block holding
|
||||||
|
the file's contents, which is exactly equivalent to what the browser does with
|
||||||
|
the import. Nothing is minified and nothing is reordered; the output is meant
|
||||||
|
to stay readable and to diff cleanly against the sources.
|
||||||
|
|
||||||
|
python3 tools/build.py [--check]
|
||||||
|
|
||||||
|
--check verifies dist/ is current without writing, for use in CI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ENTRY = ROOT / "niveus" / "niveus.css"
|
||||||
|
OUTPUT = ROOT / "dist" / "niveus.css"
|
||||||
|
|
||||||
|
IMPORT = re.compile(
|
||||||
|
r"""@import\s+url\(\s*["']([^"']+)["']\s*\)\s*(?:layer\(\s*([^)]+?)\s*\))?\s*;""",
|
||||||
|
re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def indent(text: str, prefix: str = " ") -> str:
|
||||||
|
return "\n".join(prefix + line if line.strip() else line for line in text.split("\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def bundle(path: Path, seen: set[Path] | None = None) -> str:
|
||||||
|
"""Inline every @import in `path`, depth first."""
|
||||||
|
seen = seen if seen is not None else set()
|
||||||
|
resolved = path.resolve()
|
||||||
|
if resolved in seen:
|
||||||
|
raise SystemExit(f"circular @import involving {resolved.relative_to(ROOT)}")
|
||||||
|
seen.add(resolved)
|
||||||
|
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
out: list[str] = []
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
for match in IMPORT.finditer(source):
|
||||||
|
out.append(source[cursor : match.start()])
|
||||||
|
cursor = match.end()
|
||||||
|
|
||||||
|
target = (path.parent / match.group(1)).resolve()
|
||||||
|
if not target.is_file():
|
||||||
|
raise SystemExit(f"{path.relative_to(ROOT)}: cannot resolve {match.group(1)}")
|
||||||
|
|
||||||
|
# The import's own comment header is kept; it explains the section.
|
||||||
|
contents = bundle(target, seen).strip("\n")
|
||||||
|
layer = match.group(2)
|
||||||
|
if layer:
|
||||||
|
out.append(f"@layer {layer} {{\n{indent(contents)}\n}}")
|
||||||
|
else:
|
||||||
|
out.append(contents)
|
||||||
|
|
||||||
|
out.append(source[cursor:])
|
||||||
|
seen.discard(resolved)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def build() -> str:
|
||||||
|
header = (
|
||||||
|
"/* Niveus — generated bundle. Do not edit.\n"
|
||||||
|
" Source: niveus/niveus.css and the files it imports.\n"
|
||||||
|
" Rebuild: python3 tools/build.py\n"
|
||||||
|
"*/\n\n"
|
||||||
|
)
|
||||||
|
body = bundle(ENTRY)
|
||||||
|
# Collapse the runs of blank lines left behind by the inlined imports.
|
||||||
|
body = re.sub(r"\n{3,}", "\n\n", body).strip() + "\n"
|
||||||
|
return header + body
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check",
|
||||||
|
action="store_true",
|
||||||
|
help="fail if dist/niveus.css differs from the sources",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
result = build()
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
current = OUTPUT.read_text(encoding="utf-8") if OUTPUT.is_file() else None
|
||||||
|
if current != result:
|
||||||
|
print("dist/niveus.css is out of date — run: python3 tools/build.py")
|
||||||
|
return 1
|
||||||
|
print("dist/niveus.css is up to date")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUTPUT.write_text(result, encoding="utf-8")
|
||||||
|
size = len(result.encode("utf-8"))
|
||||||
|
print(f"wrote {OUTPUT.relative_to(ROOT)} ({size:,} bytes)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
255
tools/check.py
Executable file
255
tools/check.py
Executable file
|
|
@ -0,0 +1,255 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check that the Niveus sources hold to their own rules.
|
||||||
|
|
||||||
|
Four things go wrong in a design system long before anything looks broken, and
|
||||||
|
all four are mechanical:
|
||||||
|
|
||||||
|
1. A semantic token is defined for one appearance but not the other, so a
|
||||||
|
role silently resolves to nothing in light or in dark.
|
||||||
|
2. A component references a token that does not exist — a typo that degrades
|
||||||
|
to "no style" instead of to an error.
|
||||||
|
3. A visual value is hard-coded in a component — or in an example, which
|
||||||
|
claims to be built from the system and nothing else — instead of coming
|
||||||
|
from a token, which is how a system stops being a single source of truth.
|
||||||
|
4. dist/ drifts from the sources.
|
||||||
|
|
||||||
|
This script catches the first three; `build.py --check` covers the fourth.
|
||||||
|
|
||||||
|
python3 tools/check.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SRC = ROOT / "niveus"
|
||||||
|
|
||||||
|
DEFINITION = re.compile(r"(--[a-zA-Z0-9_-]+)\s*:")
|
||||||
|
REFERENCE = re.compile(r"var\(\s*(--[a-zA-Z0-9_-]+)")
|
||||||
|
COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
|
||||||
|
|
||||||
|
# A raw color or a raw shadow geometry in a component means the design language
|
||||||
|
# was restated locally instead of referenced.
|
||||||
|
HARD_CODED = [
|
||||||
|
(re.compile(r"#[0-9a-fA-F]{3,8}\b"), "hard-coded color"),
|
||||||
|
(re.compile(r"\b(?:rgb|rgba|hsl|hsla|oklch|lab)\(", re.IGNORECASE), "hard-coded color"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Properties the *application* supplies per instance, not the system. A menu
|
||||||
|
# and its trigger have to name the same anchor, and only the markup knows which
|
||||||
|
# pair belongs together.
|
||||||
|
CONSUMER_DEFINED = {"--nv-anchor"}
|
||||||
|
|
||||||
|
TOKEN_FILES = sorted((SRC / "tokens").glob("*.css")) + sorted((SRC / "themes").glob("*.css"))
|
||||||
|
CONSUMER_FILES = (
|
||||||
|
sorted((SRC / "components").glob("*.css"))
|
||||||
|
+ [SRC / "base.css", SRC / "utilities.css"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(text: str) -> str:
|
||||||
|
return COMMENT.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
def collect(paths: list[Path]) -> tuple[set[str], dict[str, list[tuple[Path, int, str]]]]:
|
||||||
|
"""Return every custom property defined across `paths`, and every reference."""
|
||||||
|
defined: set[str] = set()
|
||||||
|
references: dict[str, list[tuple[Path, int, str]]] = {}
|
||||||
|
|
||||||
|
for path in paths:
|
||||||
|
source = strip_comments(path.read_text(encoding="utf-8"))
|
||||||
|
for line_no, line in enumerate(source.split("\n"), start=1):
|
||||||
|
for name in DEFINITION.findall(line):
|
||||||
|
defined.add(name)
|
||||||
|
for name in REFERENCE.findall(line):
|
||||||
|
references.setdefault(name, []).append((path, line_no, line.strip()))
|
||||||
|
|
||||||
|
return defined, references
|
||||||
|
|
||||||
|
|
||||||
|
def rel(path: Path) -> str:
|
||||||
|
return str(path.relative_to(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def check_theme_parity(failures: list[str]) -> None:
|
||||||
|
light = SRC / "themes" / "light.css"
|
||||||
|
dark = SRC / "themes" / "dark.css"
|
||||||
|
|
||||||
|
light_roles = {
|
||||||
|
name[len("--nv-light-") :]
|
||||||
|
for name in DEFINITION.findall(strip_comments(light.read_text(encoding="utf-8")))
|
||||||
|
if name.startswith("--nv-light-")
|
||||||
|
}
|
||||||
|
dark_roles = {
|
||||||
|
name[len("--nv-dark-") :]
|
||||||
|
for name in DEFINITION.findall(strip_comments(dark.read_text(encoding="utf-8")))
|
||||||
|
if name.startswith("--nv-dark-")
|
||||||
|
}
|
||||||
|
|
||||||
|
for role in sorted(light_roles - dark_roles):
|
||||||
|
failures.append(f"themes/dark.css: missing --nv-dark-{role} (light defines it)")
|
||||||
|
for role in sorted(dark_roles - light_roles):
|
||||||
|
failures.append(f"themes/light.css: missing --nv-light-{role} (dark defines it)")
|
||||||
|
|
||||||
|
if not failures:
|
||||||
|
print(f" theme parity: {len(light_roles)} roles defined in both appearances")
|
||||||
|
|
||||||
|
|
||||||
|
def check_references(failures: list[str]) -> None:
|
||||||
|
all_files = TOKEN_FILES + CONSUMER_FILES
|
||||||
|
defined, references = collect(all_files)
|
||||||
|
|
||||||
|
unresolved = []
|
||||||
|
for name, sites in sorted(references.items()):
|
||||||
|
if name.startswith("--_"):
|
||||||
|
continue # component-private, defined and used in the same rule
|
||||||
|
if name in CONSUMER_DEFINED:
|
||||||
|
continue
|
||||||
|
if name in defined:
|
||||||
|
continue
|
||||||
|
# var() with a fallback is an intentional escape hatch.
|
||||||
|
if all("," in line.split(f"var({name}")[1][:40] for _, _, line in sites):
|
||||||
|
continue
|
||||||
|
unresolved.append((name, sites[0]))
|
||||||
|
|
||||||
|
for name, (path, line_no, _) in unresolved:
|
||||||
|
failures.append(f"{rel(path)}:{line_no}: {name} is referenced but never defined")
|
||||||
|
|
||||||
|
if not unresolved:
|
||||||
|
print(f" references: {len(references)} tokens referenced, all resolve")
|
||||||
|
|
||||||
|
|
||||||
|
def check_hard_coded(failures: list[str]) -> None:
|
||||||
|
found = 0
|
||||||
|
for path in CONSUMER_FILES:
|
||||||
|
source = strip_comments(path.read_text(encoding="utf-8"))
|
||||||
|
for line_no, line in enumerate(source.split("\n"), start=1):
|
||||||
|
for pattern, label in HARD_CODED:
|
||||||
|
if pattern.search(line):
|
||||||
|
failures.append(f"{rel(path)}:{line_no}: {label} — {line.strip()}")
|
||||||
|
found += 1
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
print(f" no hard-coded colors in {len(CONSUMER_FILES)} component and base files")
|
||||||
|
|
||||||
|
|
||||||
|
REFERENCE_DOC = ROOT / "REFERENCE.md"
|
||||||
|
|
||||||
|
EXAMPLES = ROOT / "examples"
|
||||||
|
|
||||||
|
# The one literal color an example is allowed: the input to the system.
|
||||||
|
BRAND_INPUT = re.compile(r"--nv-brand\s*:")
|
||||||
|
|
||||||
|
STYLE_BLOCK = re.compile(r"<style[^>]*>(.*?)</style>", re.DOTALL | re.IGNORECASE)
|
||||||
|
INLINE_STYLE = re.compile(r"""\sstyle=["']([^"']*)["']""")
|
||||||
|
|
||||||
|
|
||||||
|
def check_examples(failures: list[str]) -> None:
|
||||||
|
"""The examples claim to be built from the system and nothing else.
|
||||||
|
|
||||||
|
That claim is only worth making if it is enforced: an example that quietly
|
||||||
|
reaches for a hex value is no longer demonstrating the system, it is
|
||||||
|
demonstrating how to work around one. The single exception is --nv-brand,
|
||||||
|
which is the input the whole color system derives from — a literal there is
|
||||||
|
the point.
|
||||||
|
"""
|
||||||
|
if not EXAMPLES.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
scanned = 0
|
||||||
|
for path in sorted(EXAMPLES.rglob("*")):
|
||||||
|
if path.suffix not in {".css", ".html"}:
|
||||||
|
continue
|
||||||
|
scanned += 1
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if path.suffix == ".css":
|
||||||
|
regions = [(1, strip_comments(raw))]
|
||||||
|
else:
|
||||||
|
# Only the parts of an HTML file that can carry style.
|
||||||
|
regions = []
|
||||||
|
for match in STYLE_BLOCK.finditer(raw):
|
||||||
|
line = raw[: match.start()].count("\n") + 1
|
||||||
|
regions.append((line, strip_comments(match.group(1))))
|
||||||
|
for match in INLINE_STYLE.finditer(raw):
|
||||||
|
line = raw[: match.start()].count("\n") + 1
|
||||||
|
regions.append((line, match.group(1)))
|
||||||
|
|
||||||
|
for base_line, region in regions:
|
||||||
|
for offset, line in enumerate(region.split("\n")):
|
||||||
|
if BRAND_INPUT.search(line):
|
||||||
|
continue
|
||||||
|
for pattern, label in HARD_CODED:
|
||||||
|
if pattern.search(line):
|
||||||
|
failures.append(
|
||||||
|
f"{rel(path)}:{base_line + offset}: {label} in an example "
|
||||||
|
f"— {line.strip()[:70]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if path.name == "index.html" and "dist/niveus.css" not in raw:
|
||||||
|
failures.append(f"{rel(path)}: does not link the system stylesheet")
|
||||||
|
|
||||||
|
if not failures:
|
||||||
|
print(f" examples: {scanned} files, no color stated outside --nv-brand")
|
||||||
|
|
||||||
|
|
||||||
|
def check_reference(failures: list[str]) -> None:
|
||||||
|
"""Every class the CSS defines must appear in REFERENCE.md, spelled in full.
|
||||||
|
|
||||||
|
The reference claims to be the entire API — which is the only reason it is
|
||||||
|
safe to hand to someone, or to a model, instead of the source. A class that
|
||||||
|
exists but is undocumented breaks that claim silently, and a documented
|
||||||
|
class that no longer exists is worse: it reads as real.
|
||||||
|
"""
|
||||||
|
if not REFERENCE_DOC.is_file():
|
||||||
|
return
|
||||||
|
|
||||||
|
documented = REFERENCE_DOC.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
defined: set[str] = set()
|
||||||
|
sources = (
|
||||||
|
sorted((SRC / "components").glob("*.css"))
|
||||||
|
+ [SRC / "utilities.css", SRC / "base.css", SRC / "continuity.css"]
|
||||||
|
)
|
||||||
|
for path in sources:
|
||||||
|
source = strip_comments(path.read_text(encoding="utf-8"))
|
||||||
|
defined.update(re.findall(r"\.(nv-[a-z0-9_-]+)", source))
|
||||||
|
|
||||||
|
missing = sorted(name for name in defined if f".{name}" not in documented)
|
||||||
|
for name in missing:
|
||||||
|
failures.append(f"REFERENCE.md: .{name} is defined in the CSS but not documented")
|
||||||
|
|
||||||
|
# And the reverse: nothing documented that no longer exists.
|
||||||
|
mentioned = set(re.findall(r"`\.(nv-[a-z0-9_-]+)`", documented))
|
||||||
|
for name in sorted(mentioned - defined):
|
||||||
|
failures.append(f"REFERENCE.md: .{name} is documented but not defined anywhere")
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
print(f" reference: {len(defined)} classes, all documented")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
failures: list[str] = []
|
||||||
|
|
||||||
|
print("Niveus checks")
|
||||||
|
check_theme_parity(failures)
|
||||||
|
check_references(failures)
|
||||||
|
check_hard_coded(failures)
|
||||||
|
check_examples(failures)
|
||||||
|
check_reference(failures)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"\n{len(failures)} problem(s):")
|
||||||
|
for failure in failures:
|
||||||
|
print(f" {failure}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\nAll checks passed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
944
tools/niveus_css.py
Normal file
944
tools/niveus_css.py
Normal file
|
|
@ -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 <color> L C H [/ A])
|
||||||
|
color-mix(in oklab, <a>, <b> <pct>)
|
||||||
|
light-dark(<a>, <b>) 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<ws>\s+)
|
||||||
|
| (?P<hex>\#[0-9a-fA-F]{3,8})
|
||||||
|
| (?P<func>[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<ident>-{1,2}(?![0-9])[-a-zA-Z0-9_]+|[a-zA-Z_][-a-zA-Z0-9_]*)
|
||||||
|
| (?P<num>[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?(?:%|[a-zA-Z]+)?)
|
||||||
|
| (?P<punct>[(),/*+\-])
|
||||||
|
""",
|
||||||
|
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, <a> [pct], <b> [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
|
||||||
Loading…
Reference in a new issue