@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.
- package/fesm2022/flyos-design-system.mjs +402 -273
- package/fesm2022/flyos-design-system.mjs.map +1 -1
- package/package.json +1 -1
- package/scss/_app-surface-tokens.scss +592 -589
- package/scss/_nova-glass.scss +575 -575
- package/types/flyos-design-system.d.ts +137 -6
- package/types/flyos-design-system.d.ts.map +1 -1
package/scss/_nova-glass.scss
CHANGED
|
@@ -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
|
+
}
|