@flyos/design-system 3.11.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,589 +1,592 @@
1
- // ─── App-surface tokens — the BUSINESS-APP content layer ─────────────────────
2
- // WHY THIS IS A SEPARATE FAMILY FROM `--label-*` / `--surface-*`
3
- // The DS carries two token families because it dresses two genuinely different
4
- // surfaces, not because anyone forgot to merge them:
5
-
6
- // 1. `--label-*` / `--surface-*` / `--window-*` / `--glass-*` (_theme-light/dark)
7
- // SHELL CHROME. Translucent glass composited over the user's wallpaper.
8
- // `--surface-card` is a 20%-white LIFT on the window plate — it is only ever
9
- // meaningful stacked on that plate.
10
-
11
- // 2. `--ink-*` / `--bg-*` / `--line-*` (this file)
12
- // BUSINESS-APP CONTENT. An app's own opaque reading surface: white cards on
13
- // a near-white page in light, dark cards on a darker page in dark. Apps that
14
- // also embed in the shell re-tint THESE to glass in their own
15
- // `.fly-shell <app>` block (see Circles' `circles-theme.scss`) — that
16
- // override layer is the seam, and it only works because the resting values
17
- // are opaque.
18
-
19
- // Pointing `--bg-2` at `--surface-card` was evaluated and rejected: standalone,
20
- // a 20%-white card over a 98.5%-white page stops reading as a card at all. The
21
- // two families stay distinct on purpose; this comment is the contract.
22
-
23
- // ADDITIVE ONLY. `tools/ds-compat` classifies re-valuing an existing theme
24
- // token as MAJOR (it forks the Native Federation shared singleton for every
25
- // remote), so nothing here may redefine a token another DS partial owns.
26
-
27
- // Three tokens are deliberately ABSENT — they are the consuming app's BRAND and
28
- // every app already sets them in its own theme file:
29
- // --accent --accent-soft --focus-ring
30
-
31
- // The other nine platform tokens (--danger* --warning* --purple --scrim
32
- // --glass-blur) USED to be absent too, on the assumption that the platform layer
33
- // supplies them. That assumption only holds when an app runs FEDERATED, where
34
- // the desktop shell emits `_theme-light/dark.scss`. A business app running
35
- // STANDALONE loads only this file plus its own theme — `@use 'fly-theme'` is
36
- // commented out in both Circles and Thoughts — so `var(--danger)` resolved to
37
- // nothing and error text rendered colourless. Confirmed in a live browser, not
38
- // inferred: 9 of the 12 computed to the empty string on Thoughts standalone.
39
-
40
- // They are now mirrored below at the SAME values `_theme-light/dark.scss` uses.
41
- // Mirroring (rather than picking new values) is what makes this safe under the
42
- // cascade: this file's `:root, html.light-theme` selector can tie with the
43
- // shell's `html.light-theme`, and when the values are identical it does not
44
- // matter which one wins.
45
-
46
- // Consumed by the `fly-*` component library in `lib/components/`. Emitted via
47
- // `_fly-theme.scss`, so `@use 'fly-theme'` is all a consumer needs.
48
- // ─────────────────────────────────────────────────────────────────────────────
49
-
50
- // ── The platform TYPE layer (UX v2, S1.7–S1.10 fallout) ──────────────────────
51
- // `_tokens.scss` owns `--font-family` / `--font-family-mono` and the `--font-*`
52
- // role ramp (`--font-footnote`, `--font-caption1`, …). Like the two Nova
53
- // partials below it was reachable only through `_fly-theme.scss`, i.e. only in
54
- // the desktop shell.
55
-
56
- // The type layer fails harder than the colour layers do, and it fails silently.
57
- // The Nova form/overlay/content tasks landed 26 declarations across `lib/**` of
58
- // the form `font: 600 13px/1.3 var(--font-family)` or `font: var(--nova-font-
59
- // menu-item)`. A `var()` naming an undefined custom property is invalid at
60
- // computed-value time, and in a SHORTHAND that invalidates the WHOLE
61
- // declaration — weight, size and line-height all fall back to inherited, not
62
- // just the family. Standalone that is a 13px control label rendering at the
63
- // app's 16px body size inside a box hard-sized to 36/30/26px: a `fly-select`
64
- // trigger, a `fly-form-field` label, a `fly-checkbox` label, a `fly-drawer`
65
- // title. Nothing reported it because the shell renders every one of them
66
- // correctly. Before this line the DS had ZERO `var(--font-family)` references
67
- // in `lib/**`; the guard that now walks those files lives in
68
- // `app-surface-nova-bridge.spec.ts`.
69
-
70
- // Idempotent in the shell for the same reason as the two below: `_fly-theme.scss`
71
- // already `@use`s `tokens` FIRST, Sass loads a module once per compilation, and
72
- // the compiled entry is byte-identical either way.
73
-
74
- // Standalone it adds 120 greenfield names, of which the three External Apps
75
- // reference five. Four of those (`--font-title2`, `--system-blue`,
76
- // `--system-indigo`, `--system-red`) were dangling references that now resolve.
77
- // The fifth is `--font-family` itself, and it is the one behavioural change in
78
- // this commit: Circles already loads the DS's own Geist from its
79
- // `public/ui-fonts/geist.css` and is unaffected, but Thoughts and PPM write
80
- // `var(--font-family, 'Roboto', …)` on `html, body` while bundling Roboto and
81
- // NOT Geist. Both must bind `--font-family` to their own stack (or drop the
82
- // indirection) before taking this DS release, or their body text falls through
83
- // to a face they do not ship. Recorded here rather than worked around: the
84
- // alternative — leaving the token undefined so their fallback keeps firing —
85
- // is what dropped the 26 declarations above.
86
- @use 'tokens';
87
-
88
- // ── The Nova bridge (UX v2, S1.11) ───────────────────────────────────────────
89
- // `_nova-tokens.scss` carries the Nova vocabulary — the `--w*` ink ramp,
90
- // `--mat-*`, `--sys-*`, `--tint-*`, the `--glass2-*` quartet. `_fly-theme.scss`
91
- // loads it, and the desktop shell is the only consumer that loads that entry.
92
- // The three External Apps do NOT: Circles, Thoughts and PPM each `@use` THIS
93
- // partial by bare name, and Circles keeps `// @use 'fly-theme';` commented out
94
- // on line 1 of its `styles.scss` — the same fact this file's header records.
95
-
96
- // So without the line below, the Nova names are undefined in precisely the
97
- // builds that render this family on its own opaque paper. That matters for what
98
- // comes NEXT rather than for anything today: at the DS 2.0 flip the values in
99
- // this file are re-pointed at ramp steps (`--ink: var(--w95)`,
100
- // `--line: var(--w18)`; the whole table is in `.workflow/plans/ux-refresh/
101
- // notes/S1.11-app-surface-bridge.md`). A `var()` whose token is undefined makes
102
- // the entire declaration invalid at computed-value time, so that flip would
103
- // silently unset ink and hairlines for every standalone External App while
104
- // looking perfect in the shell — the light-mode-only failure that
105
- // `_shell-embed-bridge.scss` and `_ink-baseline.scss` were each written to end,
106
- // arriving a third time through a different door.
107
-
108
- // It is additive in both directions. In the shell it changes nothing at all:
109
- // `_fly-theme.scss` already `@use`s `nova-tokens` first, Sass loads a module
110
- // once per compilation, and the compiled entry is byte-identical either way.
111
- // Standalone it adds greenfield names only — no Nova name is referenced or
112
- // declared anywhere in Circles, Thoughts or PPM. It is also invisible to
113
- // `tools/ds-compat`, which derives the token surface by scanning `src/scss/**`
114
- // per file, not by compiling this entry.
115
-
116
- // `app-surface-nova-bridge.spec.ts` holds the wire in place and proves that
117
- // every `var()` this file emits still resolves inside the standalone bundle.
118
- @use 'nova-tokens';
119
-
120
- // The same argument, for motion. The app-surface components now reach for the
121
- // Nova keyframes (`itemIn` / `riseIn` / `appTileIn`) and the `--nova-ease-*` /
122
- // `--nova-duration-*` tokens, and those live in `_nova-motion.scss` — which,
123
- // like `_nova-tokens.scss`, was reachable only through `_fly-theme.scss` and so
124
- // only in the shell. Standalone, an `animation: itemIn …` naming an undefined
125
- // keyframe is not an error: it silently does nothing, so a list that staggers
126
- // in for a Core App would appear instantly in an External App with no gate
127
- // anywhere reporting a difference. Bridging it here is what makes the two
128
- // builds agree. Same idempotence: the shell already loads this module, so the
129
- // compiled shell entry is unchanged, and standalone it adds one `:root` block,
130
- // the keyframe inventory and the single `prefers-reduced-motion` block.
131
- @use 'nova-motion';
132
-
133
- // The same argument again, for the Nova TYPE roles — and the reason `tokens`
134
- // above is not optional. Half the `--nova-font-*` table SNAPs onto the platform
135
- // ramp by reference (`--nova-font-menu-item: var(--font-footnote)`,
136
- // `--nova-font-body`, `--nova-font-tooltip`), so loading this partial without
137
- // `tokens` would not fix anything: it would move the dangling reference one
138
- // level down, where a component's `font: var(--nova-font-menu-item)` still
139
- // resolves to an invalid value and still drops whole. The two travel together
140
- // or neither is worth loading.
141
-
142
- // This is the one `@use` in this file that the partial's own header argues
143
- // against ("`@use`d exactly once, from `_fly-theme.scss` … never a second
144
- // time"). That warning is about COMPONENT-level `@use`, which re-emits the
145
- // `:root` block into each component's compiled stylesheet; this is a global
146
- // entry, the same role `_fly-theme.scss` plays, and Sass still emits the module
147
- // exactly once per compilation. Federated it is `_fly-theme.scss`'s copy;
148
- // standalone it is this one. Never both.
149
- @use 'nova-type';
150
-
151
- :root,
152
- html.light-theme {
153
- // ── Neutrals — text ──────────────────────────────────────────────────────
154
- --ink: oklch(18% 0.005 250deg); // primary text, primary button fills
155
- --ink-2: oklch(32% 0.005 250deg); // secondary text
156
- --ink-3: oklch(50% 0.005 250deg); // tertiary text, icons
157
- --ink-4: oklch(65% 0.005 250deg); // disabled, muted labels
158
- --ink-inverse: oklch(99% 0 0deg); // text on dark / gradient fills
159
- --ink-hover: oklch(28% 0.005 250deg); // hover shade of --ink-filled controls
160
-
161
- // Label for a fill of `--ink` itself (the primary button). Distinct from
162
- // `--ink-inverse`, which is a FIXED near-white for coloured/gradient fills and
163
- // therefore wrong here: `--ink` flips per theme, so its label must flip too.
164
-
165
- // CONTRACT — anything that redefines `--ink` MUST redefine `--on-ink`.
166
- // It must also be OPAQUE. The shell-embed glass remaps in Circles and Thoughts
167
- // set `--ink` to white and every surface token to a translucent veil; a
168
- // translucent label composites into the fill and vanishes.
169
- --on-ink: var(--ink-inverse);
170
-
171
- // ── Neutrals — borders ───────────────────────────────────────────────────
172
- --line: oklch(92% 0.004 250deg); // default borders
173
- --line-2: oklch(88% 0.004 250deg); // hover borders
174
- --line-3: oklch(96% 0.003 250deg); // inner row dividers
175
-
176
- // ── Surfaces (opaque by design — see the header note) ────────────────────
177
- --bg: oklch(98.5% 0.003 100deg); // app background (warm off-white)
178
- --bg-2: oklch(100% 0 0deg); // cards, table
179
- --bg-3: oklch(97% 0.004 100deg); // sidebar, table header
180
- --bg-hover: oklch(96% 0.005 250deg);
181
-
182
- // ── Brand primitives ─────────────────────────────────────────────────────
183
- // `--purple` / `--accent` / `--accent-soft` are platform-owned (_theme-*.scss).
184
- --teal: oklch(74% 0.13 195deg);
185
- --grad: linear-gradient(
186
- 135deg,
187
- oklch(82% 0.12 325deg),
188
- oklch(60% 0.19 305deg),
189
- oklch(45% 0.2 285deg)
190
- );
191
- --grad-soft: linear-gradient(135deg, oklch(74% 0.13 195deg / 12%), oklch(55% 0.18 295deg / 12%));
192
-
193
- // ── Accent plate/ink PAIRS ───────────────────────────────────────────────
194
- // NEVER paint --accent on an --accent-soft plate: that is purple-on-purple,
195
- // and it is the bug that keeps coming back. Two plates, two inks:
196
-
197
- // 1. --accent-soft is a TINT the consuming context may redefine (the shell
198
- // resolves it to `color-mix(… 18%, transparent)`, i.e. a translucent wash
199
- // over unknowable glass). The only ink correct against it in every mode
200
- // is the ambient --ink, which each theme already contrasts with its own
201
- // background.
202
- --on-accent-soft: var(--ink);
203
-
204
- // 2. --accent-fill is a SOLID plate we own outright, so its ink is knowable
205
- // regardless of backdrop — knowable FROM THE PLATE, not "always white".
206
- // Badges/chips use this pair, so their contrast cannot be broken by a
207
- // host token override.
208
- --accent-fill: var(--accent);
209
-
210
- // FALLBACK RUNG ONLY. The accent-aware derivation at the foot of this file
211
- // overrides this wherever relative colour syntax parses; this is what an
212
- // older engine gets, and Nova's theme-invariant `--on-accent` (#fff) is the
213
- // right answer for the platform violet in both themes.
214
-
215
- // It is deliberately NOT an alias of `--ink-inverse` any more. Two tokens that
216
- // happen to agree today are not the same requirement: "ink on a dark/gradient
217
- // fill" is a constant, "ink on the accent plate" is a function of the accent.
218
- // Aliasing them made the second one's contrast depend on a token that is not
219
- // the plate. See the foot of the file for what it depends on now.
220
- --on-accent-fill: var(--on-accent);
221
-
222
- // ── Interaction state layers ─────────────────────────────────────────────
223
- // Translucent veils layered OVER a surface for hover/press/selected instead
224
- // of swapping its bg colour — one value reads correctly on any background
225
- // (the visionOS / Material "state layer" model), so hover looks consistent
226
- // across cards, rows, chips and glass plates alike.
227
- --state-hover: oklch(50% 0.03 285deg / 5%);
228
- --state-press: oklch(50% 0.03 285deg / 9%);
229
- --state-selected: var(--accent-soft);
230
-
231
- // Bright top rim that gives a translucent plate physical depth. Inert on
232
- // opaque surfaces; the shell's glass mode leans on it.
233
- --edge-highlight: oklch(100% 0 0deg / 50%);
234
-
235
- // ── Icon size ramp ───────────────────────────────────────────────────────
236
- // ONE scale for every icon (PrimeIcons + inline SVG). Icons had drifted to
237
- // ~13 different sizes, which is why they never lined up beside each other.
238
- --icon-xs: 12px; // dense meta rows, chips
239
- --icon-sm: 14px; // text buttons, section-header actions
240
- --icon-md: 16px; // default — icon buttons, rails, nav
241
- --icon-lg: 20px; // emphasis / empty states
242
-
243
- // ── Status chips (workflow-generic) ──────────────────────────────────────
244
- --st-published: oklch(55% 0.13 165deg);
245
- --st-published-bg: oklch(96% 0.04 165deg);
246
- --st-approved: oklch(55% 0.15 245deg);
247
- --st-approved-bg: oklch(96% 0.03 245deg);
248
- --st-draft: oklch(55% 0.005 250deg);
249
- --st-draft-bg: oklch(95% 0.003 250deg);
250
- --st-archived: oklch(55% 0.06 60deg);
251
- --st-archived-bg: oklch(96% 0.025 80deg);
252
-
253
- // ── Semantic feedback ────────────────────────────────────────────────────
254
- // Success has no platform equivalent (`--status-success` is a raw dot colour,
255
- // not a fg/bg pair), so it ships here, aliased onto the published status chip.
256
- --success: var(--st-published);
257
- --success-bg: var(--st-published-bg);
258
-
259
- // Standalone fallbacks for the platform tokens — see the header. Values are
260
- // copied verbatim from `_theme-light.scss`; keep them in sync if that file
261
- // changes, or a standalone app drifts from its federated self.
262
- --danger: #ff6b6b;
263
- --danger-bg: rgb(255 59 48 / 20%);
264
- --danger-line: rgb(255 59 48 / 40%);
265
- --warning: #febc2e;
266
- --warning-bg: rgb(254 188 46 / 18%);
267
- --warning-fg: rgb(255 255 255 / 96%);
268
- --purple: #9333ea;
269
- --scrim: rgb(0 0 0 / 50%);
270
- --glass-blur: 24px;
271
-
272
- // ── Module-card teal (app-home fidelity pass, 2026-08-20) ────────────────
273
- // The launch-card family (`.ah__card--feature` tile, hover wash, hover ring)
274
- // is deliberately THEME-FIXED teal — decoupled from the running `--accent`
275
- // and from `--sys-teal`'s per-theme flip so every app's home cards share
276
- // one identity over the glass. Consumers derive the design's alphas via
277
- // color-mix; only these two literals exist (conventions-spec rule: raw
278
- // colour values live in this file alone). Declared once in `:root`, no
279
- // dark-scope redefinition, same as `--ink-inverse`.
280
- --module-teal: #2896a0;
281
- --module-teal-deep: #105a5a;
282
-
283
- // ── Spacing scale (4px base) ─────────────────────────────────────────────
284
- --sp-1: 4px;
285
- --sp-2: 8px;
286
- --sp-3: 12px;
287
- --sp-4: 16px;
288
- --sp-5: 22px;
289
- --sp-6: 28px;
290
- --sp-7: 40px;
291
- --sp-8: 56px;
292
-
293
- // ── Typography scale ─────────────────────────────────────────────────────
294
- --text-2xs: 11.5px; // field labels, field errors, footer hints
295
- --text-xs: 11px; // eyebrows, badges, metric labels
296
- --text-sm: 12.5px; // meta, hints, crumbs
297
- --text-md: 13.5px; // body in cards, subtitles
298
- --text-base: 14px; // controls, table body
299
- --text-lg: 16px; // page body
300
- --text-xl: 18px; // card titles, dialog titles
301
- --text-2xl: 28px; // detail page h1
302
- --text-3xl: 32px; // listing page h1
303
- --text-display: 44px; // hero title
304
- --fw-book: 450; // between regular and medium (segmented buttons)
305
- --fw-medium: 500;
306
- --fw-semibold: 600;
307
- --fw-bold: 700;
308
- --tracking-tight: -0.02em;
309
- --tracking-wide: 0.06em;
310
-
311
- // Type families. `--font-family` / `--font-family-mono` are the platform's
312
- // names; these are the short aliases the component library reads.
313
- --font-sans: var(--font-family);
314
-
315
- // Monospace is retired the UI is one sans face throughout. Kept (aliased)
316
- // rather than deleted so existing `var(--font-mono)` consumers keep
317
- // resolving; the `.mono` utility still contributes tabular-nums, which is
318
- // what those id/number treatments actually need.
319
- --font-mono: var(--font-sans);
320
-
321
- // ── Radii ────────────────────────────────────────────────────────────────
322
- --r-sm: 6px; // icon buttons, kbd
323
- --r-md: 8px; // buttons, inputs, nav items
324
- --r-lg: 12px; // cards, table wrapper, filter panel
325
- --r-xl: 16px; // reserved
326
-
327
- // ── Layout ───────────────────────────────────────────────────────────────
328
- --row-h: 56px; // table row
329
-
330
- // ── Z-index scale ────────────────────────────────────────────────────────
331
- --z-sticky: 10;
332
- --z-bulkbar: 50;
333
- --z-overlay: 100; // drawers, scrims
334
- --z-dialog: 110; // centered dialogs / confirms
335
- --z-toast: 200;
336
- --z-tooltip: 1000;
337
-
338
- // ── Shadows ──────────────────────────────────────────────────────────────
339
- --shadow-card: 0 1px 2px oklch(0% 0 0deg / 4%);
340
- --shadow-active: 0 1px 2px oklch(0% 0 0deg / 4%), 0 0 0 1px var(--line);
341
- --shadow-bulk: 0 6px 20px oklch(0% 0 0deg / 10%);
342
- --shadow-filter: 0 12px 36px oklch(0% 0 0deg / 6%);
343
- --shadow-panel: var(--shadow-filter), 0 1px 0 oklch(100% 0 0deg) inset;
344
- --shadow-raised: 0 1px 2px oklch(0% 0 0deg / 8%);
345
-
346
- // DERIVED off --accent, never a copy of its hex: a hardcoded purple here
347
- // would not follow a runtime accent override (FlyThemeService.applyAccent),
348
- // so you would get the new accent with the old glow beside it.
349
- --shadow-accent: 0 4px 12px color-mix(in srgb, var(--accent) 25%, transparent);
350
- --shadow-tooltip: 0 4px 16px oklch(0% 0 0deg / 18%);
351
- --shadow-focus-soft: 0 0 0 3px oklch(0% 0 0deg / 4%);
352
- --shadow-drawer: -24px 0 64px oklch(0% 0 0deg / 8%); // end-side panel (LTR)
353
- --shadow-drawer-rtl: 24px 0 64px oklch(0% 0 0deg / 8%); // mirrored under RTL
354
- --shadow-dialog:
355
- 0 24px 64px oklch(18% 0.005 250deg / 18%), 0 8px 24px oklch(18% 0.005 250deg / 10%);
356
- --shadow-toast:
357
- 0 12px 32px oklch(18% 0.005 250deg / 14%), 0 4px 12px oklch(18% 0.005 250deg / 8%);
358
-
359
- // ── Transitions ──────────────────────────────────────────────────────────
360
- --t-state: 0.12s ease;
361
- --t-layout: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
362
- --t-overlay: 0.18s cubic-bezier(0.2, 0.7, 0.2, 1);
363
- --t-drawer: 0.32s cubic-bezier(0.32, 0.72, 0.2, 1);
364
- }
365
-
366
- // ── Dark theme ───────────────────────────────────────────────────────────────
367
- // Sizing, radii, type, spacing, z-index and transition tokens are theme-neutral
368
- // and inherit the block above unchanged. Alias-based tokens (--success*,
369
- // --on-accent-*, --state-selected, --font-*) re-resolve through their targets
370
- // automatically and are NOT redeclared a second declaration would just be a
371
- // second place to get it wrong.
372
- html.dark-theme {
373
- // The nine platform fallbacks carry the SAME values in both themes upstream,
374
- // except this one `_theme-dark.scss` widens the blur. Redeclaring only the
375
- // token that actually differs keeps the rest inheriting from the block above.
376
- --glass-blur: 40px;
377
-
378
- // ── Neutrals (inverted lightness) ────────────────────────────────────────
379
- --ink: oklch(95% 0.005 250deg);
380
- --ink-2: oklch(78% 0.005 250deg);
381
- --ink-3: oklch(62% 0.005 250deg);
382
- --ink-4: oklch(45% 0.005 250deg);
383
- --ink-hover: oklch(85% 0.005 250deg);
384
-
385
- // Flips with --ink above: the primary button is a near-white pill here, so its
386
- // label goes near-black. Aliased to --bg (the darkest opaque surface in this
387
- // theme) rather than repeating its literal — see the contract on the :root decl.
388
- --on-ink: var(--bg);
389
- --line: oklch(22% 0.004 250deg);
390
- --line-2: oklch(28% 0.004 250deg);
391
- --line-3: oklch(18% 0.003 250deg);
392
- --bg: oklch(14% 0.003 250deg);
393
- --bg-2: oklch(17% 0.003 250deg);
394
- --bg-3: oklch(20% 0.004 250deg);
395
- --bg-hover: oklch(24% 0.005 250deg);
396
-
397
- // ── Brand — higher chroma/lightness to pop on dark ───────────────────────
398
- --teal: oklch(78% 0.13 195deg);
399
- --grad: linear-gradient(
400
- 135deg,
401
- oklch(85% 0.12 325deg),
402
- oklch(70% 0.19 305deg),
403
- oklch(60% 0.2 285deg)
404
- );
405
- --grad-soft: linear-gradient(135deg, oklch(78% 0.13 195deg / 18%), oklch(68% 0.18 295deg / 18%));
406
-
407
- // ── Interaction state layers — light veil on dark surfaces ───────────────
408
- --state-hover: oklch(95% 0.02 285deg / 6%);
409
- --state-press: oklch(95% 0.02 285deg / 11%);
410
- --edge-highlight: oklch(100% 0 0deg / 6%);
411
-
412
- // ── Status chips dim bg, bright fg ─────────────────────────────────────
413
- --st-published: oklch(85% 0.13 165deg);
414
- --st-published-bg: oklch(28% 0.06 165deg);
415
- --st-approved: oklch(82% 0.15 245deg);
416
- --st-approved-bg: oklch(28% 0.06 245deg);
417
- --st-draft: var(--ink-2); // same grey as secondary text on dark
418
- --st-draft-bg: oklch(24% 0.003 250deg);
419
- --st-archived: oklch(82% 0.06 60deg);
420
- --st-archived-bg: oklch(28% 0.05 60deg);
421
-
422
- // ── Shadows — deeper opacity on dark surfaces ────────────────────────────
423
- --shadow-card: 0 1px 2px oklch(0% 0 0deg / 35%);
424
- --shadow-active: 0 1px 2px oklch(0% 0 0deg / 40%), 0 0 0 1px var(--line);
425
- --shadow-bulk: 0 6px 20px oklch(0% 0 0deg / 55%);
426
- --shadow-filter: 0 12px 36px oklch(0% 0 0deg / 50%);
427
- --shadow-panel: var(--shadow-filter), 0 1px 0 oklch(100% 0 0deg / 5%) inset;
428
- --shadow-accent: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
429
- --shadow-focus-soft: 0 0 0 3px oklch(100% 0 0deg / 6%);
430
- --shadow-drawer: -24px 0 64px oklch(0% 0 0deg / 50%);
431
- --shadow-drawer-rtl: 24px 0 64px oklch(0% 0 0deg / 50%);
432
- --shadow-dialog: 0 24px 64px oklch(0% 0 0deg / 60%), 0 8px 24px oklch(0% 0 0deg / 40%);
433
- --shadow-toast: 0 12px 32px oklch(0% 0 0deg / 50%), 0 4px 12px oklch(0% 0 0deg / 30%);
434
- }
435
-
436
- // ── Chrome-token bridge (STANDALONE ONLY) ────────────────────────────────────
437
- // The older DS components (everything outside lib/app-surface) theme from the
438
- // SHELL-CHROME family — --label-*, --surface-*, --text-color*, --separator* —
439
- // and each reference carries a hardcoded literal fallback, e.g.
440
- // `var(--label-primary, #1d1d1f)` or `var(--surface-card, #fff)`. 211 such
441
- // fallbacks exist across the library.
442
-
443
- // Those literals are theme-BLIND: a single constant picked by whoever wrote the
444
- // component. Federated that never shows, because the desktop shell defines the
445
- // whole chrome family. Standalone, no one does — so a business app got whatever
446
- // constant each component happened to hardcode, in BOTH light and dark.
447
-
448
- // Aliasing the chrome names onto the app-surface family fixes all 211 sites at
449
- // once, without touching a single component: the var() resolves through the
450
- // token and never reaches its literal. The targets are theme-aware, so these
451
- // follow light/dark automatically.
452
-
453
- // SELECTOR IS LOAD-BEARING: this block is `:root` ALONE deliberately not
454
- // `:root, html.light-theme` like the blocks above. The shell declares every one
455
- // of these under `html.light-theme` / `html.dark-theme` (specificity 0,1,1),
456
- // which beats a bare `:root` (0,1,0). So federated, the shell's chrome always
457
- // wins and nothing about the desktop changes; standalone, nothing else declares
458
- // them and these apply. Widening this selector would create a specificity TIE
459
- // with the shell and hand the desktop's own chrome to source order.
460
- :root {
461
- // Text
462
- --label-primary: var(--ink);
463
- --label-secondary: var(--ink-3);
464
- --label-tertiary: var(--ink-4);
465
- --text-color: var(--ink);
466
- --text-color-secondary: var(--ink-3);
467
-
468
- // Surfaces
469
- --surface-ground: var(--bg);
470
- --surface-card: var(--bg-2);
471
- --surface-section: var(--bg-3);
472
- --surface-overlay: var(--bg-2);
473
- --surface-overlay-strong: var(--bg-2);
474
-
475
- // Lines + interaction states
476
- --surface-border: var(--line);
477
- --separator: var(--line);
478
- --separator-primary: var(--line);
479
- --surface-hover: var(--bg-hover);
480
- --surface-active: var(--state-selected);
481
- }
482
-
483
- // ── Plate ink, derived from the plate (UX v2, S7.1a) ─────────────────────────
484
- // `--on-accent-fill` labels a SOLID `--accent-fill` plate: `fly-detail-shell`'s
485
- // active tab, `badge()`'s default, `fly-pagination`'s current-page pill.
486
-
487
- // THE FAILURE IS THE EXTERNAL-APP ACCENT, NOT THE EMBED. A fixed near-white is
488
- // right for the platform violet (5.25:1) and for its `--accent-hi` partner
489
- // (≈6.9:1 since the 2026-08-17 accent-family re-value; `plate-ink.spec.ts`
490
- // recomputes it live), and catastrophic for an External App that remaps `--accent` to a
491
- // light brand colour: white on a pale gold #F5C542 measures 1.58:1, on a cyan
492
- // #22D3EE 1.76:1. Three tokens are deliberately absent from this file precisely
493
- // because the accent is the consuming app's to choose (see the header), so this
494
- // is not a hypothetical it is what the seam is for. The label has to follow
495
- // the plate's LUMINANCE, which means the plate is what it is computed from.
496
-
497
- // WHAT S1.11 RECORDED HERE WAS WRONG, and the correction is the reason this is
498
- // a derivation rather than the one-line repoint that note proposed. The claim
499
- // was that `app-surface-on-glass` pinning `--ink-inverse` to a dark literal
500
- // dragged `--on-accent-fill` (then an alias of it) down to ~2.5:1 on #9333EA.
501
- // It never did. A custom property is substituted at the element that DECLARES
502
- // it: `--on-accent-fill: var(--ink-inverse)` resolves at `:root`, and the
503
- // resolved value is what inherits. Every shipped `app-surface-on-glass` call
504
- // site is a component host far below `:root`, so it re-values `--ink-inverse`
505
- // for its own subtree and cannot reach an `--on-accent-fill` that was computed
506
- // above it. Measured in Chromium against the real compiled mixin: inside the
507
- // mixin `--ink-inverse` is `oklch(18% 0.005 250deg)` while `--on-accent-fill`
508
- // is still `oklch(99% 0 0deg)`, rendering 5.25:1 identical to outside it.
509
- // The same rule is why there is no generic `--on-plate` token; see below.
510
-
511
- // WHY A LIVE `var()` CHAIN AND NOT A BUILD-TIME COMPUTATION. Sass cannot see
512
- // the input. `--accent` is a runtime knob: `FlyThemeService.applyAccent` writes
513
- // it as an INLINE style on `<html>` from tenant/user branding, and
514
- // `isAccentColor` admits anything `CSS.supports('color', …)` does. Any value
515
- // resolved at compile time is answering a question about a colour that does not
516
- // exist yet. Same reason `--shadow-accent` is a `color-mix` and not a hex.
517
-
518
- // MECHANISMS REJECTED.
519
- // · `contrast-color(var(--accent-fill))` is CSS Color 5 and is literally this
520
- // feature, but it is not yet carried widely enough to be the only rung — and
521
- // it splits black/white on the same WCAG luminance the expression below
522
- // computes, so it would buy a dependency rather than an answer.
523
- // · `light-dark()` pairs on the THEME. An accent's lightness is independent of
524
- // the theme (an app may set a pale accent in dark mode), so it answers a
525
- // different question and fails on exactly the case that motivates this.
526
- // · `color-mix(… var(--accent) N%, white)` scales lightness monotonically; it
527
- // cannot invert polarity, so it cannot cross the light/dark accent boundary.
528
- // · "External Apps override the token" moves the defect into every integrator,
529
- // silently nothing can gate an app that forgets. The token stays
530
- // overridable as an escape hatch; it is not the contract.
531
-
532
- // THE EXPRESSION. Relative colour syntax in `srgb-linear` exposes `r`/`g`/`b`
533
- // as exactly the linearised channels WCAG's relative-luminance formula takes,
534
- // so `0.2126r + 0.7152g + 0.0722b` IS that luminance — no approximation. An
535
- // oklch-`l` split is the tempting one-channel version and it is not safe: `l`
536
- // and WCAG luminance disagree by up to ~0.04 on saturated hues, which near the
537
- // boundary picks the wrong pole and ships a ~3.7:1 label. `(0.1791 - Y) *
538
- // infinity` clamped to 0..1 is a step function: 1 (white) below the threshold,
539
- // 0 (black) above. 0.1791 is where white and black contrast a background
540
- // EQUALLY, so the worst case over the entire sRGB cube is 4.58:1 the AA floor
541
- // here is arithmetic, not a review. The explicit `/ 100%` is load-bearing:
542
- // relative colour syntax inherits the ORIGIN's alpha when the slot is omitted,
543
- // and this file's header requires a counter-ink to be OPAQUE or it composites
544
- // into its own fill. A tenant accent may legitimately carry one — `isAccentColor`
545
- // admits any value `CSS.supports('color', …)` accepts.
546
-
547
- // TWO RUNGS. Relative colour syntax is Baseline since Firefox 128 (2024-07);
548
- // anything older keeps the `--on-accent` white declared above, i.e. today's
549
- // platform-correct behaviour. `@supports` is what makes that a fallback at all:
550
- // a custom property accepts almost any token sequence at PARSE time, so the
551
- // usual declare-it-twice ladder does not work here the later declaration
552
- // always wins and the failure surfaces at the consumer as an unset `color`.
553
- // The `infinity` keyword needs no rung of its own; every engine with relative
554
- // colour syntax predates its own support for it.
555
-
556
- // THERE IS NO GENERIC `--on-plate` TOKEN, and the substitution rule above is
557
- // why. A plate that is not the accent — `-board`'s presence chips carry a
558
- // per-user colour from `presenceColorFor`, bound inline per element — cannot be
559
- // served by a `:root` token, because a `:root` declaration substitutes `:root`'s
560
- // input, not the chip's. Tried and measured: with an `--on-plate` token declared
561
- // here and its input set on the chip, every chip resolved to the `:root`
562
- // fallback and nothing changed. A plate the DS cannot know has to be derived in the
563
- // CONSUMING declaration, on the element that paints it, which is what
564
- // `canvas-diagram` / `canvas-fishbone` now do. `$_plate-ink` is the one
565
- // statement of the formula; `plate-ink.spec.ts` holds those copies to it, and
566
- // evaluates this one at both accent poles rather than pattern-matching it.
567
-
568
- // Both theme classes are listed rather than leaning on `:root` alone: a future
569
- // redeclaration of `--on-accent-fill` inside `html.dark-theme` (0,1,1) would
570
- // otherwise silently outrank a bare `:root` (0,1,0) override.
571
-
572
- // A Sass-private string, interpolated once per channel: all three take the SAME
573
- // switch, and written inline Sass would try to evaluate `0.2126 * r` as
574
- // arithmetic and fail. So this is both the escape hatch and the single place
575
- // the formula exists it cannot drift between the channels or the tokens.
576
- $_plate-ink: 'clamp(0, (0.1791 - (0.2126 * r + 0.7152 * g + 0.0722 * b)) * infinity, 1)';
577
-
578
- // The probe colour is `rgb(0 0 0)`, not a hex, so the identical prelude can be
579
- // copied verbatim into `design-system-board` whose own gate bans bare colour
580
- // literals anywhere in a component stylesheet, prelude and comments included.
581
- @supports (color: color(from rgb(0 0 0) srgb-linear r g b)) {
582
- :root,
583
- html.light-theme,
584
- html.dark-theme {
585
- --on-accent-fill: color(
586
- from var(--accent-fill) srgb-linear #{$_plate-ink} #{$_plate-ink} #{$_plate-ink} / 100%
587
- );
588
- }
589
- }
1
+ // ─── App-surface tokens — the BUSINESS-APP content layer ─────────────────────
2
+ // WHY THIS IS A SEPARATE FAMILY FROM `--label-*` / `--surface-*`
3
+ // The DS carries two token families because it dresses two genuinely different
4
+ // surfaces, not because anyone forgot to merge them:
5
+
6
+ // 1. `--label-*` / `--surface-*` / `--window-*` / `--glass-*` (_theme-light/dark)
7
+ // SHELL CHROME. Translucent glass composited over the user's wallpaper.
8
+ // `--surface-card` is a 20%-white LIFT on the window plate — it is only ever
9
+ // meaningful stacked on that plate.
10
+
11
+ // 2. `--ink-*` / `--bg-*` / `--line-*` (this file)
12
+ // BUSINESS-APP CONTENT. An app's own opaque reading surface: white cards on
13
+ // a near-white page in light, dark cards on a darker page in dark. Apps that
14
+ // also embed in the shell re-tint THESE to glass in their own
15
+ // `.fly-shell <app>` block (see Circles' `circles-theme.scss`) — that
16
+ // override layer is the seam, and it only works because the resting values
17
+ // are opaque.
18
+
19
+ // Pointing `--bg-2` at `--surface-card` was evaluated and rejected: standalone,
20
+ // a 20%-white card over a 98.5%-white page stops reading as a card at all. The
21
+ // two families stay distinct on purpose; this comment is the contract.
22
+
23
+ // ADDITIVE ONLY. `tools/ds-compat` classifies re-valuing an existing theme
24
+ // token as MAJOR (it forks the Native Federation shared singleton for every
25
+ // remote), so nothing here may redefine a token another DS partial owns.
26
+
27
+ // Three tokens are deliberately ABSENT — they are the consuming app's BRAND and
28
+ // every app already sets them in its own theme file:
29
+ // --accent --accent-soft --focus-ring
30
+
31
+ // The other nine platform tokens (--danger* --warning* --purple --scrim
32
+ // --glass-blur) USED to be absent too, on the assumption that the platform layer
33
+ // supplies them. That assumption only holds when an app runs FEDERATED, where
34
+ // the desktop shell emits `_theme-light/dark.scss`. A business app running
35
+ // STANDALONE loads only this file plus its own theme — `@use 'fly-theme'` is
36
+ // commented out in both Circles and Thoughts — so `var(--danger)` resolved to
37
+ // nothing and error text rendered colourless. Confirmed in a live browser, not
38
+ // inferred: 9 of the 12 computed to the empty string on Thoughts standalone.
39
+
40
+ // They are now mirrored below at the SAME values `_theme-light/dark.scss` uses.
41
+ // Mirroring (rather than picking new values) is what makes this safe under the
42
+ // cascade: this file's `:root, html.light-theme` selector can tie with the
43
+ // shell's `html.light-theme`, and when the values are identical it does not
44
+ // matter which one wins.
45
+
46
+ // Consumed by the `fly-*` component library in `lib/components/`. Emitted via
47
+ // `_fly-theme.scss`, so `@use 'fly-theme'` is all a consumer needs.
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ // ── The platform TYPE layer (UX v2, S1.7–S1.10 fallout) ──────────────────────
51
+ // `_tokens.scss` owns `--font-family` / `--font-family-mono` and the `--font-*`
52
+ // role ramp (`--font-footnote`, `--font-caption1`, …). Like the two Nova
53
+ // partials below it was reachable only through `_fly-theme.scss`, i.e. only in
54
+ // the desktop shell.
55
+
56
+ // The type layer fails harder than the colour layers do, and it fails silently.
57
+ // The Nova form/overlay/content tasks landed 26 declarations across `lib/**` of
58
+ // the form `font: 600 13px/1.3 var(--font-family)` or `font: var(--nova-font-
59
+ // menu-item)`. A `var()` naming an undefined custom property is invalid at
60
+ // computed-value time, and in a SHORTHAND that invalidates the WHOLE
61
+ // declaration — weight, size and line-height all fall back to inherited, not
62
+ // just the family. Standalone that is a 13px control label rendering at the
63
+ // app's 16px body size inside a box hard-sized to 36/30/26px: a `fly-select`
64
+ // trigger, a `fly-form-field` label, a `fly-checkbox` label, a `fly-drawer`
65
+ // title. Nothing reported it because the shell renders every one of them
66
+ // correctly. Before this line the DS had ZERO `var(--font-family)` references
67
+ // in `lib/**`; the guard that now walks those files lives in
68
+ // `app-surface-nova-bridge.spec.ts`.
69
+
70
+ // Idempotent in the shell for the same reason as the two below: `_fly-theme.scss`
71
+ // already `@use`s `tokens` FIRST, Sass loads a module once per compilation, and
72
+ // the compiled entry is byte-identical either way.
73
+
74
+ // Standalone it adds 120 greenfield names, of which the three External Apps
75
+ // reference five. Four of those (`--font-title2`, `--system-blue`,
76
+ // `--system-indigo`, `--system-red`) were dangling references that now resolve.
77
+ // The fifth is `--font-family` itself, and it is the one behavioural change in
78
+ // this commit: Circles already loads the DS's own Geist from its
79
+ // `public/ui-fonts/geist.css` and is unaffected, but Thoughts and PPM write
80
+ // `var(--font-family, 'Roboto', …)` on `html, body` while bundling Roboto and
81
+ // NOT Geist. Both must bind `--font-family` to their own stack (or drop the
82
+ // indirection) before taking this DS release, or their body text falls through
83
+ // to a face they do not ship. Recorded here rather than worked around: the
84
+ // alternative — leaving the token undefined so their fallback keeps firing —
85
+ // is what dropped the 26 declarations above.
86
+ @use 'tokens';
87
+
88
+ // ── The Nova bridge (UX v2, S1.11) ───────────────────────────────────────────
89
+ // `_nova-tokens.scss` carries the Nova vocabulary — the `--w*` ink ramp,
90
+ // `--mat-*`, `--sys-*`, `--tint-*`, the `--glass2-*` quartet. `_fly-theme.scss`
91
+ // loads it, and the desktop shell is the only consumer that loads that entry.
92
+ // The three External Apps do NOT: Circles, Thoughts and PPM each `@use` THIS
93
+ // partial by bare name, and Circles keeps `// @use 'fly-theme';` commented out
94
+ // on line 1 of its `styles.scss` — the same fact this file's header records.
95
+
96
+ // So without the line below, the Nova names are undefined in precisely the
97
+ // builds that render this family on its own opaque paper. That matters for what
98
+ // comes NEXT rather than for anything today: at the DS 2.0 flip the values in
99
+ // this file are re-pointed at ramp steps (`--ink: var(--w95)`,
100
+ // `--line: var(--w18)`; the whole table is in `.workflow/plans/ux-refresh/
101
+ // notes/S1.11-app-surface-bridge.md`). A `var()` whose token is undefined makes
102
+ // the entire declaration invalid at computed-value time, so that flip would
103
+ // silently unset ink and hairlines for every standalone External App while
104
+ // looking perfect in the shell — the light-mode-only failure that
105
+ // `_shell-embed-bridge.scss` and `_ink-baseline.scss` were each written to end,
106
+ // arriving a third time through a different door.
107
+
108
+ // It is additive in both directions. In the shell it changes nothing at all:
109
+ // `_fly-theme.scss` already `@use`s `nova-tokens` first, Sass loads a module
110
+ // once per compilation, and the compiled entry is byte-identical either way.
111
+ // Standalone it adds greenfield names only — no Nova name is referenced or
112
+ // declared anywhere in Circles, Thoughts or PPM. It is also invisible to
113
+ // `tools/ds-compat`, which derives the token surface by scanning `src/scss/**`
114
+ // per file, not by compiling this entry.
115
+
116
+ // `app-surface-nova-bridge.spec.ts` holds the wire in place and proves that
117
+ // every `var()` this file emits still resolves inside the standalone bundle.
118
+ @use 'nova-tokens';
119
+
120
+ // The same argument, for motion. The app-surface components now reach for the
121
+ // Nova keyframes (`itemIn` / `riseIn` / `appTileIn`) and the `--nova-ease-*` /
122
+ // `--nova-duration-*` tokens, and those live in `_nova-motion.scss` — which,
123
+ // like `_nova-tokens.scss`, was reachable only through `_fly-theme.scss` and so
124
+ // only in the shell. Standalone, an `animation: itemIn …` naming an undefined
125
+ // keyframe is not an error: it silently does nothing, so a list that staggers
126
+ // in for a Core App would appear instantly in an External App with no gate
127
+ // anywhere reporting a difference. Bridging it here is what makes the two
128
+ // builds agree. Same idempotence: the shell already loads this module, so the
129
+ // compiled shell entry is unchanged, and standalone it adds one `:root` block,
130
+ // the keyframe inventory and the single `prefers-reduced-motion` block.
131
+ @use 'nova-motion';
132
+
133
+ // The same argument again, for the Nova TYPE roles — and the reason `tokens`
134
+ // above is not optional. Half the `--nova-font-*` table SNAPs onto the platform
135
+ // ramp by reference (`--nova-font-menu-item: var(--font-footnote)`,
136
+ // `--nova-font-body`, `--nova-font-tooltip`), so loading this partial without
137
+ // `tokens` would not fix anything: it would move the dangling reference one
138
+ // level down, where a component's `font: var(--nova-font-menu-item)` still
139
+ // resolves to an invalid value and still drops whole. The two travel together
140
+ // or neither is worth loading.
141
+
142
+ // This is the one `@use` in this file that the partial's own header argues
143
+ // against ("`@use`d exactly once, from `_fly-theme.scss` … never a second
144
+ // time"). That warning is about COMPONENT-level `@use`, which re-emits the
145
+ // `:root` block into each component's compiled stylesheet; this is a global
146
+ // entry, the same role `_fly-theme.scss` plays, and Sass still emits the module
147
+ // exactly once per compilation. Federated it is `_fly-theme.scss`'s copy;
148
+ // standalone it is this one. Never both.
149
+ @use 'nova-type';
150
+
151
+ :root,
152
+ html.light-theme {
153
+ // ── Neutrals — text ──────────────────────────────────────────────────────
154
+ --ink: oklch(18% 0.005 250deg); // primary text, primary button fills
155
+ --ink-2: oklch(32% 0.005 250deg); // secondary text
156
+ --ink-3: oklch(50% 0.005 250deg); // tertiary text, icons
157
+ --ink-4: oklch(65% 0.005 250deg); // disabled, muted labels
158
+ --ink-inverse: oklch(99% 0 0deg); // text on dark / gradient fills
159
+ --ink-hover: oklch(28% 0.005 250deg); // hover shade of --ink-filled controls
160
+
161
+ // Label for a fill of `--ink` itself (the primary button). Distinct from
162
+ // `--ink-inverse`, which is a FIXED near-white for coloured/gradient fills and
163
+ // therefore wrong here: `--ink` flips per theme, so its label must flip too.
164
+
165
+ // CONTRACT — anything that redefines `--ink` MUST redefine `--on-ink`.
166
+ // It must also be OPAQUE. The shell-embed glass remaps in Circles and Thoughts
167
+ // set `--ink` to white and every surface token to a translucent veil; a
168
+ // translucent label composites into the fill and vanishes.
169
+ --on-ink: var(--ink-inverse);
170
+
171
+ // ── Neutrals — borders ───────────────────────────────────────────────────
172
+ --line: oklch(92% 0.004 250deg); // default borders
173
+ --line-2: oklch(88% 0.004 250deg); // hover borders
174
+ --line-3: oklch(96% 0.003 250deg); // inner row dividers
175
+
176
+ // ── Surfaces (opaque by design — see the header note) ────────────────────
177
+ --bg: oklch(98.5% 0.003 100deg); // app background (warm off-white)
178
+ --bg-2: oklch(100% 0 0deg); // cards, table
179
+ --bg-3: oklch(97% 0.004 100deg); // sidebar, table header
180
+ --bg-hover: oklch(96% 0.005 250deg);
181
+
182
+ // ── Brand primitives ─────────────────────────────────────────────────────
183
+ // `--purple` / `--accent` / `--accent-soft` are platform-owned (_theme-*.scss).
184
+ --teal: oklch(74% 0.13 195deg);
185
+ --grad: linear-gradient(
186
+ 135deg,
187
+ oklch(82% 0.12 325deg),
188
+ oklch(60% 0.19 305deg),
189
+ oklch(45% 0.2 285deg)
190
+ );
191
+ --grad-soft: linear-gradient(135deg, oklch(74% 0.13 195deg / 12%), oklch(55% 0.18 295deg / 12%));
192
+
193
+ // ── Accent plate/ink PAIRS ───────────────────────────────────────────────
194
+ // NEVER paint --accent on an --accent-soft plate: that is purple-on-purple,
195
+ // and it is the bug that keeps coming back. Two plates, two inks:
196
+
197
+ // 1. --accent-soft is a TINT the consuming context may redefine (the shell
198
+ // resolves it to `color-mix(… 18%, transparent)`, i.e. a translucent wash
199
+ // over unknowable glass). The only ink correct against it in every mode
200
+ // is the ambient --ink, which each theme already contrasts with its own
201
+ // background.
202
+ --on-accent-soft: var(--ink);
203
+
204
+ // 2. --accent-fill is a SOLID plate we own outright, so its ink is knowable
205
+ // regardless of backdrop — knowable FROM THE PLATE, not "always white".
206
+ // Badges/chips use this pair, so their contrast cannot be broken by a
207
+ // host token override.
208
+ --accent-fill: var(--accent);
209
+
210
+ // FALLBACK RUNG ONLY. The accent-aware derivation at the foot of this file
211
+ // overrides this wherever relative colour syntax parses; this is what an
212
+ // older engine gets, and Nova's theme-invariant `--on-accent` (#fff) is the
213
+ // right answer for the platform violet in both themes.
214
+
215
+ // It is deliberately NOT an alias of `--ink-inverse` any more. Two tokens that
216
+ // happen to agree today are not the same requirement: "ink on a dark/gradient
217
+ // fill" is a constant, "ink on the accent plate" is a function of the accent.
218
+ // Aliasing them made the second one's contrast depend on a token that is not
219
+ // the plate. See the foot of the file for what it depends on now.
220
+ --on-accent-fill: var(--on-accent);
221
+
222
+ // ── Interaction state layers ─────────────────────────────────────────────
223
+ // Translucent veils layered OVER a surface for hover/press/selected instead
224
+ // of swapping its bg colour — one value reads correctly on any background
225
+ // (the visionOS / Material "state layer" model), so hover looks consistent
226
+ // across cards, rows, chips and glass plates alike.
227
+ --state-hover: oklch(50% 0.03 285deg / 5%);
228
+ --state-press: oklch(50% 0.03 285deg / 9%);
229
+ --state-selected: var(--accent-soft);
230
+
231
+ // Bright top rim that gives a translucent plate physical depth. Inert on
232
+ // opaque surfaces; the shell's glass mode leans on it.
233
+ --edge-highlight: oklch(100% 0 0deg / 50%);
234
+
235
+ // ── Icon size ramp ───────────────────────────────────────────────────────
236
+ // ONE scale for every icon (PrimeIcons + inline SVG). Icons had drifted to
237
+ // ~13 different sizes, which is why they never lined up beside each other.
238
+ --icon-xs: 12px; // dense meta rows, chips
239
+ --icon-sm: 14px; // text buttons, section-header actions
240
+ --icon-md: 16px; // default — icon buttons, rails, nav
241
+ --icon-lg: 20px; // emphasis / empty states
242
+
243
+ // ── Status chips (workflow-generic) ──────────────────────────────────────
244
+ --st-published: oklch(55% 0.13 165deg);
245
+ --st-published-bg: oklch(96% 0.04 165deg);
246
+ --st-approved: oklch(55% 0.15 245deg);
247
+ --st-approved-bg: oklch(96% 0.03 245deg);
248
+ --st-draft: oklch(55% 0.005 250deg);
249
+ --st-draft-bg: oklch(95% 0.003 250deg);
250
+ --st-archived: oklch(55% 0.06 60deg);
251
+ --st-archived-bg: oklch(96% 0.025 80deg);
252
+
253
+ // ── Semantic feedback ────────────────────────────────────────────────────
254
+ // Success has no platform equivalent (`--status-success` is a raw dot colour,
255
+ // not a fg/bg pair), so it ships here, aliased onto the published status chip.
256
+ --success: var(--st-published);
257
+ --success-bg: var(--st-published-bg);
258
+
259
+ // Standalone fallbacks for the platform tokens — see the header. Values are
260
+ // copied verbatim from `_theme-light.scss`; keep them in sync if that file
261
+ // changes, or a standalone app drifts from its federated self.
262
+ --danger: #ff6b6b;
263
+ --danger-bg: rgb(255 59 48 / 20%);
264
+ --danger-line: rgb(255 59 48 / 40%);
265
+ --warning: #febc2e;
266
+ --warning-bg: rgb(254 188 46 / 18%);
267
+ --warning-fg: rgb(255 255 255 / 96%);
268
+ --purple: #9333ea;
269
+ --scrim: rgb(0 0 0 / 50%);
270
+ --glass-blur: 24px;
271
+
272
+ // ── Module-card teal (app-home fidelity pass, 2026-08-20) ────────────────
273
+ // The launch-card family (`.ah__card--feature` tile, hover wash, hover ring)
274
+ // is deliberately THEME-FIXED — decoupled from the running `--accent` and
275
+ // from `--sys-teal`'s per-theme flip. Since the app-brand cascade (3.12.0)
276
+ // this pair is the FALLBACK: `fly-app-home` prefers a cascaded
277
+ // `--app-color`/`--app-color-deep` (published by the shell window from the
278
+ // app's authored brand colour), and renders this fixed teal only where no
279
+ // app colour cascades in. Consumers derive the design's alphas via
280
+ // color-mix; only these two literals exist (conventions-spec rule: raw
281
+ // colour values live in this file alone). Declared once in `:root`, no
282
+ // dark-scope redefinition, same as `--ink-inverse`.
283
+ --module-teal: #2896a0;
284
+ --module-teal-deep: #105a5a;
285
+
286
+ // ── Spacing scale (4px base) ─────────────────────────────────────────────
287
+ --sp-1: 4px;
288
+ --sp-2: 8px;
289
+ --sp-3: 12px;
290
+ --sp-4: 16px;
291
+ --sp-5: 22px;
292
+ --sp-6: 28px;
293
+ --sp-7: 40px;
294
+ --sp-8: 56px;
295
+
296
+ // ── Typography scale ─────────────────────────────────────────────────────
297
+ --text-2xs: 11.5px; // field labels, field errors, footer hints
298
+ --text-xs: 11px; // eyebrows, badges, metric labels
299
+ --text-sm: 12.5px; // meta, hints, crumbs
300
+ --text-md: 13.5px; // body in cards, subtitles
301
+ --text-base: 14px; // controls, table body
302
+ --text-lg: 16px; // page body
303
+ --text-xl: 18px; // card titles, dialog titles
304
+ --text-2xl: 28px; // detail page h1
305
+ --text-3xl: 32px; // listing page h1
306
+ --text-display: 44px; // hero title
307
+ --fw-book: 450; // between regular and medium (segmented buttons)
308
+ --fw-medium: 500;
309
+ --fw-semibold: 600;
310
+ --fw-bold: 700;
311
+ --tracking-tight: -0.02em;
312
+ --tracking-wide: 0.06em;
313
+
314
+ // Type families. `--font-family` / `--font-family-mono` are the platform's
315
+ // names; these are the short aliases the component library reads.
316
+ --font-sans: var(--font-family);
317
+
318
+ // Monospace is retired the UI is one sans face throughout. Kept (aliased)
319
+ // rather than deleted so existing `var(--font-mono)` consumers keep
320
+ // resolving; the `.mono` utility still contributes tabular-nums, which is
321
+ // what those id/number treatments actually need.
322
+ --font-mono: var(--font-sans);
323
+
324
+ // ── Radii ────────────────────────────────────────────────────────────────
325
+ --r-sm: 6px; // icon buttons, kbd
326
+ --r-md: 8px; // buttons, inputs, nav items
327
+ --r-lg: 12px; // cards, table wrapper, filter panel
328
+ --r-xl: 16px; // reserved
329
+
330
+ // ── Layout ───────────────────────────────────────────────────────────────
331
+ --row-h: 56px; // table row
332
+
333
+ // ── Z-index scale ────────────────────────────────────────────────────────
334
+ --z-sticky: 10;
335
+ --z-bulkbar: 50;
336
+ --z-overlay: 100; // drawers, scrims
337
+ --z-dialog: 110; // centered dialogs / confirms
338
+ --z-toast: 200;
339
+ --z-tooltip: 1000;
340
+
341
+ // ── Shadows ──────────────────────────────────────────────────────────────
342
+ --shadow-card: 0 1px 2px oklch(0% 0 0deg / 4%);
343
+ --shadow-active: 0 1px 2px oklch(0% 0 0deg / 4%), 0 0 0 1px var(--line);
344
+ --shadow-bulk: 0 6px 20px oklch(0% 0 0deg / 10%);
345
+ --shadow-filter: 0 12px 36px oklch(0% 0 0deg / 6%);
346
+ --shadow-panel: var(--shadow-filter), 0 1px 0 oklch(100% 0 0deg) inset;
347
+ --shadow-raised: 0 1px 2px oklch(0% 0 0deg / 8%);
348
+
349
+ // DERIVED off --accent, never a copy of its hex: a hardcoded purple here
350
+ // would not follow a runtime accent override (FlyThemeService.applyAccent),
351
+ // so you would get the new accent with the old glow beside it.
352
+ --shadow-accent: 0 4px 12px color-mix(in srgb, var(--accent) 25%, transparent);
353
+ --shadow-tooltip: 0 4px 16px oklch(0% 0 0deg / 18%);
354
+ --shadow-focus-soft: 0 0 0 3px oklch(0% 0 0deg / 4%);
355
+ --shadow-drawer: -24px 0 64px oklch(0% 0 0deg / 8%); // end-side panel (LTR)
356
+ --shadow-drawer-rtl: 24px 0 64px oklch(0% 0 0deg / 8%); // mirrored under RTL
357
+ --shadow-dialog:
358
+ 0 24px 64px oklch(18% 0.005 250deg / 18%), 0 8px 24px oklch(18% 0.005 250deg / 10%);
359
+ --shadow-toast:
360
+ 0 12px 32px oklch(18% 0.005 250deg / 14%), 0 4px 12px oklch(18% 0.005 250deg / 8%);
361
+
362
+ // ── Transitions ──────────────────────────────────────────────────────────
363
+ --t-state: 0.12s ease;
364
+ --t-layout: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
365
+ --t-overlay: 0.18s cubic-bezier(0.2, 0.7, 0.2, 1);
366
+ --t-drawer: 0.32s cubic-bezier(0.32, 0.72, 0.2, 1);
367
+ }
368
+
369
+ // ── Dark theme ───────────────────────────────────────────────────────────────
370
+ // Sizing, radii, type, spacing, z-index and transition tokens are theme-neutral
371
+ // and inherit the block above unchanged. Alias-based tokens (--success*,
372
+ // --on-accent-*, --state-selected, --font-*) re-resolve through their targets
373
+ // automatically and are NOT redeclared a second declaration would just be a
374
+ // second place to get it wrong.
375
+ html.dark-theme {
376
+ // The nine platform fallbacks carry the SAME values in both themes upstream,
377
+ // except this one — `_theme-dark.scss` widens the blur. Redeclaring only the
378
+ // token that actually differs keeps the rest inheriting from the block above.
379
+ --glass-blur: 40px;
380
+
381
+ // ── Neutrals (inverted lightness) ────────────────────────────────────────
382
+ --ink: oklch(95% 0.005 250deg);
383
+ --ink-2: oklch(78% 0.005 250deg);
384
+ --ink-3: oklch(62% 0.005 250deg);
385
+ --ink-4: oklch(45% 0.005 250deg);
386
+ --ink-hover: oklch(85% 0.005 250deg);
387
+
388
+ // Flips with --ink above: the primary button is a near-white pill here, so its
389
+ // label goes near-black. Aliased to --bg (the darkest opaque surface in this
390
+ // theme) rather than repeating its literal — see the contract on the :root decl.
391
+ --on-ink: var(--bg);
392
+ --line: oklch(22% 0.004 250deg);
393
+ --line-2: oklch(28% 0.004 250deg);
394
+ --line-3: oklch(18% 0.003 250deg);
395
+ --bg: oklch(14% 0.003 250deg);
396
+ --bg-2: oklch(17% 0.003 250deg);
397
+ --bg-3: oklch(20% 0.004 250deg);
398
+ --bg-hover: oklch(24% 0.005 250deg);
399
+
400
+ // ── Brand — higher chroma/lightness to pop on dark ───────────────────────
401
+ --teal: oklch(78% 0.13 195deg);
402
+ --grad: linear-gradient(
403
+ 135deg,
404
+ oklch(85% 0.12 325deg),
405
+ oklch(70% 0.19 305deg),
406
+ oklch(60% 0.2 285deg)
407
+ );
408
+ --grad-soft: linear-gradient(135deg, oklch(78% 0.13 195deg / 18%), oklch(68% 0.18 295deg / 18%));
409
+
410
+ // ── Interaction state layers — light veil on dark surfaces ───────────────
411
+ --state-hover: oklch(95% 0.02 285deg / 6%);
412
+ --state-press: oklch(95% 0.02 285deg / 11%);
413
+ --edge-highlight: oklch(100% 0 0deg / 6%);
414
+
415
+ // ── Status chips — dim bg, bright fg ─────────────────────────────────────
416
+ --st-published: oklch(85% 0.13 165deg);
417
+ --st-published-bg: oklch(28% 0.06 165deg);
418
+ --st-approved: oklch(82% 0.15 245deg);
419
+ --st-approved-bg: oklch(28% 0.06 245deg);
420
+ --st-draft: var(--ink-2); // same grey as secondary text on dark
421
+ --st-draft-bg: oklch(24% 0.003 250deg);
422
+ --st-archived: oklch(82% 0.06 60deg);
423
+ --st-archived-bg: oklch(28% 0.05 60deg);
424
+
425
+ // ── Shadows deeper opacity on dark surfaces ────────────────────────────
426
+ --shadow-card: 0 1px 2px oklch(0% 0 0deg / 35%);
427
+ --shadow-active: 0 1px 2px oklch(0% 0 0deg / 40%), 0 0 0 1px var(--line);
428
+ --shadow-bulk: 0 6px 20px oklch(0% 0 0deg / 55%);
429
+ --shadow-filter: 0 12px 36px oklch(0% 0 0deg / 50%);
430
+ --shadow-panel: var(--shadow-filter), 0 1px 0 oklch(100% 0 0deg / 5%) inset;
431
+ --shadow-accent: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
432
+ --shadow-focus-soft: 0 0 0 3px oklch(100% 0 0deg / 6%);
433
+ --shadow-drawer: -24px 0 64px oklch(0% 0 0deg / 50%);
434
+ --shadow-drawer-rtl: 24px 0 64px oklch(0% 0 0deg / 50%);
435
+ --shadow-dialog: 0 24px 64px oklch(0% 0 0deg / 60%), 0 8px 24px oklch(0% 0 0deg / 40%);
436
+ --shadow-toast: 0 12px 32px oklch(0% 0 0deg / 50%), 0 4px 12px oklch(0% 0 0deg / 30%);
437
+ }
438
+
439
+ // ── Chrome-token bridge (STANDALONE ONLY) ────────────────────────────────────
440
+ // The older DS components (everything outside lib/app-surface) theme from the
441
+ // SHELL-CHROME family --label-*, --surface-*, --text-color*, --separator* —
442
+ // and each reference carries a hardcoded literal fallback, e.g.
443
+ // `var(--label-primary, #1d1d1f)` or `var(--surface-card, #fff)`. 211 such
444
+ // fallbacks exist across the library.
445
+
446
+ // Those literals are theme-BLIND: a single constant picked by whoever wrote the
447
+ // component. Federated that never shows, because the desktop shell defines the
448
+ // whole chrome family. Standalone, no one does so a business app got whatever
449
+ // constant each component happened to hardcode, in BOTH light and dark.
450
+
451
+ // Aliasing the chrome names onto the app-surface family fixes all 211 sites at
452
+ // once, without touching a single component: the var() resolves through the
453
+ // token and never reaches its literal. The targets are theme-aware, so these
454
+ // follow light/dark automatically.
455
+
456
+ // SELECTOR IS LOAD-BEARING: this block is `:root` ALONE deliberately not
457
+ // `:root, html.light-theme` like the blocks above. The shell declares every one
458
+ // of these under `html.light-theme` / `html.dark-theme` (specificity 0,1,1),
459
+ // which beats a bare `:root` (0,1,0). So federated, the shell's chrome always
460
+ // wins and nothing about the desktop changes; standalone, nothing else declares
461
+ // them and these apply. Widening this selector would create a specificity TIE
462
+ // with the shell and hand the desktop's own chrome to source order.
463
+ :root {
464
+ // Text
465
+ --label-primary: var(--ink);
466
+ --label-secondary: var(--ink-3);
467
+ --label-tertiary: var(--ink-4);
468
+ --text-color: var(--ink);
469
+ --text-color-secondary: var(--ink-3);
470
+
471
+ // Surfaces
472
+ --surface-ground: var(--bg);
473
+ --surface-card: var(--bg-2);
474
+ --surface-section: var(--bg-3);
475
+ --surface-overlay: var(--bg-2);
476
+ --surface-overlay-strong: var(--bg-2);
477
+
478
+ // Lines + interaction states
479
+ --surface-border: var(--line);
480
+ --separator: var(--line);
481
+ --separator-primary: var(--line);
482
+ --surface-hover: var(--bg-hover);
483
+ --surface-active: var(--state-selected);
484
+ }
485
+
486
+ // ── Plate ink, derived from the plate (UX v2, S7.1a) ─────────────────────────
487
+ // `--on-accent-fill` labels a SOLID `--accent-fill` plate: `fly-detail-shell`'s
488
+ // active tab, `badge()`'s default, `fly-pagination`'s current-page pill.
489
+
490
+ // THE FAILURE IS THE EXTERNAL-APP ACCENT, NOT THE EMBED. A fixed near-white is
491
+ // right for the platform violet (5.25:1) and for its `--accent-hi` partner
492
+ // (≈6.9:1 since the 2026-08-17 accent-family re-value; `plate-ink.spec.ts`
493
+ // recomputes it live), and catastrophic for an External App that remaps `--accent` to a
494
+ // light brand colour: white on a pale gold #F5C542 measures 1.58:1, on a cyan
495
+ // #22D3EE 1.76:1. Three tokens are deliberately absent from this file precisely
496
+ // because the accent is the consuming app's to choose (see the header), so this
497
+ // is not a hypothetical it is what the seam is for. The label has to follow
498
+ // the plate's LUMINANCE, which means the plate is what it is computed from.
499
+
500
+ // WHAT S1.11 RECORDED HERE WAS WRONG, and the correction is the reason this is
501
+ // a derivation rather than the one-line repoint that note proposed. The claim
502
+ // was that `app-surface-on-glass` pinning `--ink-inverse` to a dark literal
503
+ // dragged `--on-accent-fill` (then an alias of it) down to ~2.5:1 on #9333EA.
504
+ // It never did. A custom property is substituted at the element that DECLARES
505
+ // it: `--on-accent-fill: var(--ink-inverse)` resolves at `:root`, and the
506
+ // resolved value is what inherits. Every shipped `app-surface-on-glass` call
507
+ // site is a component host far below `:root`, so it re-values `--ink-inverse`
508
+ // for its own subtree and cannot reach an `--on-accent-fill` that was computed
509
+ // above it. Measured in Chromium against the real compiled mixin: inside the
510
+ // mixin `--ink-inverse` is `oklch(18% 0.005 250deg)` while `--on-accent-fill`
511
+ // is still `oklch(99% 0 0deg)`, rendering 5.25:1 identical to outside it.
512
+ // The same rule is why there is no generic `--on-plate` token; see below.
513
+
514
+ // WHY A LIVE `var()` CHAIN AND NOT A BUILD-TIME COMPUTATION. Sass cannot see
515
+ // the input. `--accent` is a runtime knob: `FlyThemeService.applyAccent` writes
516
+ // it as an INLINE style on `<html>` from tenant/user branding, and
517
+ // `isAccentColor` admits anything `CSS.supports('color', …)` does. Any value
518
+ // resolved at compile time is answering a question about a colour that does not
519
+ // exist yet. Same reason `--shadow-accent` is a `color-mix` and not a hex.
520
+
521
+ // MECHANISMS REJECTED.
522
+ // · `contrast-color(var(--accent-fill))` is CSS Color 5 and is literally this
523
+ // feature, but it is not yet carried widely enough to be the only rung — and
524
+ // it splits black/white on the same WCAG luminance the expression below
525
+ // computes, so it would buy a dependency rather than an answer.
526
+ // · `light-dark()` pairs on the THEME. An accent's lightness is independent of
527
+ // the theme (an app may set a pale accent in dark mode), so it answers a
528
+ // different question and fails on exactly the case that motivates this.
529
+ // · `color-mix(… var(--accent) N%, white)` scales lightness monotonically; it
530
+ // cannot invert polarity, so it cannot cross the light/dark accent boundary.
531
+ // · "External Apps override the token" moves the defect into every integrator,
532
+ // silently nothing can gate an app that forgets. The token stays
533
+ // overridable as an escape hatch; it is not the contract.
534
+
535
+ // THE EXPRESSION. Relative colour syntax in `srgb-linear` exposes `r`/`g`/`b`
536
+ // as exactly the linearised channels WCAG's relative-luminance formula takes,
537
+ // so `0.2126r + 0.7152g + 0.0722b` IS that luminance no approximation. An
538
+ // oklch-`l` split is the tempting one-channel version and it is not safe: `l`
539
+ // and WCAG luminance disagree by up to ~0.04 on saturated hues, which near the
540
+ // boundary picks the wrong pole and ships a ~3.7:1 label. `(0.1791 - Y) *
541
+ // infinity` clamped to 0..1 is a step function: 1 (white) below the threshold,
542
+ // 0 (black) above. 0.1791 is where white and black contrast a background
543
+ // EQUALLY, so the worst case over the entire sRGB cube is 4.58:1 — the AA floor
544
+ // here is arithmetic, not a review. The explicit `/ 100%` is load-bearing:
545
+ // relative colour syntax inherits the ORIGIN's alpha when the slot is omitted,
546
+ // and this file's header requires a counter-ink to be OPAQUE or it composites
547
+ // into its own fill. A tenant accent may legitimately carry one `isAccentColor`
548
+ // admits any value `CSS.supports('color', …)` accepts.
549
+
550
+ // TWO RUNGS. Relative colour syntax is Baseline since Firefox 128 (2024-07);
551
+ // anything older keeps the `--on-accent` white declared above, i.e. today's
552
+ // platform-correct behaviour. `@supports` is what makes that a fallback at all:
553
+ // a custom property accepts almost any token sequence at PARSE time, so the
554
+ // usual declare-it-twice ladder does not work here — the later declaration
555
+ // always wins and the failure surfaces at the consumer as an unset `color`.
556
+ // The `infinity` keyword needs no rung of its own; every engine with relative
557
+ // colour syntax predates its own support for it.
558
+
559
+ // THERE IS NO GENERIC `--on-plate` TOKEN, and the substitution rule above is
560
+ // why. A plate that is not the accent `-board`'s presence chips carry a
561
+ // per-user colour from `presenceColorFor`, bound inline per element cannot be
562
+ // served by a `:root` token, because a `:root` declaration substitutes `:root`'s
563
+ // input, not the chip's. Tried and measured: with an `--on-plate` token declared
564
+ // here and its input set on the chip, every chip resolved to the `:root`
565
+ // fallback and nothing changed. A plate the DS cannot know has to be derived in the
566
+ // CONSUMING declaration, on the element that paints it, which is what
567
+ // `canvas-diagram` / `canvas-fishbone` now do. `$_plate-ink` is the one
568
+ // statement of the formula; `plate-ink.spec.ts` holds those copies to it, and
569
+ // evaluates this one at both accent poles rather than pattern-matching it.
570
+
571
+ // Both theme classes are listed rather than leaning on `:root` alone: a future
572
+ // redeclaration of `--on-accent-fill` inside `html.dark-theme` (0,1,1) would
573
+ // otherwise silently outrank a bare `:root` (0,1,0) override.
574
+
575
+ // A Sass-private string, interpolated once per channel: all three take the SAME
576
+ // switch, and written inline Sass would try to evaluate `0.2126 * r` as
577
+ // arithmetic and fail. So this is both the escape hatch and the single place
578
+ // the formula exists it cannot drift between the channels or the tokens.
579
+ $_plate-ink: 'clamp(0, (0.1791 - (0.2126 * r + 0.7152 * g + 0.0722 * b)) * infinity, 1)';
580
+
581
+ // The probe colour is `rgb(0 0 0)`, not a hex, so the identical prelude can be
582
+ // copied verbatim into `design-system-board` — whose own gate bans bare colour
583
+ // literals anywhere in a component stylesheet, prelude and comments included.
584
+ @supports (color: color(from rgb(0 0 0) srgb-linear r g b)) {
585
+ :root,
586
+ html.light-theme,
587
+ html.dark-theme {
588
+ --on-accent-fill: color(
589
+ from var(--accent-fill) srgb-linear #{$_plate-ink} #{$_plate-ink} #{$_plate-ink} / 100%
590
+ );
591
+ }
592
+ }