@flyos/design-system 1.7.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,324 @@
1
+ // ─── Nova motion library (UX v2) ─────────────────────────────────────────────
2
+ // Stage 1 task S1.3 of the UX-refresh program.
3
+ // Canon: `skills/desktop-design-language.md` §5, which cites
4
+ // `.workflow/plans/ux-refresh/analysis/D1-home-desktop.md` §5 and
5
+ // `D2-appwindow-circles.md` §6 for the exact numbers below.
6
+ // This partial is the EMISSION entry, `@use`d exactly once — from
7
+ // `_fly-theme.scss` (and transitively by the standalone `_app-surface-tokens
8
+ // .scss` bundle). It emits GLOBAL CSS: custom properties, `@keyframes`, and one
9
+ // `prefers-reduced-motion` block. Never `@use` it from a component: `@keyframes`
10
+ // and custom-property declarations are not scoped by Sass module boundaries, so
11
+ // a second `@use` emits every rule again into that component's stylesheet.
12
+ // **Need `nova-stagger` in a component? `@use 'nova-motion-mixins'.**
13
+ // The mixin moved to that sibling partial at the S1 review (F4) precisely
14
+ // because bundling a mixin with global emission made it unusable: the two
15
+ // callers who honoured this header hand-copied the mixin's math instead, and the
16
+ // one who ignored it re-emitted this whole file. The mixins-only partial emits
17
+ // nothing, so it is safe to `@use` anywhere. It is `@forward`ed below, so an
18
+ // existing `@use 'nova-motion'` caller still resolves `nova-stagger` — but pay
19
+ // the global emission for nothing if you do.
20
+ // Contract for the rest of Stage 1 (full detail in
21
+ // `.workflow/plans/ux-refresh/notes/S1.3-motion-library.md`):
22
+ // - Easing tokens `--nova-ease-*` and duration tokens `--nova-duration-*` /
23
+ // `--nova-scale-press` / `--nova-filter-press` — a component transition or
24
+ // animation NEVER writes a raw `cubic-bezier(...)`, `scale(.985)` or
25
+ // `brightness(.96)` literal again.
26
+ // - The 16 ratified `@keyframes` by exact name — components reference them
27
+ // by name in their own `animation:` shorthand (duration/easing/iteration
28
+ // are the CALLER's concern; a keyframe only defines the shape).
29
+ // - The single `prefers-reduced-motion` block for the whole app. Components
30
+ // never write their own.
31
+
32
+ // Compatibility re-export — emits nothing (the target is mixins-only).
33
+ @forward 'nova-motion-mixins';
34
+
35
+ :root {
36
+ // ── Easing families (§5 table — use these and nothing else) ───────────────
37
+ --nova-ease-structural: cubic-bezier(0.32, 0.72, 0, 1); // menus, drawers, panel/aside width, search morph, view entry, flex-basis, theme bg fade
38
+ --nova-ease-micro: cubic-bezier(0.2, 0.8, 0.2, 1); // hover/colour/scrim, fadeIn, tooltips
39
+ --nova-ease-soft: cubic-bezier(0.4, 0.6, 0.2, 1); // buttons/menuitems base transition (the interaction default)
40
+ --nova-ease-overshoot: cubic-bezier(0.34, 1.56, 0.64, 1); // icon press bounce only — playful overshoot
41
+
42
+ // ── Interaction timing (§5) ────────────────────────────────────────────────
43
+ --nova-duration-settle: 0.34s; // default settle
44
+ --nova-duration-hover: 0.14s; // hover snaps in
45
+ --nova-duration-press: 0.06s; // press — pairs with --nova-scale-press / --nova-filter-press below
46
+
47
+ // Bundled with --nova-duration-press because the canon always cites them
48
+ // together ("press .06s + scale(.985) + filter:brightness(.96)") — without
49
+ // these two, every pressable component would still hand-roll the literal.
50
+ --nova-scale-press: 0.985;
51
+ --nova-filter-press: brightness(0.96);
52
+
53
+ // ── drawerIn direction ──────────────────────────────────────────────────────
54
+ // The design is LTR-authored (drawer travels in from +28px, i.e. the inline-
55
+ // end edge). A `@keyframes` block cannot itself be direction-aware — CSS
56
+ // keyframes carry no selector — so the keyframe below animates this custom
57
+ // property instead of a literal offset, and ONLY this property flips under
58
+ // RTL (see the `[dir='rtl']` override immediately after this block). Custom
59
+ // properties inherit through Angular's emulated-encapsulation boundary even
60
+ // though selectors don't (that boundary is what makes the `[dir=rtl] &`
61
+ // combinator dead per the program's RTL invariant) — so a consumer gets the
62
+ // correct edge for free. See the handoff note for the full rationale.
63
+ --nova-drawer-in-x: 28px;
64
+ }
65
+
66
+ // The ONLY direction-aware rule in this file. It lives here — global,
67
+ // unencapsulated CSS — specifically so no consumer of `drawerIn` ever needs
68
+ // its own `[dir=rtl]`/`:host-context([dir=rtl])` selector. `[dir='rtl']`
69
+ // matches `<html dir="rtl">` (the shell/design-lab convention) and anything
70
+ // beneath it inherits the flipped value.
71
+ [dir="rtl"] {
72
+ --nova-drawer-in-x: -28px;
73
+ }
74
+
75
+ // ── Keyframe inventory (§5 table, 16 names) ──────────────────────────────────
76
+ // Each keyframe defines ONLY the shape (from/to transform + opacity). Duration,
77
+ // easing and iteration-count are the caller's `animation:` shorthand — reach
78
+ // for the tokens above, never a raw cubic-bezier.
79
+
80
+ // Dropdowns/dialogs. `.24-.28s` structural, `both`.
81
+ @keyframes menuIn {
82
+ from {
83
+ opacity: 0;
84
+ transform: translate3d(0, -5px, 0) scale(0.965);
85
+ }
86
+
87
+ to {
88
+ opacity: 1;
89
+ transform: translate3d(0, 0, 0) scale(1);
90
+ }
91
+ }
92
+
93
+ // Centered popovers — same motion as menuIn, composed with the static
94
+ // `translateX(-50%)` centering offset so the animation doesn't fight it.
95
+ @keyframes menuInCentered {
96
+ from {
97
+ opacity: 0;
98
+ transform: translate3d(-50%, -5px, 0) scale(0.965);
99
+ }
100
+
101
+ to {
102
+ opacity: 1;
103
+ transform: translate3d(-50%, 0, 0) scale(1);
104
+ }
105
+ }
106
+
107
+ // Right/end-edge drawers. `.34s` structural. See --nova-drawer-in-x above.
108
+ @keyframes drawerIn {
109
+ from {
110
+ opacity: 0;
111
+ transform: translate3d(var(--nova-drawer-in-x), 0, 0);
112
+ }
113
+
114
+ to {
115
+ opacity: 1;
116
+ transform: translate3d(0, 0, 0);
117
+ }
118
+ }
119
+
120
+ // Scrims. `.2s` micro.
121
+ @keyframes fadeIn {
122
+ from {
123
+ opacity: 0;
124
+ }
125
+
126
+ to {
127
+ opacity: 1;
128
+ }
129
+ }
130
+
131
+ // Staggered content reveal (cards) — 10px rise. `.3-.44s`, cards stagger
132
+ // `i*55ms` via nova-stagger below.
133
+ @keyframes riseIn {
134
+ from {
135
+ opacity: 0;
136
+ transform: translate3d(0, 10px, 0);
137
+ }
138
+
139
+ to {
140
+ opacity: 1;
141
+ transform: translate3d(0, 0, 0);
142
+ }
143
+ }
144
+
145
+ // View-level entry (whole panes/routes) — 8px rise, `.4s`, no stagger (one
146
+ // view enters at a time).
147
+ @keyframes viewIn {
148
+ from {
149
+ opacity: 0;
150
+ transform: translate3d(0, 8px, 0);
151
+ }
152
+
153
+ to {
154
+ opacity: 1;
155
+ transform: translate3d(0, 0, 0);
156
+ }
157
+ }
158
+
159
+ // List/row items — 6px rise, `.4s`, items stagger `i*40ms` via nova-stagger.
160
+ @keyframes itemIn {
161
+ from {
162
+ opacity: 0;
163
+ transform: translate3d(0, 6px, 0);
164
+ }
165
+
166
+ to {
167
+ opacity: 1;
168
+ transform: translate3d(0, 0, 0);
169
+ }
170
+ }
171
+
172
+ // Contextual toolbar actions. `.3s` structural, 40ms stagger per action.
173
+ @keyframes toolbarPopIn {
174
+ from {
175
+ opacity: 0;
176
+ transform: scale(0.8) translateX(6px);
177
+ }
178
+
179
+ to {
180
+ opacity: 1;
181
+ transform: scale(1) translateX(0);
182
+ }
183
+ }
184
+
185
+ // App-launcher tiles — 6px rise, `.3s` micro, `i*20ms` stagger.
186
+ @keyframes appTileIn {
187
+ from {
188
+ opacity: 0;
189
+ transform: translate3d(0, 6px, 0);
190
+ }
191
+
192
+ to {
193
+ opacity: 1;
194
+ transform: translate3d(0, 0, 0);
195
+ }
196
+ }
197
+
198
+ // Icon-button hover. `.5s ease` (not one of the four families — a wiggle, not
199
+ // a settle).
200
+ @keyframes iconWiggle {
201
+ 0%,
202
+ 100% {
203
+ transform: rotate(0deg);
204
+ }
205
+
206
+ 25% {
207
+ transform: rotate(-8deg);
208
+ }
209
+
210
+ 75% {
211
+ transform: rotate(8deg);
212
+ }
213
+ }
214
+
215
+ // Icon-button press. `.38s` overshoot (--nova-ease-overshoot).
216
+ @keyframes iconBounce {
217
+ 0% {
218
+ transform: scale(1);
219
+ }
220
+
221
+ 35% {
222
+ transform: scale(0.78);
223
+ }
224
+
225
+ 70% {
226
+ transform: scale(1.16);
227
+ }
228
+
229
+ 100% {
230
+ transform: scale(1);
231
+ }
232
+ }
233
+
234
+ // AI quick-command chips. 4s linear infinite. The design file hardcodes the
235
+ // third stop as the dark-only literal `#BF5AF2` (skill §10 known bug) — this
236
+ // substitutes the ratified `--sys-purple` token (S1.1's `_nova-tokens.scss`;
237
+ // dark `#a48ce0` / light `#6A3FC0`) so the cycle stays correct in light theme
238
+ // too. See the handoff note.
239
+ @keyframes strokeCycle {
240
+ 0%,
241
+ 100% {
242
+ border-color: var(--accent);
243
+ }
244
+
245
+ 33% {
246
+ border-color: var(--sys-teal);
247
+ }
248
+
249
+ 66% {
250
+ border-color: var(--sys-purple);
251
+ }
252
+ }
253
+
254
+ // AI orb conic-gradient rotation. 6s (bar orb) / 14s (aside orb), both linear
255
+ // — duration is the caller's, this is just one full turn.
256
+ @keyframes novaSpin {
257
+ to {
258
+ transform: rotate(360deg);
259
+ }
260
+ }
261
+
262
+ // AI orb glow pulse. 7s ease-in-out. The design file hardcodes the two glow
263
+ // stops as dark-only literals (`rgb(80 150 255 / 40%)` → `rgb(140 120 255 /
264
+ // 75%)`) — skill §10 lists the orb literals as "tokenize at port time", the same
265
+ // known bug `strokeCycle` above already resolves. Substituted here for the
266
+ // nearest ratified tokens on the orb's own identity gradient (`--accent` →
267
+ // `--sys-teal` → `--sys-purple`): the cool stop is `--sys-blue`, the warm stop
268
+ // `--sys-purple`. `color-mix` carries the authored 40%/75% alphas forward
269
+ // unchanged, so the pulse keeps its shape while both stops now flip with theme
270
+ // instead of glowing dark-blue over light paper.
271
+ @keyframes novaBreathe {
272
+ 0%,
273
+ 100% {
274
+ box-shadow: 0 0 16px color-mix(in oklab, var(--sys-blue) 40%, transparent);
275
+ }
276
+
277
+ 50% {
278
+ box-shadow: 0 0 30px color-mix(in oklab, var(--sys-purple) 75%, transparent);
279
+ }
280
+ }
281
+
282
+ // Light-mode material sheen. 22s.
283
+ @keyframes glassSheen {
284
+ 0%,
285
+ 100% {
286
+ background-position: 0% 0%;
287
+ }
288
+
289
+ 50% {
290
+ background-position: 100% 0%;
291
+ }
292
+ }
293
+
294
+ // Gradient greeting/headline text. 11s.
295
+ @keyframes greetShift {
296
+ 0%,
297
+ 100% {
298
+ background-position: 0% 0%;
299
+ }
300
+
301
+ 50% {
302
+ background-position: 100% 0%;
303
+ }
304
+ }
305
+
306
+ // ── prefers-reduced-motion ───────────────────────────────────────────────────
307
+ // THE single block for the whole design language — every animation/transition
308
+ // in the app, Nova or otherwise, is covered here. Components never repeat
309
+ // this. (The shell's `src/styles/_accessibility.scss` carries an identical
310
+ // block for the shell's own bundle; this one makes the design-system package
311
+ // self-sufficient for any consumer — federated remote, standalone Business
312
+ // App, External App — that pulls in `_fly-theme.scss` without the shell's
313
+ // stylesheet. Both are idempotent; there is no conflict in applying both.)
314
+ @media (prefers-reduced-motion: reduce) {
315
+ *,
316
+ *::before,
317
+ *::after {
318
+ /* stylelint-disable declaration-no-important */
319
+ animation-duration: 0.01ms !important;
320
+ animation-iteration-count: 1 !important;
321
+ transition-duration: 0.01ms !important;
322
+ /* stylelint-enable declaration-no-important */
323
+ }
324
+ }
@@ -0,0 +1,356 @@
1
+ // ─── Nova token layer (UX v2) ────────────────────────────────────────────────
2
+ // The Nova design language's colour vocabulary, both themes. Landed by Stage-1
3
+ // task S1.1 of the UX-refresh program.
4
+
5
+ // Canon: `skills/desktop-design-language.md` §2 (ratified table) and §3 (the
6
+ // ramp law), which in turn cite `.workflow/plans/ux-refresh/analysis/
7
+ // D3-styleguide-tokens.md`. NEVER re-derive a value from the `UX/**/*.dc.html`
8
+ // prototypes — the prototypes drift, the skill is the contract.
9
+
10
+ // ── Additive only ────────────────────────────────────────────────────────────
11
+ // Nothing here re-values a token the shipped DS already defines; every name
12
+ // below is greenfield. `nova-tokens.spec.ts` proves it by parsing the other
13
+ // theme partials, so this stays true as the DS evolves. Two consequences of the
14
+ // D1 collision ruling that look like omissions and are not:
15
+ // · The design's `--glass-bg/-border/-inset/-blur` land as `--glass2-*`. The
16
+ // shipped `--glass-*` family has a different value SHAPE (simple fill vs a
17
+ // 3-layer background-image), so loading design values into those names
18
+ // would silently restyle every current component.
19
+ // · The design's `--ink` gets NO token at all. It folds into the existing
20
+ // `--text-color` / `--label-primary` semantics at the 2.0 flip.
21
+ // · `--accent` is NOT declared here. It is the existing runtime knob
22
+ // (`#9333EA`, re-skinnable via `FlyThemeService.applyAccent`) and is
23
+ // re-valued only at the DS 2.0 flip. `--accent-hi` IS new vocabulary and
24
+ // ships now, pinned to its 2.0 partner — so in 1.x it is deliberately
25
+ // consumed by nothing. `/design-lab` → "Tokens & Ramp" previews the pair
26
+ // together by scoping both to the panel.
27
+
28
+ // ── Notation ─────────────────────────────────────────────────────────────────
29
+ // The ratified table is written in the design files' legacy `rgba(r,g,b,.a)`
30
+ // form; this file carries the same NUMBERS in the repo's modern `rgb(r g b /
31
+ // n%)` form, because stylelint (`color-function-notation` + `alpha-value-
32
+ // notation`, via stylelint-config-standard-scss) rejects the legacy form.
33
+ // `nova-tokens.spec.ts` compares notation-normalised, so a number that drifts
34
+ // still fails even though the spelling differs.
35
+
36
+ // ── Light ─────────────────────────────────────────────────────────────────────
37
+ // Selector pair matches `_theme-light.scss`: FlyOS keys themes on
38
+ // `html.light-theme` / `html.dark-theme`, NOT on the design files'
39
+ // `[data-theme]`. Do not introduce `[data-theme]` here.
40
+ :root,
41
+ html.light-theme {
42
+ // ── Chrome + nav rail ──────────────────────────────────────────────────────
43
+ --chrome-ink: #353537;
44
+ --chrome-ink-hover: #000;
45
+ --rail-ink: #353537;
46
+ --rail-stroke: rgb(0 0 0 / 7%);
47
+ --rail-on-bg: rgb(133 88 224 / 16%);
48
+ --rail-on-ink: #6A3FC0;
49
+ --rail-on-stroke: rgb(133 88 224 / 26%);
50
+ --rail-on-shadow: 0 2px 6px rgb(106 63 192 / 12%);
51
+
52
+ // Hover/selection are a TINT, never an inversion (skill §9 rule 11).
53
+ --tint-hover: rgb(133 88 224 / 10%);
54
+ --tint-sel: rgb(133 88 224 / 15%);
55
+
56
+ // The 2.0 accent's hover/pressed partner. Its `--accent` sibling stays the
57
+ // shipped runtime knob until the flip — see the header.
58
+ --accent-hi: #5A32A8;
59
+
60
+ // ── System semantics ───────────────────────────────────────────────────────
61
+ // `--sys-red-txt` exists because `--sys-red` fails AA as dark-theme text
62
+ // (4.16:1). No `-txt` variant exists yet for blue/orange/teal; those fixes
63
+ // land centrally at S7.1, never improvised per component.
64
+ // ---
65
+ // S6.1w re-measured the family after making dark's chrome plate adaptive, to
66
+ // decide whether BLUE needed a `-txt` variant. It does not, for two separate
67
+ // reasons — the second is the load-bearing one.
68
+ // ---
69
+ // 1. Blue is never TEXT. Its only path to the DOM is `nova-priority-tone
70
+ // ('normal')` -> `.status-dot--normal`, a filled circle painted with
71
+ // `background: currentcolor` — a non-text graphical object (WCAG 1.4.11,
72
+ // 3:1), not body copy. The hues that ARE painted as text (red, green,
73
+ // orange, and `--sys-red-txt`) live on the alpha-1 dark `--mat-menu-*`
74
+ // (action-menu, magic-actions) and the opaque `--surface-overlay`, where
75
+ // nothing varies with the wallpaper: red 4.80 / 4.82, green 8.96 / 8.99.
76
+ // 2. A `-txt` variant is not the mechanism anyway. Where these colours DO
77
+ // measure badly is on TRANSLUCENT plates over a bright wallpaper —
78
+ // `--glass-bg-elevated` at 82% gives red 2.83, blue 2.64, purple 3.40 over
79
+ // the brightest backdrop — and that is a PLATE defect of exactly the kind
80
+ // S6.1w fixed for the chrome, not a palette one. The proof: the EXISTING
81
+ // `--sys-red-txt` measures 3.42 on that same plate and fails too. Clearing
82
+ // 4.5 there needs an ink of luminance >= 0.44 — near-pastel, no longer
83
+ // reading as the system colour, and washed out over the 22-of-26 dark
84
+ // wallpapers where the plain token already sits at 4.74–5.07.
85
+ // ---
86
+ // So the real fix is to bring `--glass-bg-elevated` (`_theme-dark.scss`, NOT
87
+ // this file) into the adaptive set. Minting `--sys-blue-txt` would spend a
88
+ // token to improve one number while every other hue on that plate still
89
+ // failed — the "improvised per component" move this comment already forbids.
90
+ --sys-red: #C2263A;
91
+ --sys-red-txt: #B32436;
92
+ --sys-orange: #9A5A0F;
93
+ --sys-blue: #1667B8;
94
+ --sys-teal: #1F6E7C;
95
+ --sys-green: #1B7355;
96
+ --sys-purple: #6A3FC0;
97
+
98
+ // Brushed-steel wash. Light-only in the design; the dark block declares it
99
+ // `none` so a consumer never needs `var(--steel-sheen, …)` — a hex fallback
100
+ // is banned outright (skill §9 rule 3).
101
+ --steel-sheen: linear-gradient(
102
+ 112deg,
103
+ rgb(203 213 225 / 30%) 0%,
104
+ rgb(241 245 249 / 40%) 22%,
105
+ rgb(191 203 217 / 28%) 44%,
106
+ rgb(236 241 247 / 38%) 66%,
107
+ rgb(198 209 223 / 30%) 100%
108
+ );
109
+
110
+ // ── Ink ramp — TEXT tier ───────────────────────────────────────────────────
111
+ // The light ramp is a two-tier PERCEPTUAL REMAP, not an alpha mirror of dark.
112
+ // This tier is compressed upward into 65–95% black so every text step keeps
113
+ // AA on light paper. Never lerp it back toward the dark alphas.
114
+
115
+ // Naming hazard (skill §3.3): the digits after `w` are the decimal fraction —
116
+ // `--w1` is .10, `--w15` is .15, `--w015` is .015. Pick a step BY NAME; never
117
+ // mint a new rgba.
118
+ --w95: rgb(0 0 0 / 95%);
119
+ --w9: rgb(0 0 0 / 92%);
120
+ --w85: rgb(0 0 0 / 90%);
121
+ --w8: rgb(0 0 0 / 88%);
122
+ --w78: rgb(0 0 0 / 87%);
123
+ --w75: rgb(0 0 0 / 85%);
124
+ --w72: rgb(0 0 0 / 84%);
125
+ --w7: rgb(0 0 0 / 83%);
126
+ --w65: rgb(0 0 0 / 80%);
127
+ --w6: rgb(0 0 0 / 78%);
128
+ --w55: rgb(0 0 0 / 76%);
129
+ --w5: rgb(0 0 0 / 74%);
130
+ --w45: rgb(0 0 0 / 72%);
131
+
132
+ // `--w42` ≡ `--w4` here (both 70%) — an intentional closest-step collision on
133
+ // the light side, not a copy-paste bug. Same for `--w14` ≡ `--w15` below.
134
+ --w42: rgb(0 0 0 / 70%);
135
+ --w4: rgb(0 0 0 / 70%);
136
+ --w35: rgb(0 0 0 / 68%);
137
+ --w3: rgb(0 0 0 / 66%);
138
+ --w28: rgb(0 0 0 / 65%);
139
+
140
+ // ── Ink ramp — STRUCTURE tier ──────────────────────────────────────────────
141
+ // The hard discontinuity between `--w28` (65%) and `--w22` (15%) IS the
142
+ // accessibility mechanism: text stays legible, hairlines stay faint. Do not
143
+ // "smooth" it.
144
+ --w22: rgb(0 0 0 / 15%);
145
+ --w2: rgb(0 0 0 / 14%);
146
+ --w18: rgb(0 0 0 / 13%);
147
+ --w16: rgb(0 0 0 / 12%);
148
+ --w15: rgb(0 0 0 / 11%);
149
+ --w14: rgb(0 0 0 / 11%);
150
+ --w12: rgb(0 0 0 / 10%);
151
+ --w1: rgb(0 0 0 / 9%);
152
+ --w09: rgb(0 0 0 / 8%);
153
+ --w08: rgb(0 0 0 / 7.5%);
154
+ --w075: rgb(0 0 0 / 7%);
155
+ --w07: rgb(0 0 0 / 6.5%);
156
+ --w06: rgb(0 0 0 / 6%);
157
+ --w055: rgb(0 0 0 / 5.5%);
158
+ --w05: rgb(0 0 0 / 5%);
159
+ --w045: rgb(0 0 0 / 4.5%);
160
+ --w04: rgb(0 0 0 / 4%);
161
+ --w035: rgb(0 0 0 / 3.5%);
162
+ --w03: rgb(0 0 0 / 3%);
163
+ --w02: rgb(0 0 0 / 2.2%);
164
+ --w015: rgb(0 0 0 / 1.8%);
165
+
166
+ // ── Materials ──────────────────────────────────────────────────────────────
167
+ // Each material is a two-part token on purpose: a per-material alpha knob and
168
+ // a colour composed from it. `WallpaperGlassService` already samples the
169
+ // wallpaper's luminance and writes `--fly-glass-alpha` at the document root;
170
+ // S2.10 re-anchors these materials the same way by writing ONE number per
171
+ // material, with no colour string to rewrite. The composed values are
172
+ // numerically identical to the ratified literals.
173
+
174
+ // `--mat-header` is not consumed by the design's own header (header and rail
175
+ // both use `--mat-rail`; the values are identical today). Keep both — do not
176
+ // "clean up" the unused one.
177
+ --mat-header-alpha: 0.38;
178
+ --mat-header: rgb(226 232 240 / calc(var(--mat-header-alpha) * 100%));
179
+ --mat-rail-alpha: 0.38;
180
+ --mat-rail: rgb(226 232 240 / calc(var(--mat-rail-alpha) * 100%));
181
+ --mat-panel-alpha: 0.46;
182
+ --mat-panel: rgb(230 236 243 / calc(var(--mat-panel-alpha) * 100%));
183
+ --mat-menu-a-alpha: 0.58;
184
+ --mat-menu-a: rgb(238 242 247 / calc(var(--mat-menu-a-alpha) * 100%));
185
+ --mat-menu-b-alpha: 0.64;
186
+ --mat-menu-b: rgb(222 229 238 / calc(var(--mat-menu-b-alpha) * 100%));
187
+
188
+ // The agent aside — the PANEL hue on its own alpha ramp, top-to-bottom. It was a
189
+ // hardcoded literal inside `_nova-glass.scss`'s `agent` branch, and that literal
190
+ // was `rgb(56 54 60)` — byte-identical to the DARK `--mat-panel`, so the aside
191
+ // painted the dark panel colour in both themes and could never flip. Every sibling
192
+ // branch (`panel`, `menu`) already routed through a theme-aware token; this pair is
193
+ // that same shape, so the aside stops being the one surface outside the system.
194
+ // 0.52 -> 0.60 (S6.-1 fan-in ruling). The aside is painted straight against the
195
+ // WALLPAPER, exactly like `--window-bg`, and 22 of the 26 shipped wallpapers are
196
+ // dark: at 0.52 the top stop composited to 4.06:1 for body ink over the darkest
197
+ // one — sub-AA, and measured as such by `theme-contrast.spec`. The window plate
198
+ // solves this with the adaptive `--fly-glass-alpha` knob; this pair is not in that
199
+ // set, so it takes the static equivalent instead. 0.60 is not arbitrary: it leaves
200
+ // 40% transmission, the same as the window plate's ratified floor, so the aside
201
+ // and the window beside it now let the wallpaper through by the same amount.
202
+ --mat-agent-a-alpha: 0.6;
203
+ --mat-agent-a: rgb(230 236 243 / calc(var(--mat-agent-a-alpha) * 100%));
204
+ --mat-agent-b-alpha: 0.65;
205
+ --mat-agent-b: rgb(230 236 243 / calc(var(--mat-agent-b-alpha) * 100%));
206
+ --mat-tip-a-alpha: 0.8;
207
+ --mat-tip-a: rgb(238 241 245 / calc(var(--mat-tip-a-alpha) * 100%));
208
+ --mat-tip-b-alpha: 0.86;
209
+ --mat-tip-b: rgb(222 227 234 / calc(var(--mat-tip-b-alpha) * 100%));
210
+
211
+ // ── Accent ink ─────────────────────────────────────────────────────────────
212
+ --on-accent: #fff;
213
+ --check-ink: #fff;
214
+
215
+ // ── Glass quartet (design `--glass-*`, renamed per D1) ─────────────────────
216
+ // ⚠ The light `--glass2-bg` carries the DARK near-opaque fill
217
+ // (`rgb(56 54 60 / 95%)`) — reproduced verbatim from the canon. In the mock,
218
+ // header/rail-anchored popovers therefore render dark glass even in light
219
+ // mode, and the carefully-picked light `--mat-menu-*` values go unused.
220
+ // Intent-vs-bug is an OPEN question with UX (S0.2 memo; skill §2 ⚠ and §10).
221
+ // Do NOT silently "fix" it here — the ruling changes this one value, and the
222
+ // port replaces the mock's fragile `nth-child` fill override with an explicit
223
+ // surface-context class.
224
+ --glass2-bg:
225
+ radial-gradient(70% 60% at 50% 100%, var(--w07), transparent 70%),
226
+ linear-gradient(180deg, var(--w08), transparent 20%),
227
+ linear-gradient(rgb(56 54 60 / 95%), rgb(56 54 60 / 95%));
228
+ --glass2-border: var(--w18);
229
+ --glass2-inset: inset 0 0.5px 0 var(--w22);
230
+ --glass2-blur: blur(16px) saturate(180%) brightness(.95);
231
+ }
232
+
233
+ // ── Dark ──────────────────────────────────────────────────────────────────────
234
+ // Selector matches `_theme-dark.scss`.
235
+ html.dark-theme {
236
+ // ── Chrome + nav rail ──────────────────────────────────────────────────────
237
+ --chrome-ink: rgb(255 255 255 / 65%);
238
+ --chrome-ink-hover: #fff;
239
+ --rail-ink: rgb(255 255 255 / 65%);
240
+ --rail-stroke: rgb(255 255 255 / 12%);
241
+ --rail-on-bg: #836aca;
242
+ --rail-on-ink: #fff;
243
+ --rail-on-stroke: rgb(255 255 255 / 28%);
244
+ --rail-on-shadow: 0 8px 22px rgb(131 106 202 / 45%);
245
+ --tint-hover: rgb(131 106 202 / 26%);
246
+ --tint-sel: rgb(131 106 202 / 30%);
247
+ --accent-hi: #7259bd;
248
+
249
+ // ── System semantics ───────────────────────────────────────────────────────
250
+ --sys-red: #FF453A;
251
+ --sys-red-txt: #FF6961;
252
+ --sys-orange: #FF9F0A;
253
+ --sys-blue: #0A84FF;
254
+ --sys-teal: #64D2FF;
255
+ --sys-green: #4dd6a8;
256
+ --sys-purple: #a48ce0;
257
+
258
+ // Light-only in the design — declared as a no-op here so consumers can read
259
+ // it unconditionally in either theme.
260
+ --steel-sheen: none;
261
+
262
+ // ── Ink ramp — TEXT tier ───────────────────────────────────────────────────
263
+ // White-over-dark. This is the tier the light block remaps; the two are NOT
264
+ // mirrors of each other and must never be generated from one another.
265
+ --w95: rgb(255 255 255 / 95%);
266
+ --w9: rgb(255 255 255 / 90%);
267
+ --w85: rgb(255 255 255 / 85%);
268
+ --w8: rgb(255 255 255 / 80%);
269
+ --w78: rgb(255 255 255 / 78%);
270
+ --w75: rgb(255 255 255 / 75%);
271
+ --w72: rgb(255 255 255 / 72%);
272
+ --w7: rgb(255 255 255 / 70%);
273
+ --w65: rgb(255 255 255 / 65%);
274
+ --w6: rgb(255 255 255 / 60%);
275
+ --w55: rgb(255 255 255 / 55%);
276
+ --w5: rgb(255 255 255 / 50%);
277
+ --w45: rgb(255 255 255 / 45%);
278
+ --w42: rgb(255 255 255 / 42%);
279
+ --w4: rgb(255 255 255 / 40%);
280
+ --w35: rgb(255 255 255 / 35%);
281
+ --w3: rgb(255 255 255 / 30%);
282
+ --w28: rgb(255 255 255 / 28%);
283
+
284
+ // ── Ink ramp — STRUCTURE tier ──────────────────────────────────────────────
285
+ // Dark's own tiers are continuous (28% → 22%); only the light ramp breaks.
286
+ --w22: rgb(255 255 255 / 22%);
287
+ --w2: rgb(255 255 255 / 20%);
288
+ --w18: rgb(255 255 255 / 18%);
289
+ --w16: rgb(255 255 255 / 16%);
290
+ --w15: rgb(255 255 255 / 15%);
291
+ --w14: rgb(255 255 255 / 14%);
292
+ --w12: rgb(255 255 255 / 12%);
293
+ --w1: rgb(255 255 255 / 10%);
294
+ --w09: rgb(255 255 255 / 9%);
295
+ --w08: rgb(255 255 255 / 8%);
296
+ --w075: rgb(255 255 255 / 7.5%);
297
+ --w07: rgb(255 255 255 / 7%);
298
+ --w06: rgb(255 255 255 / 6%);
299
+ --w055: rgb(255 255 255 / 5.5%);
300
+ --w05: rgb(255 255 255 / 5%);
301
+ --w045: rgb(255 255 255 / 4.5%);
302
+ --w04: rgb(255 255 255 / 4%);
303
+ --w035: rgb(255 255 255 / 3.5%);
304
+ --w03: rgb(255 255 255 / 3%);
305
+ --w02: rgb(255 255 255 / 2%);
306
+ --w015: rgb(255 255 255 / 1.5%);
307
+
308
+ // ── Materials ──────────────────────────────────────────────────────────────
309
+ // Same two-part shape as the light block. The menu gradient stops are opaque
310
+ // in this theme (`#201f25` / `#1a191e`); they are expressed as alpha-1 rgb so
311
+ // S2.10 can re-anchor every material through one uniform knob.
312
+ --mat-header-alpha: 0.38;
313
+ --mat-header: rgb(48 46 52 / calc(var(--mat-header-alpha) * 100%));
314
+ --mat-rail-alpha: 0.38;
315
+ --mat-rail: rgb(48 46 52 / calc(var(--mat-rail-alpha) * 100%));
316
+ --mat-panel-alpha: 0.55;
317
+ --mat-panel: rgb(56 54 60 / calc(var(--mat-panel-alpha) * 100%));
318
+ --mat-menu-a-alpha: 1;
319
+ --mat-menu-a: rgb(32 31 37 / calc(var(--mat-menu-a-alpha) * 100%));
320
+ --mat-menu-b-alpha: 1;
321
+ --mat-menu-b: rgb(26 25 30 / calc(var(--mat-menu-b-alpha) * 100%));
322
+
323
+ // Re-valued by S6W (agent-panel sweep). The carried-over literal was
324
+ // rgb(56 54 60) at 0.52→0.65 — warm-hued AND translucent, so over a warm
325
+ // wallpaper the aside composited into muddy brown while the account-menu
326
+ // popover beside it stayed crisp on the alpha-1 `--mat-menu-*` pair. These
327
+ // are now that same menu pair's stops at alpha 1: crisp, wallpaper-immune,
328
+ // and cool-neutral like every other dark floating surface.
329
+ // Alternatives weighed (final call is perceptual, at fan-in):
330
+ // · alpha→1 on the old hue rgb(56 54 60) — crisp but noticeably lighter
331
+ // and warmer than the menu material next to it;
332
+ // · ~0.85 alpha on either hue — still lets a warm wallpaper tint the
333
+ // plate, i.e. a smaller dose of the same defect.
334
+ --mat-agent-a-alpha: 1;
335
+ --mat-agent-a: rgb(32 31 37 / calc(var(--mat-agent-a-alpha) * 100%));
336
+ --mat-agent-b-alpha: 1;
337
+ --mat-agent-b: rgb(26 25 30 / calc(var(--mat-agent-b-alpha) * 100%));
338
+ --mat-tip-a-alpha: 0.82;
339
+ --mat-tip-a: rgb(46 44 52 / calc(var(--mat-tip-a-alpha) * 100%));
340
+ --mat-tip-b-alpha: 0.86;
341
+ --mat-tip-b: rgb(40 38 46 / calc(var(--mat-tip-b-alpha) * 100%));
342
+
343
+ // ── Accent ink ─────────────────────────────────────────────────────────────
344
+ --on-accent: #fff;
345
+ --check-ink: #0f1013;
346
+
347
+ // ── Glass quartet ──────────────────────────────────────────────────────────
348
+ // Identical to the light block by design — see the ⚠ there.
349
+ --glass2-bg:
350
+ radial-gradient(70% 60% at 50% 100%, var(--w07), transparent 70%),
351
+ linear-gradient(180deg, var(--w08), transparent 20%),
352
+ linear-gradient(rgb(56 54 60 / 95%), rgb(56 54 60 / 95%));
353
+ --glass2-border: var(--w18);
354
+ --glass2-inset: inset 0 0.5px 0 var(--w22);
355
+ --glass2-blur: blur(16px) saturate(180%) brightness(.95);
356
+ }