@adea-ai/ui 0.124.0 → 0.127.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.
Files changed (82) hide show
  1. package/dist/NOTICE +58 -0
  2. package/dist/components/theme/index.d.ts +1 -1
  3. package/dist/components/theme/index.d.ts.map +1 -1
  4. package/dist/components/theme/index.js +4 -3
  5. package/dist/components/theme/theme-document.d.ts +31 -1
  6. package/dist/components/theme/theme-document.d.ts.map +1 -1
  7. package/dist/components/theme/theme-document.js +25 -5
  8. package/dist/components/theme/theme-document.js.map +1 -1
  9. package/dist/components/theme/theme-persistence.d.ts +87 -0
  10. package/dist/components/theme/theme-persistence.d.ts.map +1 -1
  11. package/dist/components/theme/theme-persistence.js +65 -3
  12. package/dist/components/theme/theme-persistence.js.map +1 -1
  13. package/dist/components/theme/theme-provider.d.ts +49 -40
  14. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  15. package/dist/components/theme/theme-provider.js +67 -52
  16. package/dist/components/theme/theme-provider.js.map +1 -1
  17. package/dist/components/theme/theme-script.d.ts +100 -0
  18. package/dist/components/theme/theme-script.d.ts.map +1 -0
  19. package/dist/components/theme/theme-script.js +57 -0
  20. package/dist/components/theme/theme-script.js.map +1 -0
  21. package/dist/components/theme/theme-state.d.ts +85 -2
  22. package/dist/components/theme/theme-state.d.ts.map +1 -1
  23. package/dist/components/theme/theme-state.js +41 -2
  24. package/dist/components/theme/theme-state.js.map +1 -1
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +9 -4
  28. package/dist/lib/accent-roles.d.ts +124 -0
  29. package/dist/lib/accent-roles.d.ts.map +1 -0
  30. package/dist/lib/accent-roles.js +163 -0
  31. package/dist/lib/accent-roles.js.map +1 -0
  32. package/dist/lib/font-zoom.d.ts +25 -0
  33. package/dist/lib/font-zoom.d.ts.map +1 -0
  34. package/dist/lib/font-zoom.js +45 -0
  35. package/dist/lib/font-zoom.js.map +1 -0
  36. package/dist/lib/preference-store.d.ts +126 -0
  37. package/dist/lib/preference-store.d.ts.map +1 -0
  38. package/dist/lib/preference-store.js +126 -0
  39. package/dist/lib/preference-store.js.map +1 -0
  40. package/dist/lib/theme-surface.d.ts +71 -0
  41. package/dist/lib/theme-surface.d.ts.map +1 -0
  42. package/dist/lib/theme-surface.js +62 -0
  43. package/dist/lib/theme-surface.js.map +1 -0
  44. package/dist/lib/themes.d.ts +32 -1
  45. package/dist/lib/themes.d.ts.map +1 -1
  46. package/dist/lib/themes.js +34 -13
  47. package/dist/lib/themes.js.map +1 -1
  48. package/dist/lib/tokens.d.ts.map +1 -1
  49. package/dist/lib/tokens.js +15 -0
  50. package/dist/lib/tokens.js.map +1 -1
  51. package/dist/r/lib.json +20 -0
  52. package/dist/r/registry.json +25 -0
  53. package/dist/r/src/components/theme/index.ts +6 -0
  54. package/dist/r/src/components/theme/theme-document.ts +69 -4
  55. package/dist/r/src/components/theme/theme-persistence.ts +172 -2
  56. package/dist/r/src/components/theme/theme-provider.tsx +137 -51
  57. package/dist/r/src/components/theme/theme-script.ts +201 -0
  58. package/dist/r/src/components/theme/theme-state.ts +124 -3
  59. package/dist/r/src/lib/accent-roles.ts +338 -0
  60. package/dist/r/src/lib/font-zoom.ts +74 -0
  61. package/dist/r/src/lib/preference-store.ts +216 -0
  62. package/dist/r/src/lib/theme-surface.ts +110 -0
  63. package/dist/r/src/lib/themes.ts +58 -19
  64. package/dist/r/src/lib/tokens.ts +17 -0
  65. package/dist/r/src/styles/theme.css +38 -0
  66. package/dist/r/theming.json +5 -0
  67. package/package.json +1 -1
  68. package/registry.json +25 -0
  69. package/src/components/theme/index.ts +6 -0
  70. package/src/components/theme/theme-document.ts +69 -4
  71. package/src/components/theme/theme-persistence.ts +172 -2
  72. package/src/components/theme/theme-provider.tsx +137 -51
  73. package/src/components/theme/theme-script.ts +201 -0
  74. package/src/components/theme/theme-state.ts +124 -3
  75. package/src/index.ts +34 -0
  76. package/src/lib/accent-roles.ts +338 -0
  77. package/src/lib/font-zoom.ts +74 -0
  78. package/src/lib/preference-store.ts +216 -0
  79. package/src/lib/theme-surface.ts +110 -0
  80. package/src/lib/themes.ts +58 -19
  81. package/src/lib/tokens.ts +17 -0
  82. package/src/styles/theme.css +38 -0
