@flyos/design-system 1.6.0 → 1.8.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,427 @@
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 keeps its own lighter fill (skill §2 ⚠). No token exists
138
+ // for this pair; it is a ratified literal, allow-listed in the spec.
139
+ @return $glow, $sheen, linear-gradient(rgb(56 54 60 / 52%), rgb(56 54 60 / 65%));
140
+ }
141
+
142
+ @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` (the agent aside\'s lighter fill).';
143
+ }
144
+
145
+ // ── The degradation ladder (skill §4 — non-negotiable) ───────────────────────
146
+ // Every public recipe ends with this. A component must never have to remember an
147
+ // accessibility fallback, so the ladder ships WITH the glass or the glass does
148
+ // not ship. `nova-glass.spec.ts` fails the build if a recipe is added without
149
+ // it — that is the rule that stops the ladder rotting as recipes multiply.
150
+ // The reduced-transparency flat fills are theme-dependent (`#2c2c2e` dark /
151
+ // `#eef2f8` light) and are resolved with `light-dark()`, NOT a theme selector.
152
+ // `html.light-theme` / `html.dark-theme` each set `color-scheme` (see
153
+ // `_theme-light.scss` / `_theme-dark.scss`, and `native-select.spec.ts` which
154
+ // forbids re-pinning it locally), so `light-dark()` follows the APP theme rather
155
+ // than the OS. It is also the only mechanism that survives Angular's emulated
156
+ // encapsulation: `html.dark-theme &` compiles to `html.dark-theme[_ngcontent-x]`
157
+ // and is dead, and a component-local theme branch is banned outright (skill §9
158
+ // rule 4). One value, no selector, correct in both themes.
159
+ @mixin _degrade {
160
+ // Transparency off: a flat, fully opaque plate. Killing only the blur would
161
+ // leave the translucent fill, which is the thing that hurts.
162
+ @media (prefers-reduced-transparency: reduce) {
163
+ & {
164
+ border-color: transparent;
165
+ background-color: light-dark(#eef2f8, #2c2c2e);
166
+ background-image: none;
167
+ box-shadow: none;
168
+ -webkit-backdrop-filter: none;
169
+ backdrop-filter: none;
170
+ }
171
+
172
+ // The refractive rim and specular sheen ARE the transparency effect.
173
+ &::before,
174
+ &::after {
175
+ display: none;
176
+ }
177
+ }
178
+
179
+ // The glass hairline is decorative-contrast at best (`--w18` measures 1.79:1
180
+ // against the panel — D3 §contrast). This is the ratified bump.
181
+ @media (prefers-contrast: more) {
182
+ & {
183
+ border-color: var(--w6);
184
+ }
185
+ }
186
+
187
+ // The sheen is the only animation this file starts, and `::after` is the only
188
+ // pseudo it animates. `_nova-motion.scss` already zeroes every animation
189
+ // globally under this query; this block makes the recipe self-sufficient for
190
+ // any consumer that pulls the mixin without the motion partial.
191
+ @media (prefers-reduced-motion: reduce) {
192
+ &::after {
193
+ animation: none;
194
+ }
195
+ }
196
+ }
197
+ // stylelint-enable scss/at-mixin-pattern, scss/at-function-pattern
198
+
199
+ // ── Popover composite ────────────────────────────────────────────────────────
200
+ // Menus, listboxes, dialogs, drawers, filter panels — the four-token quartet
201
+ // (skill §4). Thin blur tier, always: a popover's tier is a property of the
202
+ // recipe, not a caller's choice.
203
+
204
+ // $shadow `menu` (default) — menus/listboxes/dialogs/content-panel/agent-aside
205
+ // `drawer` — the directional, direction-AWARE drawer shadow
206
+ // $surface `chrome` (default) / `menu` / `panel` / `agent` — see `_fill()`.
207
+ // A FLOATING MENU takes `menu`, not the default: `chrome` is the
208
+ // ratified quartet fill and carries the dark plate in BOTH themes.
209
+ // $nested `true` when this surface sits INSIDE another glass surface. Drops
210
+ // `backdrop-filter` entirely, because a nested backdrop root samples
211
+ // its parent instead of the wallpaper and reads as flat mud (hazard 3
212
+ // in the header). The near-opaque `chrome` fill then carries the
213
+ // surface on its own — which is why `$nested` and a translucent
214
+ // `$surface` are refused rather than silently rendered see-through.
215
+ @mixin popover($shadow: menu, $surface: chrome, $nested: false) {
216
+ @if $nested and $surface != chrome {
217
+ @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.';
218
+ }
219
+
220
+ border: 1px solid var(--glass2-border);
221
+ background-image: _fill($surface);
222
+
223
+ @if $shadow == menu {
224
+ box-shadow:
225
+ 0 16px 40px rgb(0 0 0 / 20%),
226
+ 0 4px 10px rgb(0 0 0 / 10%),
227
+ var(--glass2-inset);
228
+ } @else if $shadow == drawer {
229
+ // A drawer's shadow travels AWAY from the edge it is anchored to, so it
230
+ // mirrors under RTL. `box-shadow` has no logical-offset form, so the offset
231
+ // rides a custom property flipped by `:dir(rtl)` — a compound on the element
232
+ // ITSELF, which is why it survives emulated encapsulation where the
233
+ // `[dir='rtl'] &` descendant combinator is dead (skill §9 rule 1). On an
234
+ // engine without `:dir()` the rule drops whole and the shadow stays on the
235
+ // LTR side: cosmetic, never a layout break.
236
+ --nova-glass-drawer-shadow-x: -30px;
237
+
238
+ box-shadow:
239
+ var(--nova-glass-drawer-shadow-x) 0 70px rgb(0 0 0 / 34%),
240
+ 0 4px 10px rgb(0 0 0 / 10%),
241
+ var(--glass2-inset);
242
+ } @else {
243
+ @error 'nova-glass: unknown $shadow `#{$shadow}`. Use `menu` (menus/listboxes/dialogs/content panel/agent aside) or `drawer`.';
244
+ }
245
+
246
+ @if not $nested {
247
+ @include _backdrop(thin);
248
+ }
249
+
250
+ @if $shadow == drawer {
251
+ &:dir(rtl) {
252
+ --nova-glass-drawer-shadow-x: 30px;
253
+ }
254
+ }
255
+
256
+ @include _degrade;
257
+ }
258
+
259
+ // ── Chrome ───────────────────────────────────────────────────────────────────
260
+ // Header + nav rail. Explicitly NOT the quartet (skill §4): a flat `--mat-rail`
261
+ // fill, a `--w16` hairline, its own three-part shadow, and the 36px tier.
262
+ // `--mat-header` exists and is deliberately unused here — the design's own
263
+ // header draws `--mat-rail` too, and the skill says not to "clean that up".
264
+ // Note the inset step is `--w2` (0.20), NOT `--w22` (0.22) as the quartet's
265
+ // `--glass2-inset` uses. The digits are the decimal fraction (skill §3.3) and
266
+ // these are two different ratified steps, not a typo.
267
+ @mixin chrome {
268
+ border: 1px solid var(--w16);
269
+ background: var(--mat-rail);
270
+ box-shadow:
271
+ 0 6px 16px rgb(0 0 0 / 16%),
272
+ 0 1px 4px rgb(0 0 0 / 10%),
273
+ inset 0 0.5px 0 var(--w2);
274
+
275
+ @include _backdrop(chrome);
276
+ @include _degrade;
277
+ }
278
+
279
+ // ── Content panel ────────────────────────────────────────────────────────────
280
+ // The surface holding app content — the strongest blur in the language (72px),
281
+ // on the quartet's layer stack with the `--mat-panel` fill.
282
+ @mixin panel {
283
+ border: 1px solid var(--w18);
284
+ background-image: _fill(panel);
285
+ box-shadow:
286
+ 0 16px 40px rgb(0 0 0 / 20%),
287
+ 0 4px 10px rgb(0 0 0 / 10%),
288
+ var(--glass2-inset);
289
+
290
+ @include _backdrop(panel);
291
+ @include _degrade;
292
+ }
293
+
294
+ // ── Light "vibrancy" material — OPT-IN, shell chrome only ────────────────
295
+ // The light-theme refractive layer (skill §4): a 1px gradient rim ring
296
+ // (`::before`, mask-composite exclude) and a drifting specular sheen (`::after`,
297
+ // `glassSheen` 22s, opacity .5) over a white-gradient fill.
298
+ // This is NOT the default glass, and it must be hard to reach by accident. Two
299
+ // locks, both mechanical:
300
+ // · `$surface` has NO DEFAULT and is checked against a closed six-name list of
301
+ // REAL shell surfaces. `@include glass.material()` is a compile error, and
302
+ // there is no honest value for a feature-app component to pass.
303
+ // · The light/dark gate is the CALLER's selector scope, and the only scope
304
+ // where a theme gate actually works is a GLOBAL stylesheet:
305
+ // html.light-theme .fly-header { @include glass.material($surface: header); }
306
+ // Including this from a component stylesheet is a review-blocker: under
307
+ // emulated encapsulation `html.light-theme &` is dead (skill §9 rule 1), so
308
+ // the material would apply in DARK theme too and paint white gradients over
309
+ // dark chrome. The shell already sets this precedent — `window/
310
+ // _nova-vibrancy.scss` is included once from `styles.scss` under
311
+ // `html.light-theme`, never from a component.
312
+ // The variant follows from the surface; a caller cannot mismatch them. The four
313
+ // `regular`/`chrome` names are the design's four `[data-material]` surfaces; the
314
+ // two `thin` names are the light thin controls §4 lists alongside them.
315
+ @mixin material($surface) {
316
+ $variant: null;
317
+
318
+ @if $surface == header or $surface == rail {
319
+ $variant: chrome;
320
+ } @else if $surface == content-panel or $surface == agent-aside {
321
+ $variant: regular;
322
+ } @else if $surface == toolbar or $surface == tablist {
323
+ $variant: thin;
324
+ } @else {
325
+ @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.';
326
+ }
327
+
328
+ // The rim and sheen are `z-index: -1` pseudo-elements: they paint above this
329
+ // element's own background but below its content. `isolation` keeps them from
330
+ // falling through to an ancestor's background. No `position` is set on
331
+ // purpose — the `backdrop-filter` below already makes this element a
332
+ // containing block for absolutely positioned descendants (hazard 2 in the
333
+ // header, load-bearing exactly here), so forcing `position: relative` would
334
+ // buy nothing and would silently break a `fixed` or `sticky` caller.
335
+ isolation: isolate;
336
+ border-color: transparent;
337
+
338
+ @if $variant == regular {
339
+ background-image: linear-gradient(
340
+ 180deg,
341
+ rgb(255 255 255 / 50%),
342
+ rgb(255 255 255 / 10%) 46%,
343
+ rgb(255 255 255 / 22%) 100%
344
+ );
345
+ box-shadow:
346
+ inset 0 0.5px 0 rgb(255 255 255 / 70%),
347
+ inset 0 -0.5px 0 rgb(148 170 200 / 20%),
348
+ inset 0 0 26px rgb(255 255 255 / 16%),
349
+ 0 10px 26px rgb(71 90 120 / 8%),
350
+ 0 2px 5px rgb(71 90 120 / 5%);
351
+ -webkit-backdrop-filter: blur(34px) saturate(165%) brightness(1.04);
352
+ backdrop-filter: blur(34px) saturate(165%) brightness(1.04);
353
+ } @else if $variant == chrome {
354
+ background-image: linear-gradient(
355
+ 168deg,
356
+ rgb(255 255 255 / 72%) 0%,
357
+ rgb(248 251 255 / 36%) 34%,
358
+ rgb(232 240 252 / 24%) 72%,
359
+ rgb(255 255 255 / 46%) 100%
360
+ );
361
+ box-shadow:
362
+ inset 0 1px 0 rgb(255 255 255 / 98%),
363
+ inset 0 -0.5px 0 rgb(120 145 180 / 22%),
364
+ inset 0 0 0 0.5px rgb(255 255 255 / 50%),
365
+ 0 8px 22px rgb(60 80 115 / 10%),
366
+ 0 2px 5px rgb(60 80 115 / 6%);
367
+ -webkit-backdrop-filter: blur(30px) saturate(180%) brightness(1.07);
368
+ backdrop-filter: blur(30px) saturate(180%) brightness(1.07);
369
+ } @else {
370
+ background-image: linear-gradient(180deg, rgb(255 255 255 / 66%), rgb(240 246 255 / 24%));
371
+ box-shadow:
372
+ inset 0 0.5px 0 rgb(255 255 255 / 80%),
373
+ inset 0 -0.5px 0 rgb(148 170 200 / 16%),
374
+ inset 0 0 0 0.5px rgb(255 255 255 / 34%),
375
+ 0 1px 3px rgb(71 90 120 / 5%);
376
+ -webkit-backdrop-filter: blur(16px) saturate(160%) brightness(1.05);
377
+ backdrop-filter: blur(16px) saturate(160%) brightness(1.05);
378
+ }
379
+
380
+ // Refractive rim — light bending through the edge of the glass. A 1px padding
381
+ // box masked against its own content box leaves only the ring.
382
+ &::before {
383
+ content: '';
384
+ position: absolute;
385
+ z-index: -1;
386
+ inset: 0;
387
+ padding: 1px;
388
+ border-radius: inherit;
389
+ background: linear-gradient(
390
+ 140deg,
391
+ rgb(255 255 255 / 60%),
392
+ rgb(255 255 255 / 8%) 34%,
393
+ rgb(190 210 240 / 16%) 64%,
394
+ rgb(255 255 255 / 42%)
395
+ );
396
+ pointer-events: none;
397
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
398
+ mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
399
+ -webkit-mask-composite: xor;
400
+ mask-composite: exclude;
401
+ }
402
+
403
+ // Specular sheen drifting across the surface. `background-size` is
404
+ // load-bearing, not decoration: `glassSheen` animates background-POSITION, and
405
+ // a background no wider than its box has nowhere to travel — at 100% the
406
+ // animation runs and nothing moves.
407
+ &::after {
408
+ content: '';
409
+ position: absolute;
410
+ z-index: -1;
411
+ inset: 0;
412
+ border-radius: inherit;
413
+ background: linear-gradient(
414
+ 100deg,
415
+ transparent 22%,
416
+ rgb(255 255 255 / 22%) 46%,
417
+ rgb(255 255 255 / 7%) 54%,
418
+ transparent 76%
419
+ );
420
+ background-size: 260% 100%;
421
+ opacity: 0.5;
422
+ animation: glassSheen 22s ease-in-out infinite;
423
+ pointer-events: none;
424
+ }
425
+
426
+ @include _degrade;
427
+ }
@@ -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
+ }