@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,451 @@
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; }
13
+ // .fly-drawer { @include glass.popover($shadow: drawer, $surface: panel); }
14
+ // .app-panel { @include glass.panel; }
15
+ // .shell-header { @include glass.chrome; }
16
+ // Note the argumentless form has no parentheses — stylelint's
17
+ // `scss/at-mixin-argumentless-call-parentheses` rejects `popover()`.
18
+
19
+ // Members are namespaced rather than `nova-`-prefixed (the sibling
20
+ // `_nova-motion.scss` prefixes because `@keyframes` names are GLOBAL and can
21
+ // genuinely collide; Sass mixin names are module-scoped and cannot). Read the
22
+ // call site, not the definition: `glass.popover` already says everything
23
+ // `nova-glass-popover` would, and the namespace is what a reviewer sees.
24
+
25
+ // ── The one rule this file exists to enforce ─────────────────────────────────
26
+ // NEVER hand-compose glass (skill §9 rule 10). Per-component gradient stacks are
27
+ // the drift this program exists to kill. Every sanctioned surface is one of the
28
+ // four public mixins below; there is deliberately NO public way to pass a raw
29
+ // blur radius, a raw shadow, or a fill of your own.
30
+
31
+ // ── backdrop-filter is not a paint-only property (READ THIS) ─────────────────
32
+ // A non-`none` `backdrop-filter` does three structural things to the element,
33
+ // only the first of which is obvious:
34
+ // 1. it creates a STACKING CONTEXT (hence the shell's z-ladder: the header
35
+ // must out-rank content, skill §6);
36
+ // 2. it makes the element a CONTAINING BLOCK for every descendant, including
37
+ // `position: fixed` ones. A descendant that uses `fixed` to escape to the
38
+ // viewport is instead trapped inside the glass surface, and any coordinates
39
+ // computed from `getBoundingClientRect()` land in the wrong place;
40
+ // 3. it makes the element a BACKDROP ROOT. A nested `backdrop-filter` then
41
+ // samples only what is painted INSIDE this element — not the wallpaper — so
42
+ // glass-inside-glass reads flat and muddy. (The shell already knew this:
43
+ // `window/_nova-vibrancy.scss` invented `--surface-overlay-strong` for
44
+ // exactly this case. `$nested: true` below is that knowledge, generalised.)
45
+
46
+ // (2) is load-bearing exactly once — the `material()` rim and sheen are absolute
47
+ // pseudo-elements that resolve against the glass surface without it needing
48
+ // `position: relative`. Everywhere else it is a hazard: see the handoff note
49
+ // `.workflow/plans/ux-refresh/notes/S1.2-glass-mixins.md` §"Containing block"
50
+ // for the full inventory of what may and may not be nested under glass.
51
+
52
+ // ── Vocabulary shared by the recipes ─────────────────────────────────────────
53
+ // Private (Sass makes a leading `_` member inaccessible from other modules), so
54
+ // no consumer can reach a half-recipe: the ONLY way to get `backdrop-filter` out
55
+ // of this file is through a public mixin, and every public mixin carries the
56
+ // full degradation ladder. That is the mechanism, not a convention.
57
+
58
+ // The leading underscore IS that mechanism — it is the only privacy marker Sass
59
+ // has — and it is what the kebab-case rules below reject. Scoped off for the
60
+ // three private members only, then back on for the public API.
61
+ // These are Sass line comments, NOT `/* */`: a CSS comment would survive into the
62
+ // compiled output, and this partial's whole contract is that it emits nothing.
63
+ // stylelint-disable scss/at-mixin-pattern, scss/at-function-pattern
64
+
65
+ // The three sanctioned blur tiers (skill §4) — chrome 36 / content panel 72 /
66
+ // thin 16. Selected BY NAME; a fourth tier would have to be added here, in the
67
+ // open, where `nova-glass.spec.ts` counts them.
68
+ @mixin _backdrop($tier) {
69
+ @if $tier == chrome {
70
+ -webkit-backdrop-filter: blur(36px) saturate(180%);
71
+ backdrop-filter: blur(36px) saturate(180%);
72
+ } @else if $tier == panel {
73
+ -webkit-backdrop-filter: blur(72px) saturate(180%);
74
+ backdrop-filter: blur(72px) saturate(180%);
75
+ } @else if $tier == thin {
76
+ // The ratified quartet value: blur(16px) saturate(180%) brightness(.95).
77
+ -webkit-backdrop-filter: var(--glass2-blur);
78
+ backdrop-filter: var(--glass2-blur);
79
+ } @else {
80
+ @error 'nova-glass: unknown blur tier `#{$tier}`. The three sanctioned tiers are `chrome` (36px), `panel` (72px), `thin` (16px) — skill §4 says do not invent a fourth.';
81
+ }
82
+ }
83
+
84
+ // The surface-context seam (skill §2 ⚠, §10).
85
+ // The ratified light `--glass2-bg` carries the DARK near-opaque fill, so a light
86
+ // popover anchored in the header renders as dark glass. In the mock only ONE
87
+ // region escaped that, via a `#dc-root > div > div > div:nth-child(2) > section`
88
+ // structural selector — which is exactly the fragile thing this argument
89
+ // replaces: a caller DECLARES which surface it is and gets the right fill.
90
+ // This is the seam the open UX ruling lands on. If UX rules the light fill is a
91
+ // bug, the fix is one value in `_nova-tokens.scss` and `chrome` inherits it with
92
+ // no change here. Nothing in this file second-guesses the ratified token.
93
+
94
+ // READ THIS BEFORE PICKING `chrome`. As of the S1 review fixes NO shipping
95
+ // component passes it: `fly-context-menu`, `fly-action-menu` and the
96
+ // `fly-select` listbox moved to `menu`, and `fly-drawer` / `fly-modal` /
97
+ // `fly-confirm-dialog` were already on `panel`. Only `/design-lab`'s Glass
98
+ // Recipes preview and `nova-glass.spec.ts` still compile it. It is kept as the
99
+ // DEFAULT on purpose — it is the canon's popover fill (skill §4) and the branch
100
+ // the light-`--glass2-bg` ruling lands on — so the next genuine chrome-anchored
101
+ // popover is the first caller that will feel that ruling, and it should. What it
102
+ // must NOT be used for again is a floating menu in light theme: that pairing put
103
+ // 95 % black ink on a near-opaque #38363C plate at 1.66–1.99:1.
104
+ @function _fill($surface) {
105
+ @if $surface == chrome {
106
+ @return var(--glass2-bg);
107
+ }
108
+
109
+ // `panel`, `menu` and `agent` keep the quartet's two upper layers (bottom
110
+ // radial glow + top sheen, both composed from ramp steps) and swap ONLY the
111
+ // fill layer.
112
+ $glow: radial-gradient(70% 60% at 50% 100%, var(--w07), transparent 70%);
113
+ $sheen: linear-gradient(180deg, var(--w08), transparent 20%);
114
+
115
+ @if $surface == panel {
116
+ // Theme-aware translucency — the fill the light theme's `--mat-panel` was
117
+ // picked for and, today, the only place those values are used at all.
118
+ @return $glow, $sheen, linear-gradient(var(--mat-panel), var(--mat-panel));
119
+ } @else if $surface == menu {
120
+ // The design's own MENU material, ratified in both themes (skill §2) and
121
+ // consumed by nothing until the S1 review fixes — `_nova-tokens.scss` says
122
+ // as much in its ⚠, where the unused `--mat-menu-*` values are listed as
123
+ // collateral of the light `--glass2-bg` anomaly. A two-stop vertical
124
+ // gradient because the pair IS a gradient (`-a` top, `-b` bottom), the same
125
+ // shape `--mat-tip-a`/`-b` carry for tooltips.
126
+
127
+ // This branch exists because a floating menu is neither chrome nor a panel.
128
+ // `chrome` is unreadable in light theme (above). `panel` fixes light but is
129
+ // translucent in BOTH themes, so it inherits its backdrop: a dark-theme menu
130
+ // over a light wallpaper falls to 3.23:1 — AA failure for primary ink on the
131
+ // three most-used overlays in the system. `--mat-menu-*` is ALPHA-1 in dark,
132
+ // so this fill does not vary with the wallpaper there at all (14.85:1), and
133
+ // light lands at 17.44:1. Measured table:
134
+ // `.workflow/plans/ux-refresh/notes/S1-review-fixes-surfaces.md`.
135
+ @return $glow, $sheen, linear-gradient(180deg, var(--mat-menu-a), var(--mat-menu-b));
136
+ } @else if $surface == agent {
137
+ // The agent aside's own fill — the panel hue on a slightly wider alpha ramp,
138
+ // top-to-bottom. Same two-stop shape as `menu`, for the same reason: the pair
139
+ // IS a gradient.
140
+
141
+ // This used to be the literal `rgb(56 54 60 / 52%) → 65%`, described as "a
142
+ // ratified literal, allow-listed in the spec". That colour is byte-identical to
143
+ // the DARK `--mat-panel`, so the aside painted the dark panel in BOTH themes and
144
+ // was the one surface here that could not flip — while `panel` and `menu` right
145
+ // above it both resolved through theme-aware tokens. The dark values are carried
146
+ // over unchanged, so this is a light-theme fix with no dark-theme delta.
147
+ @return $glow, $sheen, linear-gradient(180deg, var(--mat-agent-a), var(--mat-agent-b));
148
+ }
149
+
150
+ @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).';
151
+ }
152
+
153
+ // ── The degradation ladder (skill §4 — non-negotiable) ───────────────────────
154
+ // Every public recipe ends with this. A component must never have to remember an
155
+ // accessibility fallback, so the ladder ships WITH the glass or the glass does
156
+ // not ship. `nova-glass.spec.ts` fails the build if a recipe is added without
157
+ // it — that is the rule that stops the ladder rotting as recipes multiply.
158
+ // The reduced-transparency flat fills are theme-dependent (`#2c2c2e` dark /
159
+ // `#eef2f8` light) and are resolved with `light-dark()`, NOT a theme selector.
160
+ // `html.light-theme` / `html.dark-theme` each set `color-scheme` (see
161
+ // `_theme-light.scss` / `_theme-dark.scss`, and `native-select.spec.ts` which
162
+ // forbids re-pinning it locally), so `light-dark()` follows the APP theme rather
163
+ // than the OS. It is also the only mechanism that survives Angular's emulated
164
+ // encapsulation: `html.dark-theme &` compiles to `html.dark-theme[_ngcontent-x]`
165
+ // and is dead, and a component-local theme branch is banned outright (skill §9
166
+ // rule 4). One value, no selector, correct in both themes.
167
+ @mixin _degrade {
168
+ // Transparency off: a flat, fully opaque plate. Killing only the blur would
169
+ // leave the translucent fill, which is the thing that hurts.
170
+ @media (prefers-reduced-transparency: reduce) {
171
+ & {
172
+ border-color: transparent;
173
+ background-color: light-dark(#eef2f8, #2c2c2e);
174
+ background-image: none;
175
+ box-shadow: none;
176
+ -webkit-backdrop-filter: none;
177
+ backdrop-filter: none;
178
+ }
179
+
180
+ // The refractive rim and specular sheen ARE the transparency effect.
181
+ &::before,
182
+ &::after {
183
+ display: none;
184
+ }
185
+ }
186
+
187
+ // The glass hairline is decorative-contrast at best (`--w18` measures 1.79:1
188
+ // against the panel — D3 §contrast). This is the ratified bump.
189
+ @media (prefers-contrast: more) {
190
+ & {
191
+ border-color: var(--w6);
192
+ }
193
+ }
194
+
195
+ // The sheen is the only animation this file starts, and `::after` is the only
196
+ // pseudo it animates. `_nova-motion.scss` already zeroes every animation
197
+ // globally under this query; this block makes the recipe self-sufficient for
198
+ // any consumer that pulls the mixin without the motion partial.
199
+ @media (prefers-reduced-motion: reduce) {
200
+ &::after {
201
+ animation: none;
202
+ }
203
+ }
204
+ }
205
+ // stylelint-enable scss/at-mixin-pattern, scss/at-function-pattern
206
+
207
+ // ── Popover composite ────────────────────────────────────────────────────────
208
+ // Menus, listboxes, dialogs, drawers, filter panels — the four-token quartet
209
+ // (skill §4). Thin blur tier, always: a popover's tier is a property of the
210
+ // recipe, not a caller's choice.
211
+
212
+ // $shadow `menu` (default) — menus/listboxes/dialogs/content-panel/agent-aside
213
+ // `drawer` — the directional, direction-AWARE drawer shadow
214
+ // $surface `chrome` (default) / `menu` / `panel` / `agent` — see `_fill()`.
215
+ // A FLOATING MENU takes `menu`, not the default: `chrome` is the
216
+ // ratified quartet fill and carries the dark plate in BOTH themes.
217
+ // $nested `true` when this surface sits INSIDE another glass surface. Drops
218
+ // `backdrop-filter` entirely, because a nested backdrop root samples
219
+ // its parent instead of the wallpaper and reads as flat mud (hazard 3
220
+ // in the header). The near-opaque `chrome` fill then carries the
221
+ // surface on its own — which is why `$nested` and a translucent
222
+ // `$surface` are refused rather than silently rendered see-through.
223
+ @mixin popover($shadow: menu, $surface: chrome, $nested: false) {
224
+ @if $nested and $surface != chrome {
225
+ @error 'nova-glass: popover($nested: true) needs the near-opaque `chrome` fill — a nested surface has no backdrop-filter to separate it from the glass beneath, so a translucent `#{$surface}` fill renders see-through. Drop $surface, or drop $nested and accept the muddy double-blur.';
226
+ }
227
+
228
+ border: 1px solid var(--glass2-border);
229
+ background-image: _fill($surface);
230
+
231
+ @if $shadow == menu {
232
+ box-shadow:
233
+ 0 16px 40px rgb(0 0 0 / 20%),
234
+ 0 4px 10px rgb(0 0 0 / 10%),
235
+ var(--glass2-inset);
236
+ } @else if $shadow == drawer {
237
+ // A drawer's shadow travels AWAY from the edge it is anchored to, so it
238
+ // mirrors under RTL. `box-shadow` has no logical-offset form, so the offset
239
+ // rides a custom property flipped by `:dir(rtl)` — a compound on the element
240
+ // ITSELF, which is why it survives emulated encapsulation where the
241
+ // `[dir='rtl'] &` descendant combinator is dead (skill §9 rule 1). On an
242
+ // engine without `:dir()` the rule drops whole and the shadow stays on the
243
+ // LTR side: cosmetic, never a layout break.
244
+ --nova-glass-drawer-shadow-x: -30px;
245
+
246
+ box-shadow:
247
+ var(--nova-glass-drawer-shadow-x) 0 70px rgb(0 0 0 / 34%),
248
+ 0 4px 10px rgb(0 0 0 / 10%),
249
+ var(--glass2-inset);
250
+ } @else {
251
+ @error 'nova-glass: unknown $shadow `#{$shadow}`. Use `menu` (menus/listboxes/dialogs/content panel/agent aside) or `drawer`.';
252
+ }
253
+
254
+ @if not $nested {
255
+ @include _backdrop(thin);
256
+ }
257
+
258
+ @if $shadow == drawer {
259
+ &:dir(rtl) {
260
+ --nova-glass-drawer-shadow-x: 30px;
261
+ }
262
+ }
263
+
264
+ @include _degrade;
265
+ }
266
+
267
+ // ── Chrome ───────────────────────────────────────────────────────────────────
268
+ // Header + nav rail. Explicitly NOT the quartet (skill §4): a flat `--mat-rail`
269
+ // fill, a `--w16` hairline, its own three-part shadow, and the 36px tier.
270
+ // `--mat-header` exists and is deliberately unused here — the design's own
271
+ // header draws `--mat-rail` too, and the skill says not to "clean that up".
272
+ // Note the inset step is `--w2` (0.20), NOT `--w22` (0.22) as the quartet's
273
+ // `--glass2-inset` uses. The digits are the decimal fraction (skill §3.3) and
274
+ // these are two different ratified steps, not a typo.
275
+ @mixin chrome {
276
+ border: 1px solid var(--w16);
277
+ background: var(--mat-rail);
278
+ box-shadow:
279
+ 0 6px 16px rgb(0 0 0 / 16%),
280
+ 0 1px 4px rgb(0 0 0 / 10%),
281
+ inset 0 0.5px 0 var(--w2);
282
+
283
+ @include _backdrop(chrome);
284
+ @include _degrade;
285
+ }
286
+
287
+ // ── Content panel ────────────────────────────────────────────────────────────
288
+ // The surface holding app content — the strongest blur in the language (72px),
289
+ // on the quartet's layer stack with the `--mat-panel` fill.
290
+ @mixin panel {
291
+ border: 1px solid var(--w18);
292
+ background-image: _fill(panel);
293
+ box-shadow:
294
+ 0 16px 40px rgb(0 0 0 / 20%),
295
+ 0 4px 10px rgb(0 0 0 / 10%),
296
+ var(--glass2-inset);
297
+
298
+ @include _backdrop(panel);
299
+ @include _degrade;
300
+ }
301
+
302
+ // ── Light "vibrancy" material — RETIRED IN PLACE, no production callers ──────
303
+ // DO NOT ADOPT THIS. S6.-1 reversed the ruling that motivated it: the light
304
+ // theme is conventional dark-ink-on-pale-plate, and the white-ink-on-smoke
305
+ // vibrancy model is gone. `window/_nova-vibrancy.scss` — which this docblock
306
+ // used to name as the live precedent for how to include it — WAS DELETED, along
307
+ // with its `styles.scss` include. The only remaining callers anywhere are the
308
+ // design-lab `glass-recipes-panel` exhibit and this file's own spec.
309
+ // ---
310
+ // It is kept rather than deleted at 2.0.0 because removing it is not the
311
+ // one-line edit it looks like, and none of what it costs is worth buying here:
312
+ // · `glassSheen` is a RATIFIED motion-canon keyframe (`nova-motion.spec.ts`)
313
+ // with no other consumer, so deleting the material either strands a canon
314
+ // animation or drags a second ratified removal along with it.
315
+ // · The design lab's reduced-motion simulation uses this material as its only
316
+ // vehicle — the sheen is the one thing on any recipe that reduced-motion has
317
+ // to switch off — so it would need re-vehicling, not just deleting.
318
+ // · ds-compat does NOT extract Sass mixins (it covers TS exports, custom
319
+ // properties, `@layer` names, peer floors and, since S6.1c, CSS classes), so
320
+ // removing a published mixin is a SILENT break with no gate — the same blind
321
+ // spot S6.1c closed for classes, still open for this surface.
322
+ // Leaving it costs nothing behavioural: it is unreachable by accident (below),
323
+ // and no shipped stylesheet includes it.
324
+ // ---
325
+ // It must stay hard to reach by accident. Two locks, both mechanical:
326
+ // · `$surface` has NO DEFAULT and is checked against a closed six-name list of
327
+ // REAL shell surfaces. `@include glass.material()` is a compile error, and
328
+ // there is no honest value for a feature-app component to pass.
329
+ // · The light/dark gate is the CALLER's selector scope, and the only scope
330
+ // where a theme gate actually works is a GLOBAL stylesheet:
331
+ // html.light-theme .fly-header { @include glass.material($surface: header); }
332
+ // Including this from a component stylesheet is a review-blocker: under
333
+ // emulated encapsulation `html.light-theme &` is dead (skill §9 rule 1), so
334
+ // the material would apply in DARK theme too and paint white gradients over
335
+ // dark chrome.
336
+ // The variant follows from the surface; a caller cannot mismatch them. The four
337
+ // `regular`/`chrome` names are the design's four `[data-material]` surfaces; the
338
+ // two `thin` names are the light thin controls §4 lists alongside them.
339
+ @mixin material($surface) {
340
+ $variant: null;
341
+
342
+ @if $surface == header or $surface == rail {
343
+ $variant: chrome;
344
+ } @else if $surface == content-panel or $surface == agent-aside {
345
+ $variant: regular;
346
+ } @else if $surface == toolbar or $surface == tablist {
347
+ $variant: thin;
348
+ } @else {
349
+ @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.';
350
+ }
351
+
352
+ // The rim and sheen are `z-index: -1` pseudo-elements: they paint above this
353
+ // element's own background but below its content. `isolation` keeps them from
354
+ // falling through to an ancestor's background. No `position` is set on
355
+ // purpose — the `backdrop-filter` below already makes this element a
356
+ // containing block for absolutely positioned descendants (hazard 2 in the
357
+ // header, load-bearing exactly here), so forcing `position: relative` would
358
+ // buy nothing and would silently break a `fixed` or `sticky` caller.
359
+ isolation: isolate;
360
+ border-color: transparent;
361
+
362
+ @if $variant == regular {
363
+ background-image: linear-gradient(
364
+ 180deg,
365
+ rgb(255 255 255 / 50%),
366
+ rgb(255 255 255 / 10%) 46%,
367
+ rgb(255 255 255 / 22%) 100%
368
+ );
369
+ box-shadow:
370
+ inset 0 0.5px 0 rgb(255 255 255 / 70%),
371
+ inset 0 -0.5px 0 rgb(148 170 200 / 20%),
372
+ inset 0 0 26px rgb(255 255 255 / 16%),
373
+ 0 10px 26px rgb(71 90 120 / 8%),
374
+ 0 2px 5px rgb(71 90 120 / 5%);
375
+ -webkit-backdrop-filter: blur(34px) saturate(165%) brightness(1.04);
376
+ backdrop-filter: blur(34px) saturate(165%) brightness(1.04);
377
+ } @else if $variant == chrome {
378
+ background-image: linear-gradient(
379
+ 168deg,
380
+ rgb(255 255 255 / 72%) 0%,
381
+ rgb(248 251 255 / 36%) 34%,
382
+ rgb(232 240 252 / 24%) 72%,
383
+ rgb(255 255 255 / 46%) 100%
384
+ );
385
+ box-shadow:
386
+ inset 0 1px 0 rgb(255 255 255 / 98%),
387
+ inset 0 -0.5px 0 rgb(120 145 180 / 22%),
388
+ inset 0 0 0 0.5px rgb(255 255 255 / 50%),
389
+ 0 8px 22px rgb(60 80 115 / 10%),
390
+ 0 2px 5px rgb(60 80 115 / 6%);
391
+ -webkit-backdrop-filter: blur(30px) saturate(180%) brightness(1.07);
392
+ backdrop-filter: blur(30px) saturate(180%) brightness(1.07);
393
+ } @else {
394
+ background-image: linear-gradient(180deg, rgb(255 255 255 / 66%), rgb(240 246 255 / 24%));
395
+ box-shadow:
396
+ inset 0 0.5px 0 rgb(255 255 255 / 80%),
397
+ inset 0 -0.5px 0 rgb(148 170 200 / 16%),
398
+ inset 0 0 0 0.5px rgb(255 255 255 / 34%),
399
+ 0 1px 3px rgb(71 90 120 / 5%);
400
+ -webkit-backdrop-filter: blur(16px) saturate(160%) brightness(1.05);
401
+ backdrop-filter: blur(16px) saturate(160%) brightness(1.05);
402
+ }
403
+
404
+ // Refractive rim — light bending through the edge of the glass. A 1px padding
405
+ // box masked against its own content box leaves only the ring.
406
+ &::before {
407
+ content: '';
408
+ position: absolute;
409
+ z-index: -1;
410
+ inset: 0;
411
+ padding: 1px;
412
+ border-radius: inherit;
413
+ background: linear-gradient(
414
+ 140deg,
415
+ rgb(255 255 255 / 60%),
416
+ rgb(255 255 255 / 8%) 34%,
417
+ rgb(190 210 240 / 16%) 64%,
418
+ rgb(255 255 255 / 42%)
419
+ );
420
+ pointer-events: none;
421
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
422
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
423
+ -webkit-mask-composite: xor;
424
+ mask-composite: exclude;
425
+ }
426
+
427
+ // Specular sheen drifting across the surface. `background-size` is
428
+ // load-bearing, not decoration: `glassSheen` animates background-POSITION, and
429
+ // a background no wider than its box has nowhere to travel — at 100% the
430
+ // animation runs and nothing moves.
431
+ &::after {
432
+ content: '';
433
+ position: absolute;
434
+ z-index: -1;
435
+ inset: 0;
436
+ border-radius: inherit;
437
+ background: linear-gradient(
438
+ 100deg,
439
+ transparent 22%,
440
+ rgb(255 255 255 / 22%) 46%,
441
+ rgb(255 255 255 / 7%) 54%,
442
+ transparent 76%
443
+ );
444
+ background-size: 260% 100%;
445
+ opacity: 0.5;
446
+ animation: glassSheen 22s ease-in-out infinite;
447
+ pointer-events: none;
448
+ }
449
+
450
+ @include _degrade;
451
+ }
@@ -0,0 +1,42 @@
1
+ // ─── Nova motion — MIXINS ONLY ───────────────────────────────────────────────
2
+ // The companion to `_nova-motion.scss`, split out at the S1 review (F4).
3
+ // `_nova-motion.scss` is the EMISSION entry: `@use`-ing it writes the `:root`
4
+ // token block, the `[dir='rtl']` flip, all 16 `@keyframes` and the global
5
+ // `prefers-reduced-motion` block into whatever compiled it. Because every
6
+ // Angular component stylesheet compiles as its own Sass entry, that made the
7
+ // partial's one useful Sass-level construct — the `nova-stagger` mixin —
8
+ // unreachable from a component without paying for a second copy of ~200 lines
9
+ // of global CSS. The result was predictable and did happen: two callers
10
+ // hand-copied the mixin's nth-child math rather than `@use` it, and a third
11
+ // `@use`d it anyway and re-emitted everything.
12
+ // This file is the fix, in the shape `_nova-glass.scss` already proved: it
13
+ // declares mixins and emits NOTHING, so any component may `@use` it freely. It
14
+ // is the single home of the stagger math; `_nova-motion.scss` re-exports it via
15
+ // `@forward` so an existing `@use 'nova-motion'` caller keeps working.
16
+ // Referencing a keyframe NAME (`animation: itemIn …`) or a `var(--nova-*)` token
17
+ // needs no `@use` at all — both are plain global CSS, emitted once through
18
+ // `_fly-theme.scss`. Only the nth-child loop is a Sass construct, which is why
19
+ // it is the only thing here.
20
+
21
+ // ── Stagger utility ──────────────────────────────────────────────────────────
22
+ // Emits an `:nth-child` animation-delay chain so no component hand-writes one.
23
+ // $step matches the context: 40ms toolbar actions/items, 55ms cards, 20ms app
24
+ // tiles (skill §5). Capped at $max (default 12 — every shipped stagger set in
25
+ // the design is well under this) because emitting hundreds of nth-child rules
26
+ // for an unbounded list is worse than the duplication this mixin replaces. Items
27
+ // past the cap hold at the LAST step's delay rather than snapping to a bare 0,
28
+ // so a long list's tail doesn't visibly un-stagger.
29
+ // `:nth-child` counts ALL element siblings, not just the ones this selector
30
+ // matches — a caller must keep the staggered element's parent homogeneous, or
31
+ // insert non-staggered siblings only AFTER the last staggered one.
32
+ @mixin nova-stagger($step: 40ms, $max: 12) {
33
+ @for $i from 0 through ($max - 1) {
34
+ &:nth-child(#{$i + 1}) {
35
+ animation-delay: $i * $step;
36
+ }
37
+ }
38
+
39
+ &:nth-child(n + #{$max + 1}) {
40
+ animation-delay: ($max - 1) * $step;
41
+ }
42
+ }