@@ -0,0 +1,110 @@
1
+ /*
2
+ * Copyright (c) 2026 Wing
3
+ * Licensed under the MIT License.
4
+ *
5
+ * `resolveSurface` is Zeron's `SurfacePreference.resolve` (https://github.com/zeronsh/zeron),
6
+ * extended by Adea's accepted port of it (`packages/ui/src/components/appearance.ts`)
7
+ * with the translucent capability gate and the reduced-transparency policy. Moved
8
+ * here so the shared provider and Adea run one implementation. See NOTICE.
9
+ */
10
+
11
+ /**
12
+ * The surface axis, and the policy that decides what it paints.
13
+ *
14
+ * A surface is how opaque the window's elevated panels are: `opaque` is the plain
15
+ * canvas, `frosted` is a tokenized translucent fill over a blur (works everywhere,
16
+ * in CSS alone), and `translucent` asks for the host window's own vibrancy, which only
17
+ * a desktop shell can honour.
18
+ *
19
+ * Nothing here touches the document or a signal. The provider reads the operating
20
+ * system and the host's capability, calls {@link resolveSurface}, and
21
+ * `theme-document.ts` writes the answer; the rule itself is a function of its four
22
+ * inputs, which is what makes it testable.
23
+ */
24
+ export type ThemeSurface = 'opaque' | 'frosted' | 'translucent'
25
+
26
+ /** Every surface, in the order a picker offers them. */
27
+ export const themeSurfaces: readonly ThemeSurface[] = ['opaque', 'frosted', 'translucent']
28
+
29
+ /** The media query the operating system's Reduce Transparency setting answers. */
30
+ export const REDUCED_TRANSPARENCY_QUERY = '(prefers-reduced-transparency: reduce)'
31
+
32
+ /**
33
+ * What the world says about transparency, as opposed to what the user prefers.
34
+ */
35
+ export type SurfaceEnvironment = Readonly<{
36
+ /** The operating system asks for less transparency. */
37
+ osReducedTransparency: boolean
38
+ /** The user turned reduced transparency on inside the application. */
39
+ userReducedTransparency: boolean
40
+ /** The host window can render OS translucency (vibrancy, acrylic). */
41
+ nativeTranslucency: boolean
42
+ }>
43
+
44
+ /**
45
+ * A stored value as a surface, or nothing.
46
+ *
47
+ * `undefined` rather than `opaque` for an unreadable value: the selection field is
48
+ * optional, and an absent field is how a store that never chose a surface stays
49
+ * byte-identical to what it was before the axis existed.
50
+ */
51
+ export function normalizeSurface(value: unknown): ThemeSurface | undefined {
52
+ return value === 'opaque' || value === 'frosted' || value === 'translucent' ? value : undefined
53
+ }
54
+
55
+ /**
56
+ * The surface to paint: the preference, after the accessibility override and the
57
+ * host's capability.
58
+ *
59
+ * The OS setting or the user's own flag forces an accessible opaque fallback. A
60
+ * translucent request on a host without native translucency renders the tokenized
61
+ * frosted surface instead of pretending to OS vibrancy.
62
+ */
63
+ export function resolveSurface(
64
+ preference: ThemeSurface,
65
+ environment: SurfaceEnvironment
66
+ ): ThemeSurface {
67
+ if (environment.osReducedTransparency || environment.userReducedTransparency) return 'opaque'
68
+ if (preference === 'translucent' && !environment.nativeTranslucency) return 'frosted'
69
+ return preference
70
+ }
71
+
72
+ /**
73
+ * The fill opacity each surface paints, written as `--surface-alpha`.
74
+ *
75
+ * These are the values `--surface-frost` and `--surface-translucent` declare in
76
+ * `theme.css` (the stylesheet is the readable copy; `tests/theme-surface.test.ts`
77
+ * fails when the two disagree). They are numbers rather than `var()` references so
78
+ * the property reads the same to a script, a test and the stylesheet, whichever
79
+ * wrote it.
80
+ */
81
+ export const surfaceAlpha: Readonly<Record<ThemeSurface, string>> = {
82
+ opaque: '1',
83
+ frosted: '0.92',
84
+ translucent: '0.8',
85
+ }
86
+
87
+ /** What a selection paints for the surface axis. */
88
+ export type SurfaceState = Readonly<{
89
+ /** The surface after the accessibility override and capability gate. */
90
+ surface: ThemeSurface
91
+ /** True while the OS or the user forces opaque surfaces. */
92
+ reduceTransparencyActive: boolean
93
+ }>
94
+
95
+ /**
96
+ * The surface state for a preference and an environment.
97
+ *
98
+ * A preference of `undefined` is `opaque`: the axis is optional on a selection, and
99
+ * a host that never set it gets the plain canvas.
100
+ */
101
+ export function resolveSurfaceState(
102
+ preference: ThemeSurface | undefined,
103
+ environment: SurfaceEnvironment
104
+ ): SurfaceState {
105
+ return {
106
+ surface: resolveSurface(preference ?? 'opaque', environment),
107
+ reduceTransparencyActive:
108
+ environment.osReducedTransparency || environment.userReducedTransparency,
109
+ }
110
+ }
@@ -36,7 +36,6 @@
36
36
  import {
37
37
  ACCENTS,
38
38
  accentForeground,
39
- accentRoles,
40
39
  chartSeries,
41
40
  getAccent,
42
41
  getTheme,
@@ -58,6 +57,7 @@ import {
58
57
  } from '@adea-ai/themes'
59
58
  import { shadcnVariables } from '@adea-ai/themes/adapters/shadcn'
60
59
  import { oklchToHex, parseColor } from '@adea-ai/themes/oklch'
60
+ import { accentOverrideVariables, resolveAccentRoles, type AccentRoles } from './accent-roles'
61
61
  import { isThemeAccentId } from './theme-accents'
62
62
 
63
63
  export { isThemeAccentId } from './theme-accents'
@@ -566,26 +566,51 @@ export function accentVariables(
566
566
  pair: { lightThemeId: string; darkThemeId: string } = {
567
567
  lightThemeId: defaultLightThemeId,
568
568
  darkThemeId: defaultDarkThemeId,
569
- }
569
+ },
570
+ options: AccentVariableOptions = {}
570
571
  ): Record<string, string> | undefined {
572
+ const roles = accentRolesForSelection(accentId, appearance, pair, options)
573
+ return roles ? accentOverrideVariables(roles) : undefined
574
+ }
575
+
576
+ /** What an accent is held to when it is resolved against a surface. */
577
+ export type AccentVariableOptions = {
578
+ /** The surface the accent must read on: the variant's `colors.background`. */
579
+ background?: string
580
+ /**
581
+ * The contrast floor against `background`. Absent, a preset is painted verbatim
582
+ * and only a custom `#rrggbb` is held to the default floor.
583
+ */
584
+ floor?: number
585
+ /** The variant the accent is applied to, so the tint takes its measured strength. */
586
+ variantId?: string
587
+ }
588
+
589
+ /**
590
+ * An accent selection resolved to the full {@link AccentRoles}, against a theme
591
+ * pair. {@link accentVariables} is the document half of this; an element-scoped
592
+ * paint (`paintAccentRoles`) wants all of it, including the tint.
593
+ */
594
+ export function accentRolesForSelection(
595
+ accentId: string,
596
+ appearance: ThemeAppearance,
597
+ pair: { lightThemeId: string; darkThemeId: string } = {
598
+ lightThemeId: defaultLightThemeId,
599
+ darkThemeId: defaultDarkThemeId,
600
+ },
601
+ options: AccentVariableOptions = {}
602
+ ): AccentRoles | undefined {
571
603
  if (accentId === 'theme') return undefined
572
- const { light, dark } = recordPair(pair.lightThemeId, pair.darkThemeId)
573
- const preset = resolveAccentPreset(accentId, light, dark)
574
- if (!preset) return undefined
575
-
576
- // `--primary-subtle` is deliberately not here. It is a `color-mix()` over
577
- // `var(--primary)`, so the theme's own declaration (`themeCssVariables`, or the
578
- // default block in `theme.css`) already follows the accent; and its strength is the
579
- // *theme's*, measured by `primarySubtleAlpha` to hold body text at 4.5:1 for every
580
- // accent the theme offers. Writing one here would replace that measured strength
581
- // with whatever this function was given, which is how an accent used to undo it.
582
- const roles = accentRoles(preset, appearance)
583
- return {
584
- '--primary': roles.primary,
585
- '--primary-foreground': roles.primaryForeground,
586
- '--primary-hover': roles.primaryHover,
587
- '--ring': roles.ring,
588
- }
604
+ const record = options.variantId ? getTheme(options.variantId) : undefined
605
+ return resolveAccentRoles(accentId, {
606
+ target: record ?? appearance,
607
+ background: options.background,
608
+ floor: options.floor,
609
+ // Only an `ansi-*` id needs the pair's offering, and measuring it is not free.
610
+ themeAccents: isThemeAccentId(accentId)
611
+ ? themeAccentsFor(pair.lightThemeId, pair.darkThemeId)
612
+ : undefined,
613
+ })
589
614
  }
