@flyos/design-system 3.11.0 → 3.12.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.
@@ -1,575 +1,575 @@
1
- // ─── Nova glass recipes (UX v2) ──────────────────────────────────────────────
2
- // Stage 1 task S1.2 of the UX-refresh program. Canon: `skills/desktop-design-
3
- // language.md` §4, which cites `.workflow/plans/ux-refresh/analysis/
4
- // D1-home-desktop.md` §4 and `D3-styleguide-tokens.md` §1.6.
5
-
6
- // Mixins only — this partial emits NO CSS when merely `@use`d, so components can
7
- // `@use` it freely without duplicating rules. That is also why `_fly-theme.scss`
8
- // does NOT `@use` it: there would be nothing to emit.
9
-
10
- // ── Canonical import ─────────────────────────────────────────────────────────
11
- // @use 'nova-glass' as glass;
12
- // .fly-menu { @include glass.popover($surface: menu); } // portaled to <body>
13
- // .in-win-menu { @include glass.popover($surface: menu, $nested: true); } // inside a window
14
- // .fly-drawer { @include glass.popover($shadow: drawer, $surface: chrome, $nested: true); }
15
- // .app-panel { @include glass.panel; }
16
- // .shell-header { @include glass.chrome; }
17
- // Note the argumentless form has no parentheses — stylelint's
18
- // `scss/at-mixin-argumentless-call-parentheses` rejects `popover()`.
19
-
20
- // Members are namespaced rather than `nova-`-prefixed (the sibling
21
- // `_nova-motion.scss` prefixes because `@keyframes` names are GLOBAL and can
22
- // genuinely collide; Sass mixin names are module-scoped and cannot). Read the
23
- // call site, not the definition: `glass.popover` already says everything
24
- // `nova-glass-popover` would, and the namespace is what a reviewer sees.
25
-
26
- // ── The one rule this file exists to enforce ─────────────────────────────────
27
- // NEVER hand-compose glass (skill §9 rule 10). Per-component gradient stacks are
28
- // the drift this program exists to kill. Every sanctioned surface is one of the
29
- // four public mixins below; there is deliberately NO public way to pass a raw
30
- // blur radius, a raw shadow, or a fill of your own.
31
-
32
- // ── backdrop-filter is not a paint-only property (READ THIS) ─────────────────
33
- // A non-`none` `backdrop-filter` does three structural things to the element,
34
- // only the first of which is obvious:
35
- // 1. it creates a STACKING CONTEXT (hence the shell's z-ladder: the header
36
- // must out-rank content, skill §6);
37
- // 2. it makes the element a CONTAINING BLOCK for every descendant, including
38
- // `position: fixed` ones. A descendant that uses `fixed` to escape to the
39
- // viewport is instead trapped inside the glass surface, and any coordinates
40
- // computed from `getBoundingClientRect()` land in the wrong place;
41
- // 3. it makes the element a BACKDROP ROOT. A nested `backdrop-filter` then
42
- // samples only what is painted INSIDE this element — not the wallpaper — so
43
- // glass-inside-glass reads flat and muddy. (The shell already knew this:
44
- // `window/_nova-vibrancy.scss` invented `--surface-overlay-strong` for
45
- // exactly this case. `$nested: true` below is that knowledge, generalised.)
46
-
47
- // (2) is load-bearing exactly once — the `material()` rim and sheen are absolute
48
- // pseudo-elements that resolve against the glass surface without it needing
49
- // `position: relative`. Everywhere else it is a hazard: see the handoff note
50
- // `.workflow/plans/ux-refresh/notes/S1.2-glass-mixins.md` §"Containing block"
51
- // for the full inventory of what may and may not be nested under glass.
52
-
53
- // ── Vocabulary shared by the recipes ─────────────────────────────────────────
54
- // Private (Sass makes a leading `_` member inaccessible from other modules), so
55
- // no consumer can reach a half-recipe: the ONLY way to get `backdrop-filter` out
56
- // of this file is through a public mixin, and every public mixin carries the
57
- // full degradation ladder. That is the mechanism, not a convention.
58
-
59
- // The leading underscore IS that mechanism — it is the only privacy marker Sass
60
- // has — and it is what the kebab-case rules below reject. Scoped off for the
61
- // three private members only, then back on for the public API.
62
- // These are Sass line comments, NOT `/* */`: a CSS comment would survive into the
63
- // compiled output, and this partial's whole contract is that it emits nothing.
64
- // stylelint-disable scss/at-mixin-pattern, scss/at-function-pattern
65
-
66
- // The four sanctioned blur tiers (skill §4) — chrome 36 / content panel 72 /
67
- // thin 16 / window 50 (added when the window plate left the chrome family —
68
- // see `window` below). Selected BY NAME; a fifth tier would have to be added
69
- // here, in the open, where `nova-glass.spec.ts` counts them.
70
- @mixin _backdrop($tier) {
71
- @if $tier == chrome {
72
- -webkit-backdrop-filter: blur(36px) saturate(180%);
73
- backdrop-filter: blur(36px) saturate(180%);
74
- } @else if $tier == panel {
75
- -webkit-backdrop-filter: blur(72px) saturate(180%);
76
- backdrop-filter: blur(72px) saturate(180%);
77
- } @else if $tier == thin {
78
- // The ratified quartet value: blur(16px) saturate(180%) brightness(.95).
79
- -webkit-backdrop-filter: var(--glass2-blur);
80
- backdrop-filter: var(--glass2-blur);
81
- } @else if $tier == window {
82
- // Ratified from the window's own recipe (`window` mixin below) — 50px, one
83
- // step heavier than chrome, because it is the single largest glass surface
84
- // in the shell and carries the richer specular/edge treatment alongside it.
85
- -webkit-backdrop-filter: blur(50px) saturate(180%);
86
- backdrop-filter: blur(50px) saturate(180%);
87
- } @else {
88
- @error 'nova-glass: unknown blur tier `#{$tier}`. The four sanctioned tiers are `chrome` (36px), `panel` (72px), `thin` (16px), `window` (50px) — skill §4 says do not invent a fifth.';
89
- }
90
- }
91
-
92
- // The surface-context seam (skill §2, §10).
93
- // A caller DECLARES which surface it is and gets the right fill — this replaced
94
- // the mock's fragile `#dc-root > … > section` structural override. The seam did
95
- // its job on 2026-08-17: UX ruled the light `--glass2-bg` carrying the dark
96
- // near-opaque fill was a canon-export BUG, and the one-value fix landed in
97
- // `_nova-tokens.scss` (light fill = the light popover hue at 95%). `chrome`
98
- // inherited it with no change here, exactly as designed.
99
-
100
- // `chrome` is the canon's popover fill (skill §4) and — since the ruling — is
101
- // legible in both themes, so panel-class floating surfaces (`fly-filter-panel`,
102
- // `fly-drawer`, `fly-modal`, `fly-confirm-dialog`) sit on it, paired with
103
- // `$nested: true` because they render INSIDE a window (a backdrop root — hazard
104
- // 3 above — where a translucent fill + blur reads as flat mud; the dark-mode
105
- // drift review of 2026-08-17 was exactly that mud being reported). The S1-era
106
- // warning about `chrome` under a light floating MENU (95% black ink on a dark
107
- // #38363C plate at 1.66–1.99:1) described the pre-ruling value and is history;
108
- // menus stay on `menu` — its dark stops are the AA-measured alpha-1 pair, and
109
- // IN-WINDOW menus add `$nested: true`, which swaps to the solid twin pair so
110
- // light keeps its opacity where the blur is gone (the branch below).
111
- @function _fill($surface, $nested: false) {
112
- @if $surface == chrome {
113
- @return var(--glass2-bg);
114
- }
115
-
116
- // `panel`, `menu` and `agent` keep the quartet's two upper layers (bottom
117
- // radial glow + top sheen, both composed from ramp steps) and swap ONLY the
118
- // fill layer.
119
- $glow: radial-gradient(70% 60% at 50% 100%, var(--w07), transparent 70%);
120
- $sheen: linear-gradient(180deg, var(--w08), transparent 20%);
121
-
122
- @if $surface == panel {
123
- // Theme-aware translucency — the fill the light theme's `--mat-panel` was
124
- // picked for and, today, the only place those values are used at all.
125
- @return $glow, $sheen, linear-gradient(var(--mat-panel), var(--mat-panel));
126
- } @else if $surface == menu {
127
- // The design's own MENU material, ratified in both themes (skill §2) and
128
- // consumed by nothing until the S1 review fixes — `_nova-tokens.scss` says
129
- // as much in its ⚠, where the unused `--mat-menu-*` values are listed as
130
- // collateral of the light `--glass2-bg` anomaly. A two-stop vertical
131
- // gradient because the pair IS a gradient (`-a` top, `-b` bottom), the same
132
- // shape `--mat-tip-a`/`-b` carry for tooltips.
133
-
134
- // This branch exists because a floating menu is neither chrome nor a panel.
135
- // `chrome` is unreadable in light theme (above). `panel` fixes light but is
136
- // translucent in BOTH themes, so it inherits its backdrop: a dark-theme menu
137
- // over a light wallpaper falls to 3.23:1 — AA failure for primary ink on the
138
- // three most-used overlays in the system. `--mat-menu-*` is ALPHA-1 in dark,
139
- // so this fill does not vary with the wallpaper there at all (14.85:1), and
140
- // light lands at 17.44:1. Measured table:
141
- // `.workflow/plans/ux-refresh/notes/S1-review-fixes-surfaces.md`.
142
-
143
- // Nested (in-window) menus take the SOLID twin pair: light's 58/64% stops
144
- // lean on the thin blur for separation, and a nested surface has none (the
145
- // window is a backdrop root), so the translucent pair inside a window read
146
- // washed over the app's own content — the light half of the hazard the
147
- // 2026-08-17 review fixed for dark. Alpha-1 in both themes; dark's twins are
148
- // byte-identical to the base pair, so this changes nothing there.
149
- @if $nested {
150
- @return $glow, $sheen,
151
- linear-gradient(180deg, var(--mat-menu-solid-a), var(--mat-menu-solid-b));
152
- }
153
-
154
- @return $glow, $sheen, linear-gradient(180deg, var(--mat-menu-a), var(--mat-menu-b));
155
- } @else if $surface == agent {
156
- // The agent aside's own fill — the panel hue on a slightly wider alpha ramp,
157
- // top-to-bottom. Same two-stop shape as `menu`, for the same reason: the pair
158
- // IS a gradient.
159
-
160
- // This used to be the literal `rgb(56 54 60 / 52%) → 65%`, described as "a
161
- // ratified literal, allow-listed in the spec". That colour is byte-identical to
162
- // the DARK `--mat-panel`, so the aside painted the dark panel in BOTH themes and
163
- // was the one surface here that could not flip — while `panel` and `menu` right
164
- // above it both resolved through theme-aware tokens. The dark values are carried
165
- // over unchanged, so this is a light-theme fix with no dark-theme delta.
166
- @return $glow, $sheen, linear-gradient(180deg, var(--mat-agent-a), var(--mat-agent-b));
167
- }
168
-
169
- @error 'nova-glass: unknown surface `#{$surface}`. Declare one of `chrome` (ratified --glass2-bg fill; the canon default, but unreadable under a light-theme FLOATING menu — see the note on the branch), `menu` (--mat-menu-a/-b, the design\'s menu material, for menus/listboxes/action menus), `panel` (--mat-panel fill, for a surface inside the content panel), `agent` (--mat-agent-a/-b, the agent aside\'s own fill).';
170
- }
171
-
172
- // ── The degradation ladder (skill §4 — non-negotiable) ───────────────────────
173
- // Every public recipe ends with this. A component must never have to remember an
174
- // accessibility fallback, so the ladder ships WITH the glass or the glass does
175
- // not ship. `nova-glass.spec.ts` fails the build if a recipe is added without
176
- // it — that is the rule that stops the ladder rotting as recipes multiply.
177
- // The reduced-transparency flat fills are theme-dependent (`#2c2c2e` dark /
178
- // `#eef2f8` light) and are resolved with `light-dark()`, NOT a theme selector.
179
- // `html.light-theme` / `html.dark-theme` each set `color-scheme` (see
180
- // `_theme-light.scss` / `_theme-dark.scss`, and `native-select.spec.ts` which
181
- // forbids re-pinning it locally), so `light-dark()` follows the APP theme rather
182
- // than the OS. It is also the only mechanism that survives Angular's emulated
183
- // encapsulation: `html.dark-theme &` compiles to `html.dark-theme[_ngcontent-x]`
184
- // and is dead, and a component-local theme branch is banned outright (skill §9
185
- // rule 4). One value, no selector, correct in both themes.
186
- @mixin _degrade {
187
- // Transparency off: a flat, fully opaque plate. Killing only the blur would
188
- // leave the translucent fill, which is the thing that hurts.
189
- @media (prefers-reduced-transparency: reduce) {
190
- & {
191
- border-color: transparent;
192
- background-color: light-dark(#eef2f8, #2c2c2e);
193
- background-image: none;
194
- box-shadow: none;
195
- -webkit-backdrop-filter: none;
196
- backdrop-filter: none;
197
- }
198
-
199
- // The refractive rim and specular sheen ARE the transparency effect.
200
- &::before,
201
- &::after {
202
- display: none;
203
- }
204
- }
205
-
206
- // The glass hairline is decorative-contrast at best (`--w18` measures 1.79:1
207
- // against the panel — D3 §contrast). This is the ratified bump.
208
- @media (prefers-contrast: more) {
209
- & {
210
- border-color: var(--w6);
211
- }
212
- }
213
-
214
- // The sheen is the only animation this file starts, and `::after` is the only
215
- // pseudo it animates. `_nova-motion.scss` already zeroes every animation
216
- // globally under this query; this block makes the recipe self-sufficient for
217
- // any consumer that pulls the mixin without the motion partial.
218
- @media (prefers-reduced-motion: reduce) {
219
- &::after {
220
- animation: none;
221
- }
222
- }
223
- }
224
- // stylelint-enable scss/at-mixin-pattern, scss/at-function-pattern
225
-
226
- // ── Popover composite ────────────────────────────────────────────────────────
227
- // Menus, listboxes, dialogs, drawers, filter panels — the four-token quartet
228
- // (skill §4). Thin blur tier, always: a popover's tier is a property of the
229
- // recipe, not a caller's choice.
230
-
231
- // $shadow `menu` (default) — menus/listboxes/dialogs/content-panel/agent-aside
232
- // `drawer` — the directional, direction-AWARE drawer shadow
233
- // $surface `chrome` (default) / `menu` / `panel` / `agent` — see `_fill()`.
234
- // A FLOATING MENU takes `menu`, not the default: `chrome` is the
235
- // ratified quartet fill and carries the dark plate in BOTH themes.
236
- // $nested `true` when this surface sits INSIDE another glass surface. Drops
237
- // `backdrop-filter` entirely, because a nested backdrop root samples
238
- // its parent instead of the wallpaper and reads as flat mud (hazard 3
239
- // in the header). The fill must then carry the surface on its own:
240
- // `chrome` is near-opaque already, and `menu` swaps to its solid
241
- // alpha-1 twins (`--mat-menu-solid-a/-b`) — which is why `$nested`
242
- // with the genuinely translucent `panel`/`agent` fills is refused
243
- // rather than silently rendered see-through.
244
- @mixin popover($shadow: menu, $surface: chrome, $nested: false) {
245
- @if $nested and $surface != chrome and $surface != menu {
246
- @error 'nova-glass: popover($nested: true) needs a near-opaque fill — a nested surface has no backdrop-filter to separate it from the glass beneath, so the translucent `#{$surface}` fill renders see-through. Nestable surfaces are `chrome` (the 95% quartet plate) and `menu` (swaps to the solid --mat-menu-solid-a/-b twins). Drop $surface, or drop $nested and accept the muddy double-blur.';
247
- }
248
-
249
- border: 1px solid var(--glass2-border);
250
- background-image: _fill($surface, $nested);
251
-
252
- @if $shadow == menu {
253
- box-shadow:
254
- 0 16px 40px rgb(0 0 0 / 20%),
255
- 0 4px 10px rgb(0 0 0 / 10%),
256
- var(--glass2-inset);
257
- } @else if $shadow == drawer {
258
- // A drawer's shadow travels AWAY from the edge it is anchored to, so it
259
- // mirrors under RTL. `box-shadow` has no logical-offset form, so the offset
260
- // rides a custom property flipped by `:dir(rtl)` — a compound on the element
261
- // ITSELF, which is why it survives emulated encapsulation where the
262
- // `[dir='rtl'] &` descendant combinator is dead (skill §9 rule 1). On an
263
- // engine without `:dir()` the rule drops whole and the shadow stays on the
264
- // LTR side: cosmetic, never a layout break.
265
- --nova-glass-drawer-shadow-x: -30px;
266
-
267
- box-shadow:
268
- var(--nova-glass-drawer-shadow-x) 0 70px rgb(0 0 0 / 34%),
269
- 0 4px 10px rgb(0 0 0 / 10%),
270
- var(--glass2-inset);
271
- } @else {
272
- @error 'nova-glass: unknown $shadow `#{$shadow}`. Use `menu` (menus/listboxes/dialogs/content panel/agent aside) or `drawer`.';
273
- }
274
-
275
- @if not $nested {
276
- @include _backdrop(thin);
277
- }
278
-
279
- @if $shadow == drawer {
280
- &:dir(rtl) {
281
- --nova-glass-drawer-shadow-x: 30px;
282
- }
283
- }
284
-
285
- @include _degrade;
286
- }
287
-
288
- // ── Chrome ───────────────────────────────────────────────────────────────────
289
- // Header + nav rail. Explicitly NOT the quartet (skill §4): a flat `--mat-rail`
290
- // fill, a `--w16` hairline, its own three-part shadow, and the 36px tier.
291
- // `--mat-header` exists and is deliberately unused here — the design's own
292
- // header draws `--mat-rail` too, and the skill says not to "clean that up".
293
- // Note the inset step is `--w2` (0.20), NOT `--w22` (0.22) as the quartet's
294
- // `--glass2-inset` uses. The digits are the decimal fraction (skill §3.3) and
295
- // these are two different ratified steps, not a typo.
296
- @mixin chrome {
297
- border: 1px solid var(--w16);
298
- background: var(--mat-rail);
299
- box-shadow:
300
- 0 6px 16px rgb(0 0 0 / 16%),
301
- 0 1px 4px rgb(0 0 0 / 10%),
302
- inset 0 0.5px 0 var(--w2);
303
-
304
- @include _backdrop(chrome);
305
- @include _degrade;
306
- }
307
-
308
- // ── Window ───────────────────────────────────────────────────────────────────
309
- // The shared window plate (added when the one-glass-family ruling of
310
- // 2026-08-17 was superseded — skill §2/§4 carry the dated note). Originally a
311
- // bespoke, dark-theme-only material built by the Circles External App for its
312
- // own embedded window (conic-gradient specular rim, a layered gradient fill,
313
- // a distinct top/bottom edge split); promoted here as the platform default for
314
- // every window, in both themes, so it lives in ONE recipe instead of being
315
- // re-invented per app.
316
- // Consumes `--window-bg` / `--window-border` / `--window-shadow`
317
- // (`_theme-light.scss` / `_theme-dark.scss`, "Window chrome" section) — the
318
- // SAME names the pre-existing window family always used, not a parallel set:
319
- // `contextual-help-drawer`, `agent-overlay` and `agent-input` all deliberately
320
- // borrow those three tokens to read as extensions of the window's own glass,
321
- // so keeping the names carries the new recipe to them for free. Only
322
- // `--window-bg` (the fill) diverges per theme; border/shadow are one value in
323
- // both, exactly like before. The edge/rim treatment is new — a genuinely new
324
- // concept, so it gets new token names (`--window-edge-*`) nothing else reads.
325
- // This mixin does no Sass-side theme branching, same shape as `chrome`/`panel`
326
- // above; blur/saturate are a Sass-level literal via `_backdrop(window)`, same
327
- // as every other recipe here — `.window` no longer spends its own token pair
328
- // for them (retired; nothing else read those two names either).
329
- // Radius is deliberately NOT set here — `_window-shell.scss` keeps its own
330
- // `border-radius: var(--shell-window-radius)`. Glass material and window
331
- // corner geometry are separable; a 46px radius was tried once before (see that
332
- // file's own header comment) and reverted as a footgun, independent of this
333
- // mixin entirely.
334
- @mixin window {
335
- border: 1px solid var(--window-border);
336
- background-image: var(--window-bg);
337
- box-shadow: var(--window-shadow);
338
-
339
- @include _backdrop(window);
340
-
341
- // Refractive rim — the conic specular stroke. Same masked-ring technique
342
- // `material()` uses for its own rim (hazard 2 in the header: the
343
- // `backdrop-filter` above already makes this element a containing block, so
344
- // the pseudo resolves with no `position: relative` of its own needed here).
345
- &::before {
346
- content: '';
347
- position: absolute;
348
- z-index: 0;
349
- inset: 0;
350
- padding: 1px;
351
- border-radius: inherit;
352
- background: var(--window-edge-gradient);
353
- pointer-events: none;
354
- -webkit-mask:
355
- linear-gradient(#000 0 0) content-box,
356
- linear-gradient(#000 0 0);
357
- mask:
358
- linear-gradient(#000 0 0) content-box,
359
- linear-gradient(#000 0 0);
360
- -webkit-mask-composite: xor;
361
- mask-composite: exclude;
362
- }
363
-
364
- // Top/bottom edge split — catches light along the top, grounds along the
365
- // bottom, painted above content (z-index 3) so it reads at the outer 1px
366
- // edge regardless of what the app renders underneath.
367
- &::after {
368
- content: '';
369
- position: absolute;
370
- z-index: 3;
371
- inset: 0;
372
- border-radius: inherit;
373
- box-shadow:
374
- inset 0 0.5px 0 var(--window-edge-top),
375
- inset 0 -0.5px 0 var(--window-edge-bottom);
376
- pointer-events: none;
377
- }
378
-
379
- @include _degrade;
380
- }
381
-
382
- // ── Content panel ────────────────────────────────────────────────────────────
383
- // The surface holding app content — the strongest blur in the language (72px),
384
- // on the quartet's layer stack with the `--mat-panel` fill.
385
- @mixin panel {
386
- border: 1px solid var(--w18);
387
- background-image: _fill(panel);
388
- box-shadow:
389
- 0 16px 40px rgb(0 0 0 / 20%),
390
- 0 4px 10px rgb(0 0 0 / 10%),
391
- var(--glass2-inset);
392
-
393
- @include _backdrop(panel);
394
- @include _degrade;
395
- }
396
-
397
- // ── Content blur ─────────────────────────────────────────────────────────────
398
- // The one sanctioned way for a surface INSIDE a window to blur the app's OWN
399
- // content: scroll-edge toolbars, side panes, drawers over the app's canvas, and
400
- // floating tool plates that keep the app's documented fill (chats'
401
- // `--glass-bg-elevated` floaters are the reference consumers). It emits ONLY the
402
- // thin blur tier plus the degradation ladder — no fill, border or shadow — so an
403
- // app's measured, RULE-A-tracked plate/ink pairing survives the migration off
404
- // raw `backdrop-filter` untouched.
405
-
406
- // What this is NOT:
407
- // · not a menu/listbox recipe — a true menu takes `popover($surface: menu)`,
408
- // the material `fly-context-menu`/`fly-select` already ship;
409
- // · not a way to reach the wallpaper. A window is a backdrop root (hazard 3 in
410
- // the header), so inside one this blur samples the window's interior paint —
411
- // the app content behind the surface. That IS the contract here, which is
412
- // why this recipe is safe where a translucent quartet fill would read muddy.
413
-
414
- // Include it AFTER the caller's own paint declarations: the ladder's
415
- // reduced-transparency flat must be able to override the caller's translucent
416
- // fill, and within one specificity tier that is decided by source order.
417
- @mixin content-blur {
418
- @include _backdrop(thin);
419
- @include _degrade;
420
- }
421
-
422
- // ── Light "vibrancy" material — RETIRED IN PLACE, no production callers ──────
423
- // DO NOT ADOPT THIS. S6.-1 reversed the ruling that motivated it: the light
424
- // theme is conventional dark-ink-on-pale-plate, and the white-ink-on-smoke
425
- // vibrancy model is gone. `window/_nova-vibrancy.scss` — which this docblock
426
- // used to name as the live precedent for how to include it — WAS DELETED, along
427
- // with its `styles.scss` include. The only remaining callers anywhere are the
428
- // design-lab `glass-recipes-panel` exhibit and this file's own spec.
429
- // ---
430
- // It is kept rather than deleted at 2.0.0 because removing it is not the
431
- // one-line edit it looks like, and none of what it costs is worth buying here:
432
- // · `glassSheen` is a RATIFIED motion-canon keyframe (`nova-motion.spec.ts`)
433
- // with no other consumer, so deleting the material either strands a canon
434
- // animation or drags a second ratified removal along with it.
435
- // · The design lab's reduced-motion simulation uses this material as its only
436
- // vehicle — the sheen is the one thing on any recipe that reduced-motion has
437
- // to switch off — so it would need re-vehicling, not just deleting.
438
- // · ds-compat does NOT extract Sass mixins (it covers TS exports, custom
439
- // properties, `@layer` names, peer floors and, since S6.1c, CSS classes), so
440
- // removing a published mixin is a SILENT break with no gate — the same blind
441
- // spot S6.1c closed for classes, still open for this surface.
442
- // Leaving it costs nothing behavioural: it is unreachable by accident (below),
443
- // and no shipped stylesheet includes it.
444
- // ---
445
- // It must stay hard to reach by accident. Two locks, both mechanical:
446
- // · `$surface` has NO DEFAULT and is checked against a closed six-name list of
447
- // REAL shell surfaces. `@include glass.material()` is a compile error, and
448
- // there is no honest value for a feature-app component to pass.
449
- // · The light/dark gate is the CALLER's selector scope, and the only scope
450
- // where a theme gate actually works is a GLOBAL stylesheet:
451
- // html.light-theme .fly-header { @include glass.material($surface: header); }
452
- // Including this from a component stylesheet is a review-blocker: under
453
- // emulated encapsulation `html.light-theme &` is dead (skill §9 rule 1), so
454
- // the material would apply in DARK theme too and paint white gradients over
455
- // dark chrome.
456
- // The variant follows from the surface; a caller cannot mismatch them. The four
457
- // `regular`/`chrome` names are the design's four `[data-material]` surfaces; the
458
- // two `thin` names are the light thin controls §4 lists alongside them.
459
- @mixin material($surface) {
460
- $variant: null;
461
-
462
- @if $surface == header or $surface == rail {
463
- $variant: chrome;
464
- } @else if $surface == content-panel or $surface == agent-aside {
465
- $variant: regular;
466
- } @else if $surface == toolbar or $surface == tablist {
467
- $variant: thin;
468
- } @else {
469
- @error 'nova-glass: material($surface: #{$surface}) is not a shell surface. This layer is opt-in for shell chrome ONLY — pass `header`, `rail`, `content-panel`, `agent-aside` (the four [data-material] surfaces), or `toolbar` / `tablist` (the thin light controls). A feature-app component must not apply this layer at all; use popover() / panel() instead.';
470
- }
471
-
472
- // The rim and sheen are `z-index: -1` pseudo-elements: they paint above this
473
- // element's own background but below its content. `isolation` keeps them from
474
- // falling through to an ancestor's background. No `position` is set on
475
- // purpose — the `backdrop-filter` below already makes this element a
476
- // containing block for absolutely positioned descendants (hazard 2 in the
477
- // header, load-bearing exactly here), so forcing `position: relative` would
478
- // buy nothing and would silently break a `fixed` or `sticky` caller.
479
- isolation: isolate;
480
- border-color: transparent;
481
-
482
- @if $variant == regular {
483
- background-image: linear-gradient(
484
- 180deg,
485
- rgb(255 255 255 / 50%),
486
- rgb(255 255 255 / 10%) 46%,
487
- rgb(255 255 255 / 22%) 100%
488
- );
489
- box-shadow:
490
- inset 0 0.5px 0 rgb(255 255 255 / 70%),
491
- inset 0 -0.5px 0 rgb(148 170 200 / 20%),
492
- inset 0 0 26px rgb(255 255 255 / 16%),
493
- 0 10px 26px rgb(71 90 120 / 8%),
494
- 0 2px 5px rgb(71 90 120 / 5%);
495
- -webkit-backdrop-filter: blur(34px) saturate(165%) brightness(1.04);
496
- backdrop-filter: blur(34px) saturate(165%) brightness(1.04);
497
- } @else if $variant == chrome {
498
- background-image: linear-gradient(
499
- 168deg,
500
- rgb(255 255 255 / 72%) 0%,
501
- rgb(248 251 255 / 36%) 34%,
502
- rgb(232 240 252 / 24%) 72%,
503
- rgb(255 255 255 / 46%) 100%
504
- );
505
- box-shadow:
506
- inset 0 1px 0 rgb(255 255 255 / 98%),
507
- inset 0 -0.5px 0 rgb(120 145 180 / 22%),
508
- inset 0 0 0 0.5px rgb(255 255 255 / 50%),
509
- 0 8px 22px rgb(60 80 115 / 10%),
510
- 0 2px 5px rgb(60 80 115 / 6%);
511
- -webkit-backdrop-filter: blur(30px) saturate(180%) brightness(1.07);
512
- backdrop-filter: blur(30px) saturate(180%) brightness(1.07);
513
- } @else {
514
- background-image: linear-gradient(180deg, rgb(255 255 255 / 66%), rgb(240 246 255 / 24%));
515
- box-shadow:
516
- inset 0 0.5px 0 rgb(255 255 255 / 80%),
517
- inset 0 -0.5px 0 rgb(148 170 200 / 16%),
518
- inset 0 0 0 0.5px rgb(255 255 255 / 34%),
519
- 0 1px 3px rgb(71 90 120 / 5%);
520
- -webkit-backdrop-filter: blur(16px) saturate(160%) brightness(1.05);
521
- backdrop-filter: blur(16px) saturate(160%) brightness(1.05);
522
- }
523
-
524
- // Refractive rim — light bending through the edge of the glass. A 1px padding
525
- // box masked against its own content box leaves only the ring.
526
- &::before {
527
- content: '';
528
- position: absolute;
529
- z-index: -1;
530
- inset: 0;
531
- padding: 1px;
532
- border-radius: inherit;
533
- background: linear-gradient(
534
- 140deg,
535
- rgb(255 255 255 / 60%),
536
- rgb(255 255 255 / 8%) 34%,
537
- rgb(190 210 240 / 16%) 64%,
538
- rgb(255 255 255 / 42%)
539
- );
540
- pointer-events: none;
541
- -webkit-mask:
542
- linear-gradient(#000 0 0) content-box,
543
- linear-gradient(#000 0 0);
544
- mask:
545
- linear-gradient(#000 0 0) content-box,
546
- linear-gradient(#000 0 0);
547
- -webkit-mask-composite: xor;
548
- mask-composite: exclude;
549
- }
550
-
551
- // Specular sheen drifting across the surface. `background-size` is
552
- // load-bearing, not decoration: `glassSheen` animates background-POSITION, and
553
- // a background no wider than its box has nowhere to travel — at 100% the
554
- // animation runs and nothing moves.
555
- &::after {
556
- content: '';
557
- position: absolute;
558
- z-index: -1;
559
- inset: 0;
560
- border-radius: inherit;
561
- background: linear-gradient(
562
- 100deg,
563
- transparent 22%,
564
- rgb(255 255 255 / 22%) 46%,
565
- rgb(255 255 255 / 7%) 54%,
566
- transparent 76%
567
- );
568
- background-size: 260% 100%;
569
- opacity: 0.5;
570
- animation: glassSheen 22s ease-in-out infinite;
571
- pointer-events: none;
572
- }
573
-
574
- @include _degrade;
575
- }
1
+ // ─── Nova glass recipes (UX v2) ──────────────────────────────────────────────
2
+ // Stage 1 task S1.2 of the UX-refresh program. Canon: `skills/desktop-design-
3
+ // language.md` §4, which cites `.workflow/plans/ux-refresh/analysis/
4
+ // D1-home-desktop.md` §4 and `D3-styleguide-tokens.md` §1.6.
5
+
6
+ // Mixins only — this partial emits NO CSS when merely `@use`d, so components can
7
+ // `@use` it freely without duplicating rules. That is also why `_fly-theme.scss`
8
+ // does NOT `@use` it: there would be nothing to emit.
9
+
10
+ // ── Canonical import ─────────────────────────────────────────────────────────
11
+ // @use 'nova-glass' as glass;
12
+ // .fly-menu { @include glass.popover($surface: menu); } // portaled to <body>
13
+ // .in-win-menu { @include glass.popover($surface: menu, $nested: true); } // inside a window
14
+ // .fly-drawer { @include glass.popover($shadow: drawer, $surface: chrome, $nested: true); }
15
+ // .app-panel { @include glass.panel; }
16
+ // .shell-header { @include glass.chrome; }
17
+ // Note the argumentless form has no parentheses — stylelint's
18
+ // `scss/at-mixin-argumentless-call-parentheses` rejects `popover()`.
19
+
20
+ // Members are namespaced rather than `nova-`-prefixed (the sibling
21
+ // `_nova-motion.scss` prefixes because `@keyframes` names are GLOBAL and can
22
+ // genuinely collide; Sass mixin names are module-scoped and cannot). Read the
23
+ // call site, not the definition: `glass.popover` already says everything
24
+ // `nova-glass-popover` would, and the namespace is what a reviewer sees.
25
+
26
+ // ── The one rule this file exists to enforce ─────────────────────────────────
27
+ // NEVER hand-compose glass (skill §9 rule 10). Per-component gradient stacks are
28
+ // the drift this program exists to kill. Every sanctioned surface is one of the
29
+ // four public mixins below; there is deliberately NO public way to pass a raw
30
+ // blur radius, a raw shadow, or a fill of your own.
31
+
32
+ // ── backdrop-filter is not a paint-only property (READ THIS) ─────────────────
33
+ // A non-`none` `backdrop-filter` does three structural things to the element,
34
+ // only the first of which is obvious:
35
+ // 1. it creates a STACKING CONTEXT (hence the shell's z-ladder: the header
36
+ // must out-rank content, skill §6);
37
+ // 2. it makes the element a CONTAINING BLOCK for every descendant, including
38
+ // `position: fixed` ones. A descendant that uses `fixed` to escape to the
39
+ // viewport is instead trapped inside the glass surface, and any coordinates
40
+ // computed from `getBoundingClientRect()` land in the wrong place;
41
+ // 3. it makes the element a BACKDROP ROOT. A nested `backdrop-filter` then
42
+ // samples only what is painted INSIDE this element — not the wallpaper — so
43
+ // glass-inside-glass reads flat and muddy. (The shell already knew this:
44
+ // `window/_nova-vibrancy.scss` invented `--surface-overlay-strong` for
45
+ // exactly this case. `$nested: true` below is that knowledge, generalised.)
46
+
47
+ // (2) is load-bearing exactly once — the `material()` rim and sheen are absolute
48
+ // pseudo-elements that resolve against the glass surface without it needing
49
+ // `position: relative`. Everywhere else it is a hazard: see the handoff note
50
+ // `.workflow/plans/ux-refresh/notes/S1.2-glass-mixins.md` §"Containing block"
51
+ // for the full inventory of what may and may not be nested under glass.
52
+
53
+ // ── Vocabulary shared by the recipes ─────────────────────────────────────────
54
+ // Private (Sass makes a leading `_` member inaccessible from other modules), so
55
+ // no consumer can reach a half-recipe: the ONLY way to get `backdrop-filter` out
56
+ // of this file is through a public mixin, and every public mixin carries the
57
+ // full degradation ladder. That is the mechanism, not a convention.
58
+
59
+ // The leading underscore IS that mechanism — it is the only privacy marker Sass
60
+ // has — and it is what the kebab-case rules below reject. Scoped off for the
61
+ // three private members only, then back on for the public API.
62
+ // These are Sass line comments, NOT `/* */`: a CSS comment would survive into the
63
+ // compiled output, and this partial's whole contract is that it emits nothing.
64
+ // stylelint-disable scss/at-mixin-pattern, scss/at-function-pattern
65
+
66
+ // The four sanctioned blur tiers (skill §4) — chrome 36 / content panel 72 /
67
+ // thin 16 / window 50 (added when the window plate left the chrome family —
68
+ // see `window` below). Selected BY NAME; a fifth tier would have to be added
69
+ // here, in the open, where `nova-glass.spec.ts` counts them.
70
+ @mixin _backdrop($tier) {
71
+ @if $tier == chrome {
72
+ -webkit-backdrop-filter: blur(36px) saturate(180%);
73
+ backdrop-filter: blur(36px) saturate(180%);
74
+ } @else if $tier == panel {
75
+ -webkit-backdrop-filter: blur(72px) saturate(180%);
76
+ backdrop-filter: blur(72px) saturate(180%);
77
+ } @else if $tier == thin {
78
+ // The ratified quartet value: blur(16px) saturate(180%) brightness(.95).
79
+ -webkit-backdrop-filter: var(--glass2-blur);
80
+ backdrop-filter: var(--glass2-blur);
81
+ } @else if $tier == window {
82
+ // Ratified from the window's own recipe (`window` mixin below) — 50px, one
83
+ // step heavier than chrome, because it is the single largest glass surface
84
+ // in the shell and carries the richer specular/edge treatment alongside it.
85
+ -webkit-backdrop-filter: blur(50px) saturate(180%);
86
+ backdrop-filter: blur(50px) saturate(180%);
87
+ } @else {
88
+ @error 'nova-glass: unknown blur tier `#{$tier}`. The four sanctioned tiers are `chrome` (36px), `panel` (72px), `thin` (16px), `window` (50px) — skill §4 says do not invent a fifth.';
89
+ }
90
+ }
91
+
92
+ // The surface-context seam (skill §2, §10).
93
+ // A caller DECLARES which surface it is and gets the right fill — this replaced
94
+ // the mock's fragile `#dc-root > … > section` structural override. The seam did
95
+ // its job on 2026-08-17: UX ruled the light `--glass2-bg` carrying the dark
96
+ // near-opaque fill was a canon-export BUG, and the one-value fix landed in
97
+ // `_nova-tokens.scss` (light fill = the light popover hue at 95%). `chrome`
98
+ // inherited it with no change here, exactly as designed.
99
+
100
+ // `chrome` is the canon's popover fill (skill §4) and — since the ruling — is
101
+ // legible in both themes, so panel-class floating surfaces (`fly-filter-panel`,
102
+ // `fly-drawer`, `fly-modal`, `fly-confirm-dialog`) sit on it, paired with
103
+ // `$nested: true` because they render INSIDE a window (a backdrop root — hazard
104
+ // 3 above — where a translucent fill + blur reads as flat mud; the dark-mode
105
+ // drift review of 2026-08-17 was exactly that mud being reported). The S1-era
106
+ // warning about `chrome` under a light floating MENU (95% black ink on a dark
107
+ // #38363C plate at 1.66–1.99:1) described the pre-ruling value and is history;
108
+ // menus stay on `menu` — its dark stops are the AA-measured alpha-1 pair, and
109
+ // IN-WINDOW menus add `$nested: true`, which swaps to the solid twin pair so
110
+ // light keeps its opacity where the blur is gone (the branch below).
111
+ @function _fill($surface, $nested: false) {
112
+ @if $surface == chrome {
113
+ @return var(--glass2-bg);
114
+ }
115
+
116
+ // `panel`, `menu` and `agent` keep the quartet's two upper layers (bottom
117
+ // radial glow + top sheen, both composed from ramp steps) and swap ONLY the
118
+ // fill layer.
119
+ $glow: radial-gradient(70% 60% at 50% 100%, var(--w07), transparent 70%);
120
+ $sheen: linear-gradient(180deg, var(--w08), transparent 20%);
121
+
122
+ @if $surface == panel {
123
+ // Theme-aware translucency — the fill the light theme's `--mat-panel` was
124
+ // picked for and, today, the only place those values are used at all.
125
+ @return $glow, $sheen, linear-gradient(var(--mat-panel), var(--mat-panel));
126
+ } @else if $surface == menu {
127
+ // The design's own MENU material, ratified in both themes (skill §2) and
128
+ // consumed by nothing until the S1 review fixes — `_nova-tokens.scss` says
129
+ // as much in its ⚠, where the unused `--mat-menu-*` values are listed as
130
+ // collateral of the light `--glass2-bg` anomaly. A two-stop vertical
131
+ // gradient because the pair IS a gradient (`-a` top, `-b` bottom), the same
132
+ // shape `--mat-tip-a`/`-b` carry for tooltips.
133
+
134
+ // This branch exists because a floating menu is neither chrome nor a panel.
135
+ // `chrome` is unreadable in light theme (above). `panel` fixes light but is
136
+ // translucent in BOTH themes, so it inherits its backdrop: a dark-theme menu
137
+ // over a light wallpaper falls to 3.23:1 — AA failure for primary ink on the
138
+ // three most-used overlays in the system. `--mat-menu-*` is ALPHA-1 in dark,
139
+ // so this fill does not vary with the wallpaper there at all (14.85:1), and
140
+ // light lands at 17.44:1. Measured table:
141
+ // `.workflow/plans/ux-refresh/notes/S1-review-fixes-surfaces.md`.
142
+
143
+ // Nested (in-window) menus take the SOLID twin pair: light's 58/64% stops
144
+ // lean on the thin blur for separation, and a nested surface has none (the
145
+ // window is a backdrop root), so the translucent pair inside a window read
146
+ // washed over the app's own content — the light half of the hazard the
147
+ // 2026-08-17 review fixed for dark. Alpha-1 in both themes; dark's twins are
148
+ // byte-identical to the base pair, so this changes nothing there.
149
+ @if $nested {
150
+ @return $glow, $sheen,
151
+ linear-gradient(180deg, var(--mat-menu-solid-a), var(--mat-menu-solid-b));
152
+ }
153
+
154
+ @return $glow, $sheen, linear-gradient(180deg, var(--mat-menu-a), var(--mat-menu-b));
155
+ } @else if $surface == agent {
156
+ // The agent aside's own fill — the panel hue on a slightly wider alpha ramp,
157
+ // top-to-bottom. Same two-stop shape as `menu`, for the same reason: the pair
158
+ // IS a gradient.
159
+
160
+ // This used to be the literal `rgb(56 54 60 / 52%) → 65%`, described as "a
161
+ // ratified literal, allow-listed in the spec". That colour is byte-identical to
162
+ // the DARK `--mat-panel`, so the aside painted the dark panel in BOTH themes and
163
+ // was the one surface here that could not flip — while `panel` and `menu` right
164
+ // above it both resolved through theme-aware tokens. The dark values are carried
165
+ // over unchanged, so this is a light-theme fix with no dark-theme delta.
166
+ @return $glow, $sheen, linear-gradient(180deg, var(--mat-agent-a), var(--mat-agent-b));
167
+ }
168
+
169
+ @error 'nova-glass: unknown surface `#{$surface}`. Declare one of `chrome` (ratified --glass2-bg fill; the canon default, but unreadable under a light-theme FLOATING menu — see the note on the branch), `menu` (--mat-menu-a/-b, the design\'s menu material, for menus/listboxes/action menus), `panel` (--mat-panel fill, for a surface inside the content panel), `agent` (--mat-agent-a/-b, the agent aside\'s own fill).';
170
+ }
171
+
172
+ // ── The degradation ladder (skill §4 — non-negotiable) ───────────────────────
173
+ // Every public recipe ends with this. A component must never have to remember an
174
+ // accessibility fallback, so the ladder ships WITH the glass or the glass does
175
+ // not ship. `nova-glass.spec.ts` fails the build if a recipe is added without
176
+ // it — that is the rule that stops the ladder rotting as recipes multiply.
177
+ // The reduced-transparency flat fills are theme-dependent (`#2c2c2e` dark /
178
+ // `#eef2f8` light) and are resolved with `light-dark()`, NOT a theme selector.
179
+ // `html.light-theme` / `html.dark-theme` each set `color-scheme` (see
180
+ // `_theme-light.scss` / `_theme-dark.scss`, and `native-select.spec.ts` which
181
+ // forbids re-pinning it locally), so `light-dark()` follows the APP theme rather
182
+ // than the OS. It is also the only mechanism that survives Angular's emulated
183
+ // encapsulation: `html.dark-theme &` compiles to `html.dark-theme[_ngcontent-x]`
184
+ // and is dead, and a component-local theme branch is banned outright (skill §9
185
+ // rule 4). One value, no selector, correct in both themes.
186
+ @mixin _degrade {
187
+ // Transparency off: a flat, fully opaque plate. Killing only the blur would
188
+ // leave the translucent fill, which is the thing that hurts.
189
+ @media (prefers-reduced-transparency: reduce) {
190
+ & {
191
+ border-color: transparent;
192
+ background-color: light-dark(#eef2f8, #2c2c2e);
193
+ background-image: none;
194
+ box-shadow: none;
195
+ -webkit-backdrop-filter: none;
196
+ backdrop-filter: none;
197
+ }
198
+
199
+ // The refractive rim and specular sheen ARE the transparency effect.
200
+ &::before,
201
+ &::after {
202
+ display: none;
203
+ }
204
+ }
205
+
206
+ // The glass hairline is decorative-contrast at best (`--w18` measures 1.79:1
207
+ // against the panel — D3 §contrast). This is the ratified bump.
208
+ @media (prefers-contrast: more) {
209
+ & {
210
+ border-color: var(--w6);
211
+ }
212
+ }
213
+
214
+ // The sheen is the only animation this file starts, and `::after` is the only
215
+ // pseudo it animates. `_nova-motion.scss` already zeroes every animation
216
+ // globally under this query; this block makes the recipe self-sufficient for
217
+ // any consumer that pulls the mixin without the motion partial.
218
+ @media (prefers-reduced-motion: reduce) {
219
+ &::after {
220
+ animation: none;
221
+ }
222
+ }
223
+ }
224
+ // stylelint-enable scss/at-mixin-pattern, scss/at-function-pattern
225
+
226
+ // ── Popover composite ────────────────────────────────────────────────────────
227
+ // Menus, listboxes, dialogs, drawers, filter panels — the four-token quartet
228
+ // (skill §4). Thin blur tier, always: a popover's tier is a property of the
229
+ // recipe, not a caller's choice.
230
+
231
+ // $shadow `menu` (default) — menus/listboxes/dialogs/content-panel/agent-aside
232
+ // `drawer` — the directional, direction-AWARE drawer shadow
233
+ // $surface `chrome` (default) / `menu` / `panel` / `agent` — see `_fill()`.
234
+ // A FLOATING MENU takes `menu`, not the default: `chrome` is the
235
+ // ratified quartet fill and carries the dark plate in BOTH themes.
236
+ // $nested `true` when this surface sits INSIDE another glass surface. Drops
237
+ // `backdrop-filter` entirely, because a nested backdrop root samples
238
+ // its parent instead of the wallpaper and reads as flat mud (hazard 3
239
+ // in the header). The fill must then carry the surface on its own:
240
+ // `chrome` is near-opaque already, and `menu` swaps to its solid
241
+ // alpha-1 twins (`--mat-menu-solid-a/-b`) — which is why `$nested`
242
+ // with the genuinely translucent `panel`/`agent` fills is refused
243
+ // rather than silently rendered see-through.
244
+ @mixin popover($shadow: menu, $surface: chrome, $nested: false) {
245
+ @if $nested and $surface != chrome and $surface != menu {
246
+ @error 'nova-glass: popover($nested: true) needs a near-opaque fill — a nested surface has no backdrop-filter to separate it from the glass beneath, so the translucent `#{$surface}` fill renders see-through. Nestable surfaces are `chrome` (the 95% quartet plate) and `menu` (swaps to the solid --mat-menu-solid-a/-b twins). Drop $surface, or drop $nested and accept the muddy double-blur.';
247
+ }
248
+
249
+ border: 1px solid var(--glass2-border);
250
+ background-image: _fill($surface, $nested);
251
+
252
+ @if $shadow == menu {
253
+ box-shadow:
254
+ 0 16px 40px rgb(0 0 0 / 20%),
255
+ 0 4px 10px rgb(0 0 0 / 10%),
256
+ var(--glass2-inset);
257
+ } @else if $shadow == drawer {
258
+ // A drawer's shadow travels AWAY from the edge it is anchored to, so it
259
+ // mirrors under RTL. `box-shadow` has no logical-offset form, so the offset
260
+ // rides a custom property flipped by `:dir(rtl)` — a compound on the element
261
+ // ITSELF, which is why it survives emulated encapsulation where the
262
+ // `[dir='rtl'] &` descendant combinator is dead (skill §9 rule 1). On an
263
+ // engine without `:dir()` the rule drops whole and the shadow stays on the
264
+ // LTR side: cosmetic, never a layout break.
265
+ --nova-glass-drawer-shadow-x: -30px;
266
+
267
+ box-shadow:
268
+ var(--nova-glass-drawer-shadow-x) 0 70px rgb(0 0 0 / 34%),
269
+ 0 4px 10px rgb(0 0 0 / 10%),
270
+ var(--glass2-inset);
271
+ } @else {
272
+ @error 'nova-glass: unknown $shadow `#{$shadow}`. Use `menu` (menus/listboxes/dialogs/content panel/agent aside) or `drawer`.';
273
+ }
274
+
275
+ @if not $nested {
276
+ @include _backdrop(thin);
277
+ }
278
+
279
+ @if $shadow == drawer {
280
+ &:dir(rtl) {
281
+ --nova-glass-drawer-shadow-x: 30px;
282
+ }
283
+ }
284
+
285
+ @include _degrade;
286
+ }
287
+
288
+ // ── Chrome ───────────────────────────────────────────────────────────────────
289
+ // Header + nav rail. Explicitly NOT the quartet (skill §4): a flat `--mat-rail`
290
+ // fill, a `--w16` hairline, its own three-part shadow, and the 36px tier.
291
+ // `--mat-header` exists and is deliberately unused here — the design's own
292
+ // header draws `--mat-rail` too, and the skill says not to "clean that up".
293
+ // Note the inset step is `--w2` (0.20), NOT `--w22` (0.22) as the quartet's
294
+ // `--glass2-inset` uses. The digits are the decimal fraction (skill §3.3) and
295
+ // these are two different ratified steps, not a typo.
296
+ @mixin chrome {
297
+ border: 1px solid var(--w16);
298
+ background: var(--mat-rail);
299
+ box-shadow:
300
+ 0 6px 16px rgb(0 0 0 / 16%),
301
+ 0 1px 4px rgb(0 0 0 / 10%),
302
+ inset 0 0.5px 0 var(--w2);
303
+
304
+ @include _backdrop(chrome);
305
+ @include _degrade;
306
+ }
307
+
308
+ // ── Window ───────────────────────────────────────────────────────────────────
309
+ // The shared window plate (added when the one-glass-family ruling of
310
+ // 2026-08-17 was superseded — skill §2/§4 carry the dated note). Originally a
311
+ // bespoke, dark-theme-only material built by the Circles External App for its
312
+ // own embedded window (conic-gradient specular rim, a layered gradient fill,
313
+ // a distinct top/bottom edge split); promoted here as the platform default for
314
+ // every window, in both themes, so it lives in ONE recipe instead of being
315
+ // re-invented per app.
316
+ // Consumes `--window-bg` / `--window-border` / `--window-shadow`
317
+ // (`_theme-light.scss` / `_theme-dark.scss`, "Window chrome" section) — the
318
+ // SAME names the pre-existing window family always used, not a parallel set:
319
+ // `contextual-help-drawer`, `agent-overlay` and `agent-input` all deliberately
320
+ // borrow those three tokens to read as extensions of the window's own glass,
321
+ // so keeping the names carries the new recipe to them for free. Only
322
+ // `--window-bg` (the fill) diverges per theme; border/shadow are one value in
323
+ // both, exactly like before. The edge/rim treatment is new — a genuinely new
324
+ // concept, so it gets new token names (`--window-edge-*`) nothing else reads.
325
+ // This mixin does no Sass-side theme branching, same shape as `chrome`/`panel`
326
+ // above; blur/saturate are a Sass-level literal via `_backdrop(window)`, same
327
+ // as every other recipe here — `.window` no longer spends its own token pair
328
+ // for them (retired; nothing else read those two names either).
329
+ // Radius is deliberately NOT set here — `_window-shell.scss` keeps its own
330
+ // `border-radius: var(--shell-window-radius)`. Glass material and window
331
+ // corner geometry are separable; a 46px radius was tried once before (see that
332
+ // file's own header comment) and reverted as a footgun, independent of this
333
+ // mixin entirely.
334
+ @mixin window {
335
+ border: 1px solid var(--window-border);
336
+ background-image: var(--window-bg);
337
+ box-shadow: var(--window-shadow);
338
+
339
+ @include _backdrop(window);
340
+
341
+ // Refractive rim — the conic specular stroke. Same masked-ring technique
342
+ // `material()` uses for its own rim (hazard 2 in the header: the
343
+ // `backdrop-filter` above already makes this element a containing block, so
344
+ // the pseudo resolves with no `position: relative` of its own needed here).
345
+ &::before {
346
+ content: '';
347
+ position: absolute;
348
+ z-index: 0;
349
+ inset: 0;
350
+ padding: 1px;
351
+ border-radius: inherit;
352
+ background: var(--window-edge-gradient);
353
+ pointer-events: none;
354
+ -webkit-mask:
355
+ linear-gradient(#000 0 0) content-box,
356
+ linear-gradient(#000 0 0);
357
+ mask:
358
+ linear-gradient(#000 0 0) content-box,
359
+ linear-gradient(#000 0 0);
360
+ -webkit-mask-composite: xor;
361
+ mask-composite: exclude;
362
+ }
363
+
364
+ // Top/bottom edge split — catches light along the top, grounds along the
365
+ // bottom, painted above content (z-index 3) so it reads at the outer 1px
366
+ // edge regardless of what the app renders underneath.
367
+ &::after {
368
+ content: '';
369
+ position: absolute;
370
+ z-index: 3;
371
+ inset: 0;
372
+ border-radius: inherit;
373
+ box-shadow:
374
+ inset 0 0.5px 0 var(--window-edge-top),
375
+ inset 0 -0.5px 0 var(--window-edge-bottom);
376
+ pointer-events: none;
377
+ }
378
+
379
+ @include _degrade;
380
+ }
381
+
382
+ // ── Content panel ────────────────────────────────────────────────────────────
383
+ // The surface holding app content — the strongest blur in the language (72px),
384
+ // on the quartet's layer stack with the `--mat-panel` fill.
385
+ @mixin panel {
386
+ border: 1px solid var(--w18);
387
+ background-image: _fill(panel);
388
+ box-shadow:
389
+ 0 16px 40px rgb(0 0 0 / 20%),
390
+ 0 4px 10px rgb(0 0 0 / 10%),
391
+ var(--glass2-inset);
392
+
393
+ @include _backdrop(panel);
394
+ @include _degrade;
395
+ }
396
+
397
+ // ── Content blur ─────────────────────────────────────────────────────────────
398
+ // The one sanctioned way for a surface INSIDE a window to blur the app's OWN
399
+ // content: scroll-edge toolbars, side panes, drawers over the app's canvas, and
400
+ // floating tool plates that keep the app's documented fill (chats'
401
+ // `--glass-bg-elevated` floaters are the reference consumers). It emits ONLY the
402
+ // thin blur tier plus the degradation ladder — no fill, border or shadow — so an
403
+ // app's measured, RULE-A-tracked plate/ink pairing survives the migration off
404
+ // raw `backdrop-filter` untouched.
405
+
406
+ // What this is NOT:
407
+ // · not a menu/listbox recipe — a true menu takes `popover($surface: menu)`,
408
+ // the material `fly-context-menu`/`fly-select` already ship;
409
+ // · not a way to reach the wallpaper. A window is a backdrop root (hazard 3 in
410
+ // the header), so inside one this blur samples the window's interior paint —
411
+ // the app content behind the surface. That IS the contract here, which is
412
+ // why this recipe is safe where a translucent quartet fill would read muddy.
413
+
414
+ // Include it AFTER the caller's own paint declarations: the ladder's
415
+ // reduced-transparency flat must be able to override the caller's translucent
416
+ // fill, and within one specificity tier that is decided by source order.
417
+ @mixin content-blur {
418
+ @include _backdrop(thin);
419
+ @include _degrade;
420
+ }
421
+
422
+ // ── Light "vibrancy" material — RETIRED IN PLACE, no production callers ──────
423
+ // DO NOT ADOPT THIS. S6.-1 reversed the ruling that motivated it: the light
424
+ // theme is conventional dark-ink-on-pale-plate, and the white-ink-on-smoke
425
+ // vibrancy model is gone. `window/_nova-vibrancy.scss` — which this docblock
426
+ // used to name as the live precedent for how to include it — WAS DELETED, along
427
+ // with its `styles.scss` include. The only remaining callers anywhere are the
428
+ // design-lab `glass-recipes-panel` exhibit and this file's own spec.
429
+ // ---
430
+ // It is kept rather than deleted at 2.0.0 because removing it is not the
431
+ // one-line edit it looks like, and none of what it costs is worth buying here:
432
+ // · `glassSheen` is a RATIFIED motion-canon keyframe (`nova-motion.spec.ts`)
433
+ // with no other consumer, so deleting the material either strands a canon
434
+ // animation or drags a second ratified removal along with it.
435
+ // · The design lab's reduced-motion simulation uses this material as its only
436
+ // vehicle — the sheen is the one thing on any recipe that reduced-motion has
437
+ // to switch off — so it would need re-vehicling, not just deleting.
438
+ // · ds-compat does NOT extract Sass mixins (it covers TS exports, custom
439
+ // properties, `@layer` names, peer floors and, since S6.1c, CSS classes), so
440
+ // removing a published mixin is a SILENT break with no gate — the same blind
441
+ // spot S6.1c closed for classes, still open for this surface.
442
+ // Leaving it costs nothing behavioural: it is unreachable by accident (below),
443
+ // and no shipped stylesheet includes it.
444
+ // ---
445
+ // It must stay hard to reach by accident. Two locks, both mechanical:
446
+ // · `$surface` has NO DEFAULT and is checked against a closed six-name list of
447
+ // REAL shell surfaces. `@include glass.material()` is a compile error, and
448
+ // there is no honest value for a feature-app component to pass.
449
+ // · The light/dark gate is the CALLER's selector scope, and the only scope
450
+ // where a theme gate actually works is a GLOBAL stylesheet:
451
+ // html.light-theme .fly-header { @include glass.material($surface: header); }
452
+ // Including this from a component stylesheet is a review-blocker: under
453
+ // emulated encapsulation `html.light-theme &` is dead (skill §9 rule 1), so
454
+ // the material would apply in DARK theme too and paint white gradients over
455
+ // dark chrome.
456
+ // The variant follows from the surface; a caller cannot mismatch them. The four
457
+ // `regular`/`chrome` names are the design's four `[data-material]` surfaces; the
458
+ // two `thin` names are the light thin controls §4 lists alongside them.
459
+ @mixin material($surface) {
460
+ $variant: null;
461
+
462
+ @if $surface == header or $surface == rail {
463
+ $variant: chrome;
464
+ } @else if $surface == content-panel or $surface == agent-aside {
465
+ $variant: regular;
466
+ } @else if $surface == toolbar or $surface == tablist {
467
+ $variant: thin;
468
+ } @else {
469
+ @error 'nova-glass: material($surface: #{$surface}) is not a shell surface. This layer is opt-in for shell chrome ONLY — pass `header`, `rail`, `content-panel`, `agent-aside` (the four [data-material] surfaces), or `toolbar` / `tablist` (the thin light controls). A feature-app component must not apply this layer at all; use popover() / panel() instead.';
470
+ }
471
+
472
+ // The rim and sheen are `z-index: -1` pseudo-elements: they paint above this
473
+ // element's own background but below its content. `isolation` keeps them from
474
+ // falling through to an ancestor's background. No `position` is set on
475
+ // purpose — the `backdrop-filter` below already makes this element a
476
+ // containing block for absolutely positioned descendants (hazard 2 in the
477
+ // header, load-bearing exactly here), so forcing `position: relative` would
478
+ // buy nothing and would silently break a `fixed` or `sticky` caller.
479
+ isolation: isolate;
480
+ border-color: transparent;
481
+
482
+ @if $variant == regular {
483
+ background-image: linear-gradient(
484
+ 180deg,
485
+ rgb(255 255 255 / 50%),
486
+ rgb(255 255 255 / 10%) 46%,
487
+ rgb(255 255 255 / 22%) 100%
488
+ );
489
+ box-shadow:
490
+ inset 0 0.5px 0 rgb(255 255 255 / 70%),
491
+ inset 0 -0.5px 0 rgb(148 170 200 / 20%),
492
+ inset 0 0 26px rgb(255 255 255 / 16%),
493
+ 0 10px 26px rgb(71 90 120 / 8%),
494
+ 0 2px 5px rgb(71 90 120 / 5%);
495
+ -webkit-backdrop-filter: blur(34px) saturate(165%) brightness(1.04);
496
+ backdrop-filter: blur(34px) saturate(165%) brightness(1.04);
497
+ } @else if $variant == chrome {
498
+ background-image: linear-gradient(
499
+ 168deg,
500
+ rgb(255 255 255 / 72%) 0%,
501
+ rgb(248 251 255 / 36%) 34%,
502
+ rgb(232 240 252 / 24%) 72%,
503
+ rgb(255 255 255 / 46%) 100%
504
+ );
505
+ box-shadow:
506
+ inset 0 1px 0 rgb(255 255 255 / 98%),
507
+ inset 0 -0.5px 0 rgb(120 145 180 / 22%),
508
+ inset 0 0 0 0.5px rgb(255 255 255 / 50%),
509
+ 0 8px 22px rgb(60 80 115 / 10%),
510
+ 0 2px 5px rgb(60 80 115 / 6%);
511
+ -webkit-backdrop-filter: blur(30px) saturate(180%) brightness(1.07);
512
+ backdrop-filter: blur(30px) saturate(180%) brightness(1.07);
513
+ } @else {
514
+ background-image: linear-gradient(180deg, rgb(255 255 255 / 66%), rgb(240 246 255 / 24%));
515
+ box-shadow:
516
+ inset 0 0.5px 0 rgb(255 255 255 / 80%),
517
+ inset 0 -0.5px 0 rgb(148 170 200 / 16%),
518
+ inset 0 0 0 0.5px rgb(255 255 255 / 34%),
519
+ 0 1px 3px rgb(71 90 120 / 5%);
520
+ -webkit-backdrop-filter: blur(16px) saturate(160%) brightness(1.05);
521
+ backdrop-filter: blur(16px) saturate(160%) brightness(1.05);
522
+ }
523
+
524
+ // Refractive rim — light bending through the edge of the glass. A 1px padding
525
+ // box masked against its own content box leaves only the ring.
526
+ &::before {
527
+ content: '';
528
+ position: absolute;
529
+ z-index: -1;
530
+ inset: 0;
531
+ padding: 1px;
532
+ border-radius: inherit;
533
+ background: linear-gradient(
534
+ 140deg,
535
+ rgb(255 255 255 / 60%),
536
+ rgb(255 255 255 / 8%) 34%,
537
+ rgb(190 210 240 / 16%) 64%,
538
+ rgb(255 255 255 / 42%)
539
+ );
540
+ pointer-events: none;
541
+ -webkit-mask:
542
+ linear-gradient(#000 0 0) content-box,
543
+ linear-gradient(#000 0 0);
544
+ mask:
545
+ linear-gradient(#000 0 0) content-box,
546
+ linear-gradient(#000 0 0);
547
+ -webkit-mask-composite: xor;
548
+ mask-composite: exclude;
549
+ }
550
+
551
+ // Specular sheen drifting across the surface. `background-size` is
552
+ // load-bearing, not decoration: `glassSheen` animates background-POSITION, and
553
+ // a background no wider than its box has nowhere to travel — at 100% the
554
+ // animation runs and nothing moves.
555
+ &::after {
556
+ content: '';
557
+ position: absolute;
558
+ z-index: -1;
559
+ inset: 0;
560
+ border-radius: inherit;
561
+ background: linear-gradient(
562
+ 100deg,
563
+ transparent 22%,
564
+ rgb(255 255 255 / 22%) 46%,
565
+ rgb(255 255 255 / 7%) 54%,
566
+ transparent 76%
567
+ );
568
+ background-size: 260% 100%;
569
+ opacity: 0.5;
570
+ animation: glassSheen 22s ease-in-out infinite;
571
+ pointer-events: none;
572
+ }
573
+
574
+ @include _degrade;
575
+ }