590
615
 
591
616
  /**
@@ -708,6 +733,20 @@ export function themeCssVariables(theme: ThemeVariant): Record<string, string> {
708
733
  return theme.tokens ? { ...derived, ...theme.tokens } : derived
709
734
  }
710
735
 
736
+ /**
737
+ * The `--terminal-*` properties of a variant: the palette a terminal paints.
738
+ *
739
+ * What a pinned terminal theme writes over the interface variant's own. Taken from
740
+ * {@link themeCssVariables} rather than rebuilt, so the names, and a custom
741
+ * variant's verbatim `tokens`, can never drift from what the interface variant
742
+ * would have written for the same palette.
743
+ */
744
+ export function terminalCssVariables(theme: ThemeVariant): Record<string, string> {
745
+ return Object.fromEntries(
746
+ Object.entries(themeCssVariables(theme)).filter(([name]) => name.startsWith('--terminal-'))
747
+ )
748
+ }
749
+
711
750
  /**
712
751
  * Token values with every `oklch()` colour written as `#rrggbb`.
713
752
  *
@@ -122,6 +122,23 @@ export const colorTokens: TokenDefinition[] = [
122
122
  themed: true,
123
123
  description: 'Opacity of the chrome fill behind a blur.',
124
124
  },
125
+ {
126
+ name: 'surface-alpha',
127
+ kind: 'number',
128
+ description:
129
+ "The opacity a frosted dialog's fill keeps, written by the provider's surface axis. 1 is opaque and never matches the frost rule.",
130
+ },
131
+ {
132
+ name: 'surface-frost',
133
+ kind: 'number',
134
+ description: 'The fill opacity `--surface-alpha` takes for the frosted surface: 0.92.',
135
+ },
136
+ {
137
+ name: 'surface-translucent',
138
+ kind: 'number',
139
+ description:
140
+ 'The fill opacity `--surface-alpha` takes for the translucent surface: 0.8. A host without native translucency paints frosted instead.',
141
+ },
125
142
 
126
143
  // The brand action
127
144
  {
@@ -637,6 +637,15 @@
637
637
  --z-menu: 140;
638
638
  --z-tooltip: 160;
639
639
  --z-toast: 180;
640
+
641
+ /* Surface glass. `--surface-alpha` is what the provider's surface axis writes: the
642
+ opacity a frosted dialog's fill keeps (1 = opaque, so the default never matches
643
+ the frost rule's blur). `--surface-frost` and `--surface-translucent` are the two
644
+ amounts it chooses between; `lib/theme-surface.ts` writes the same numbers, and
645
+ `tests/theme-surface.test.ts` fails when they disagree. */
646
+ --surface-alpha: 1;
647
+ --surface-frost: 0.92;
648
+ --surface-translucent: 0.8;
640
649
  }
641
650
 
642
651
  /* Compact density: the standard control ladder one rung tighter.
@@ -687,6 +696,35 @@
687
696
  --shadow-xl: 0 20px 44px oklch(0 0 0 / 0.45);
688
697
  }
689
698
 
699
+ /* The OS-level backstop for the surface axis. The provider resolves the effective
700
+ surface and the reduced-transparency policy and writes `--surface-alpha` inline,
701
+ which outranks this rule; this is what keeps a frost amount from outliving the
702
+ system's Reduce Transparency setting for a document the provider has not
703
+ reached yet (a server-rendered one, before hydration). */
704
+ @media (prefers-reduced-transparency: reduce) {
705
+ :root {
706
+ --surface-alpha: 1;
707
+ }
708
+ }
709
+
710
+ /* Tokenized frost: while the resolved surface asks for glass, a modal dialog goes
711
+ translucent and blurs the content behind it. The opaque default and both
712
+ reduced-transparency paths resolve `--surface-alpha` to 1 and never write
713
+ `data-surface='frosted'`, so frost only ever applies when the user chose it and
714
+ the platform can honour it. Native window vibrancy (a desktop shell's translucent
715
+ surface) remains the shell's work; this is the frost the surface preference
716
+ promises everywhere else.
717
+
718
+ Modal dialogs only: a sheet carries `data-variant` and keeps its own bands. */
719
+ [data-surface='frosted'] [role='dialog']:not([data-variant]) {
720
+ background-color: color-mix(
721
+ in srgb,
722
+ var(--background) calc(var(--surface-alpha) * 100%),
723
+ transparent
724
+ );
725
+ backdrop-filter: blur(1.25rem) saturate(140%);
726
+ }
727
+
690
728
  /* ---------------------------------------------------------------------------
691
729
  * 4b. Named CSS hooks
692
730
  *
@@ -58,6 +58,11 @@
58
58
  "type": "registry:ui",
59
59
  "target": "components/theming/theme-provider.tsx"
60
60
  },
61
+ {
62
+ "path": "src/components/theme/theme-script.ts",
63
+ "type": "registry:ui",
64
+ "target": "components/theming/theme-script.ts"
65
+ },
61
66
  {
62
67
  "path": "src/components/theme/theme-state.ts",
63
68
  "type": "registry:ui",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adea-ai/ui",
3
- "version": "0.124.0",
3
+ "version": "0.127.0",
4
4
  "description": "The Adea design system: Kobalte-backed Solid components, semantic OKLCH tokens, and the app-shell layout primitives shared by Adea and Cortana.",
5
5
  "keywords": [
6
6
  "adea",
package/registry.json CHANGED
@@ -2557,6 +2557,11 @@
2557
2557
  "type": "registry:ui",
2558
2558
  "target": "components/theming/theme-provider.tsx"
2559
2559
  },
2560
+ {
2561
+ "path": "src/components/theme/theme-script.ts",
2562
+ "type": "registry:ui",
2563
+ "target": "components/theming/theme-script.ts"
2564
+ },
2560
2565
  {
2561
2566
  "path": "src/components/theme/theme-state.ts",
2562
2567
  "type": "registry:ui",
@@ -2644,6 +2649,11 @@
2644
2649
  ],
2645
2650
  "registryDependencies": [],
2646
2651
  "files": [
2652
+ {
2653
+ "path": "src/lib/accent-roles.ts",
2654
+ "type": "registry:lib",
2655
+ "target": "lib/accent-roles.ts"
2656
+ },
2647
2657
  {
2648
2658
  "path": "src/lib/appearance-font-settings.ts",
2649
2659
  "type": "registry:lib",
@@ -2669,6 +2679,11 @@
2669
2679
  "type": "registry:lib",
2670
2680
  "target": "lib/font-catalog.ts"
2671
2681
  },
2682
+ {
2683
+ "path": "src/lib/font-zoom.ts",
2684
+ "type": "registry:lib",
2685
+ "target": "lib/font-zoom.ts"
2686
+ },
2672
2687
  {
2673
2688
  "path": "src/lib/form-field.ts",
2674
2689
  "type": "registry:lib",
@@ -2684,6 +2699,11 @@
2684
2699
  "type": "registry:lib",
2685
2700
  "target": "lib/overlay.ts"
2686
2701
  },
2702
+ {
2703
+ "path": "src/lib/preference-store.ts",
2704
+ "type": "registry:lib",
2705
+ "target": "lib/preference-store.ts"
2706
+ },
2687
2707
  {
2688
2708
  "path": "src/lib/safe-script.ts",
2689
2709
  "type": "registry:lib",
@@ -2694,6 +2714,11 @@
2694
2714
  "type": "registry:lib",
2695
2715
  "target": "lib/theme-accents.ts"
2696
2716
  },
2717
+ {
2718
+ "path": "src/lib/theme-surface.ts",
2719
+ "type": "registry:lib",
2720
+ "target": "lib/theme-surface.ts"
2721
+ },
2697
2722
  {
2698
2723
  "path": "src/lib/themes.ts",
2699
2724
  "type": "registry:lib",
@@ -9,6 +9,7 @@ export {
9
9
  } from './theme-picker'
10
10
  export {
11
11
  ThemeProvider,
12
+ createVersionedPersistence,
12
13
  defaultThemeSelection,
13
14
  inlineScriptLiteral,
14
15
  localStoragePersistence,
@@ -20,7 +21,12 @@ export {
20
21
  type ThemeDensity,
21
22
  type ThemePersistence,
22
23
  type ThemeProviderProps,
24
+ type ThemeScriptOptions,
23
25
  type ThemeSelection,
26
+ type ThemeStorageSchema,
27
+ type ThemeSurface,
28
+ type VersionedCodec,
29
+ type VersionedPersistenceOptions,
24
30
  } from './theme-provider'
25
31
  export { ThemeToggle, type ThemeToggleProps } from './theme-toggle'
26
32
  export { ThemeModeToggle, type ThemeModeToggleProps } from './theme-mode-toggle'
@@ -4,10 +4,14 @@ import {
4
4
  } from '#lib/appearance-font-settings'
5
5
  import {
6
6
  accentVariables,
7
+ terminalCssVariables,
8
+ themeById,
7
9
  themeCssVariables,
8
10
  type ThemeAppearance,
9
11
  type ThemeVariant,
10
12
  } from '#lib/themes'
13
+ import { isCustomAccent } from '#lib/accent-roles'
14
+ import { surfaceAlpha, type SurfaceState } from '#lib/theme-surface'
11
15
  import { resolvedDensity, type ThemeSelection } from './theme-state'
12
16
 
13
17
  /**
@@ -26,6 +30,16 @@ export type ThemeDocumentState = {
26
30
  selection: ThemeSelection
27
31
  appearance: ThemeAppearance
28
32
  variant: ThemeVariant
33
+ /**
34
+ * The variant whose terminal palette is pinned over the interface variant's.
35
+ * Absent, the terminal follows the interface theme.
36
+ */
37
+ terminalVariant?: ThemeVariant
38
+ /**
39
+ * The resolved surface axis. Absent, the axis is off: no attribute and no
40
+ * `--surface-alpha` is written, and any this writer wrote before is removed.
41
+ */
42
+ surface?: SurfaceState
29
43
  }
30
44
 
31
45
  export type ThemeDocumentOptions = {
@@ -35,11 +49,35 @@ export type ThemeDocumentOptions = {
35
49
  * must return the full map; a name it drops is not written.
36
50
  */
37
51
  transformTokens?: (tokens: Record<string, string>) => Record<string, string>
52
+ /**
53
+ * The contrast an accent is held to against the variant's background. Absent, a
54
+ * preset is painted verbatim (the catalogue measured it for its own themes) and
55
+ * only a custom `#rrggbb` is held to the default floor of 3:1.
56
+ */
57
+ accentContrastFloor?: number
58
+ /**
59
+ * Where a catalogue variant's palette comes from. `inline` (the default) writes
60
+ * every role onto the root. `stylesheet` writes none for a catalogue variant,
61
+ * because the host loads a stylesheet that carries a `[data-theme]` block for it;
62
+ * a variant the catalogue does not hold (a host's imported theme) is still written
63
+ * inline, since no stylesheet can know it. The accent, the terminal pin, the
64
+ * surface alpha and the fonts are written the same in both.
65
+ *
66
+ * Switching from an imported variant back to a catalogue one removes what the
67
+ * imported one wrote, so nothing inline outlives it to beat the stylesheet.
68
+ */
69
+ palette?: 'inline' | 'stylesheet'
70
+ }
71
+
72
+ /** True for the catalogue's own variant, not a host's copy that merely shares its id. */
73
+ function isCatalogueVariant(variant: ThemeVariant): boolean {
74
+ return themeById(variant.id) === variant
38
75
  }
39
76
 
40
77
  /**
41
78
  * The custom properties a state paints: the variant's roles, with the accent
42
- * selection laid over them, then `transformTokens`.
79
+ * selection laid over them, the pinned terminal palette over that, the surface
80
+ * alpha, then `transformTokens`.
43
81
  *
44
82
  * The accent is applied *here* rather than left to the `[data-accent]` blocks in
45
83
  * `theme.css`, because the properties are written inline and an inline declaration
@@ -52,9 +90,20 @@ export function themeDocumentTokens(
52
90
  state: ThemeDocumentState,
53
91
  options: ThemeDocumentOptions = {}
54
92
  ): { tokens: Record<string, string>; accented: boolean } {
55
- const properties = { ...themeCssVariables(state.variant) }
56
- const accent = accentVariables(state.selection.accent, state.appearance, state.selection)
93
+ const fromStylesheet = options.palette === 'stylesheet' && isCatalogueVariant(state.variant)
94
+ const properties = fromStylesheet ? {} : { ...themeCssVariables(state.variant) }
95
+ const accent = accentVariables(state.selection.accent, state.appearance, state.selection, {
96
+ background: state.variant.colors.background,
97
+ floor: options.accentContrastFloor,
98
+ variantId: state.variant.id,
99
+ })
57
100
  if (accent) Object.assign(properties, accent)
101
+ // Written after the variant and the accent, so it wins over the interface
102
+ // variant's own terminal ramp (and over the stylesheet's, which it outranks as an
103
+ // inline declaration). Dropping the pin needs no removal of its own: the ramp is
104
+ // then rewritten by the variant, or swept back to the stylesheet.
105
+ if (state.terminalVariant) Object.assign(properties, terminalCssVariables(state.terminalVariant))
106
+ if (state.surface) properties['--surface-alpha'] = surfaceAlpha[state.surface.surface]
58
107
  return {
59
108
  tokens: options.transformTokens ? options.transformTokens(properties) : properties,
60
109
  accented: accent !== undefined,
@@ -92,8 +141,24 @@ export function applyThemeToRoot(
92
141
  root.dataset['appearance'] = state.appearance
93
142
  root.style.colorScheme = state.appearance
94
143
 
144
+ // A custom colour has no stylesheet block to select, and its value is the
145
+ // attribute's worst possible content, so it is reported as `custom`.
95
146
  if (!accented) root.removeAttribute('data-accent')
96
- else root.dataset['accent'] = state.selection.accent
147
+ else
148
+ root.dataset['accent'] = isCustomAccent(state.selection.accent)
149
+ ? 'custom'
150
+ : state.selection.accent
151
+
152
+ // The surface axis is opt-in, so its absence removes rather than defaults: a host
153
+ // that never enables it keeps an `<html>` with neither attribute, and one that
154
+ // turns it off leaves nothing behind. (`--surface-alpha` is swept with the tokens.)
155
+ if (state.surface) {
156
+ root.dataset['surface'] = state.surface.surface
157
+ root.dataset['reduceTransparency'] = state.surface.reduceTransparencyActive ? 'true' : 'false'
158
+ } else {
159
+ root.removeAttribute('data-surface')
160
+ root.removeAttribute('data-reduce-transparency')
161
+ }
97
162
 
98
163
  applyAppearanceFontSettings(
99
164
  root,