@adea-ai/ui 0.123.1 → 0.125.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 (64) hide show
  1. package/dist/NOTICE +36 -0
  2. package/dist/components/theme/appearance-panel.js +1 -1
  3. package/dist/components/theme/index.d.ts +1 -1
  4. package/dist/components/theme/index.d.ts.map +1 -1
  5. package/dist/components/theme/index.js +5 -2
  6. package/dist/components/theme/theme-context.d.ts +13 -0
  7. package/dist/components/theme/theme-context.d.ts.map +1 -0
  8. package/dist/components/theme/theme-context.js +41 -0
  9. package/dist/components/theme/theme-context.js.map +1 -0
  10. package/dist/components/theme/theme-document.d.ts +82 -0
  11. package/dist/components/theme/theme-document.d.ts.map +1 -0
  12. package/dist/components/theme/theme-document.js +98 -0
  13. package/dist/components/theme/theme-document.js.map +1 -0
  14. package/dist/components/theme/theme-persistence.d.ts +50 -0
  15. package/dist/components/theme/theme-persistence.d.ts.map +1 -0
  16. package/dist/components/theme/theme-persistence.js +87 -0
  17. package/dist/components/theme/theme-persistence.js.map +1 -0
  18. package/dist/components/theme/theme-provider.d.ts +52 -75
  19. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  20. package/dist/components/theme/theme-provider.js +60 -116
  21. package/dist/components/theme/theme-provider.js.map +1 -1
  22. package/dist/components/theme/theme-state.d.ts +167 -0
  23. package/dist/components/theme/theme-state.d.ts.map +1 -0
  24. package/dist/components/theme/theme-state.js +122 -0
  25. package/dist/components/theme/theme-state.js.map +1 -0
  26. package/dist/components/theme/theme-toggle.js +1 -1
  27. package/dist/index.d.ts +2 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +7 -3
  30. package/dist/lib/accent-roles.d.ts +124 -0
  31. package/dist/lib/accent-roles.d.ts.map +1 -0
  32. package/dist/lib/accent-roles.js +163 -0
  33. package/dist/lib/accent-roles.js.map +1 -0
  34. package/dist/lib/appearance-font-settings.d.ts +1 -1
  35. package/dist/lib/appearance-font-settings.d.ts.map +1 -1
  36. package/dist/lib/appearance-font-settings.js.map +1 -1
  37. package/dist/lib/themes.d.ts +71 -1
  38. package/dist/lib/themes.d.ts.map +1 -1
  39. package/dist/lib/themes.js +172 -48
  40. package/dist/lib/themes.js.map +1 -1
  41. package/dist/r/lib.json +5 -0
  42. package/dist/r/registry.json +25 -0
  43. package/dist/r/src/components/theme/index.ts +3 -0
  44. package/dist/r/src/components/theme/theme-context.ts +43 -0
  45. package/dist/r/src/components/theme/theme-document.ts +184 -0
  46. package/dist/r/src/components/theme/theme-persistence.ts +143 -0
  47. package/dist/r/src/components/theme/theme-provider.tsx +147 -253
  48. package/dist/r/src/components/theme/theme-state.ts +274 -0
  49. package/dist/r/src/lib/accent-roles.ts +338 -0
  50. package/dist/r/src/lib/appearance-font-settings.ts +1 -1
  51. package/dist/r/src/lib/themes.ts +225 -59
  52. package/dist/r/theming.json +20 -0
  53. package/package.json +1 -1
  54. package/registry.json +25 -0
  55. package/src/components/theme/index.ts +3 -0
  56. package/src/components/theme/theme-context.ts +43 -0
  57. package/src/components/theme/theme-document.ts +184 -0
  58. package/src/components/theme/theme-persistence.ts +143 -0
  59. package/src/components/theme/theme-provider.tsx +147 -253
  60. package/src/components/theme/theme-state.ts +274 -0
  61. package/src/index.ts +11 -0
  62. package/src/lib/accent-roles.ts +338 -0
  63. package/src/lib/appearance-font-settings.ts +1 -1
  64. package/src/lib/themes.ts +225 -59
@@ -0,0 +1,184 @@
1
+ import {
2
+ applyAppearanceFontSettings,
3
+ normalizeAppearanceEditorFontSettings,
4
+ } from '../../lib/appearance-font-settings'
5
+ import {
6
+ accentVariables,
7
+ themeCssVariables,
8
+ type ThemeAppearance,
9
+ type ThemeVariant,
10
+ } from '../../lib/themes'
11
+ import { isCustomAccent } from '../../lib/accent-roles'
12
+ import { resolvedDensity, type ThemeSelection } from './theme-state'
13
+
14
+ /**
15
+ * The document half of the theme provider: the single writer of `<html>`.
16
+ *
17
+ * Written against the few members of an element it uses, so the rules can be tested
18
+ * on a stand-in and the provider's effect stays a one-line call.
19
+ */
20
+ export type ThemeRoot = Pick<
21
+ HTMLElement,
22
+ 'classList' | 'dataset' | 'style' | 'removeAttribute' | 'setAttribute'
23
+ >
24
+
25
+ export type ThemeDocumentState = {
26
+ /** What the document shows: the committed selection, or a preview laid over it. */
27
+ selection: ThemeSelection
28
+ appearance: ThemeAppearance
29
+ variant: ThemeVariant
30
+ }
31
+
32
+ export type ThemeDocumentOptions = {
33
+ /**
34
+ * Rewrites the custom properties just before they are written: the variant's
35
+ * roles with the accent laid over them. It sees every property of the pass and
36
+ * must return the full map; a name it drops is not written.
37
+ */
38
+ transformTokens?: (tokens: Record<string, string>) => Record<string, string>
39
+ /**
40
+ * The contrast an accent is held to against the variant's background. Absent, a
41
+ * preset is painted verbatim (the catalogue measured it for its own themes) and
42
+ * only a custom `#rrggbb` is held to the default floor of 3:1.
43
+ */
44
+ accentContrastFloor?: number
45
+ }
46
+
47
+ /**
48
+ * The custom properties a state paints: the variant's roles, with the accent
49
+ * selection laid over them, then `transformTokens`.
50
+ *
51
+ * The accent is applied *here* rather than left to the `[data-accent]` blocks in
52
+ * `theme.css`, because the properties are written inline and an inline declaration
53
+ * outranks every stylesheet selector however specific it is. The blocks still exist
54
+ * and are still correct — they are what styles the window before this runs — but
55
+ * they cannot win from here, so leaving the accent to them meant the attribute was
56
+ * set, the rules were present, and nothing changed on screen.
57
+ */
58
+ export function themeDocumentTokens(
59
+ state: ThemeDocumentState,
60
+ options: ThemeDocumentOptions = {}
61
+ ): { tokens: Record<string, string>; accented: boolean } {
62
+ const properties = { ...themeCssVariables(state.variant) }
63
+ const accent = accentVariables(state.selection.accent, state.appearance, state.selection, {
64
+ background: state.variant.colors.background,
65
+ floor: options.accentContrastFloor,
66
+ variantId: state.variant.id,
67
+ })
68
+ if (accent) Object.assign(properties, accent)
69
+ return {
70
+ tokens: options.transformTokens ? options.transformTokens(properties) : properties,
71
+ accented: accent !== undefined,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Write a state onto a root and report which custom properties it owns.
77
+ *
78
+ * Pass the previous call's return as `previous` and any palette property that pass
79
+ * wrote and this one does not is removed. For the catalogue the two sets are the
80
+ * same, so nothing is ever removed; it matters for a host variant that carries
81
+ * `tokens` the catalogue does not, which would otherwise stay inline, beating the
82
+ * stylesheet, after the user switched away from it.
83
+ */
84
+ export function applyThemeToRoot(
85
+ root: ThemeRoot,
86
+ state: ThemeDocumentState,
87
+ options: ThemeDocumentOptions = {},
88
+ previous?: ReadonlySet<string>
89
+ ): ReadonlySet<string> {
90
+ const { tokens, accented } = themeDocumentTokens(state, options)
91
+
92
+ for (const [name, value] of Object.entries(tokens)) {
93
+ root.style.setProperty(name, value)
94
+ }
95
+ if (previous) {
96
+ for (const name of previous) {
97
+ if (!(name in tokens)) root.style.removeProperty(name)
98
+ }
99
+ }
100
+
101
+ root.classList.toggle('dark', state.appearance === 'dark')
102
+ root.dataset['theme'] = state.variant.id
103
+ root.dataset['appearance'] = state.appearance
104
+ root.style.colorScheme = state.appearance
105
+
106
+ // A custom colour has no stylesheet block to select, and its value is the
107
+ // attribute's worst possible content, so it is reported as `custom`.
108
+ if (!accented) root.removeAttribute('data-accent')
109
+ else
110
+ root.dataset['accent'] = isCustomAccent(state.selection.accent)
111
+ ? 'custom'
112
+ : state.selection.accent
113
+
114
+ applyAppearanceFontSettings(
115
+ root,
116
+ normalizeAppearanceEditorFontSettings(state.selection.fonts, state.selection.font).settings
117
+ )
118
+
119
+ // Density is a pure token shift, so unlike the colour axes there is nothing to
120
+ // compute: the attribute selects a block in `theme.css`. It is applied here for
121
+ // the same reason as the others — this is the one place that knows what the
122
+ // current selection is, and it is the only writer of these attributes.
123
+ if (resolvedDensity(state.selection) === 'comfortable') root.removeAttribute('data-density')
124
+ else root.dataset['density'] = resolvedDensity(state.selection)
125
+
126
+ return new Set(Object.keys(tokens))
127
+ }
128
+
129
+ /** What {@link applyWithoutTransitions} needs of a document. */
130
+ export type TransitionDocument = {
131
+ head: { appendChild(node: unknown): unknown }
132
+ createElement(tag: 'style'): {
133
+ textContent: string | null
134
+ offsetHeight?: number
135
+ remove(): void
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Run `apply` with every CSS transition switched off for the frame that carries
141
+ * it, so a theme switch cannot animate page-wide.
142
+ *
143
+ * Without this, flipping the custom properties on `<html>` starts a transition on
144
+ * every element that declares one, and each fades from the old palette to the new
145
+ * over its own duration: a theme change that looks like a rendering fault. The
146
+ * rule is removed two frames later, after the browser has committed the new styles
147
+ * with transitions off, and `restore` runs it early (the provider calls it when it
148
+ * is disposed mid-switch).
149
+ *
150
+ * `schedule` is the frame source; it defaults to `requestAnimationFrame` and falls
151
+ * back to running at once where there is none, which leaves transitions on rather
152
+ * than off.
153
+ */
154
+ export function applyWithoutTransitions(
155
+ doc: TransitionDocument,
156
+ apply: () => void,
157
+ schedule: (callback: () => void) => void = defaultSchedule
158
+ ): () => void {
159
+ const style = doc.createElement('style')
160
+ style.textContent = '*{transition:none !important}'
161
+ doc.head.appendChild(style)
162
+ // Reading a layout property forces the browser to commit the rule before the
163
+ // properties change; without it the two can be batched into one style recalc.
164
+ void style.offsetHeight
165
+ let removed = false
166
+ const restore = () => {
167
+ if (removed) return
168
+ removed = true
169
+ style.remove()
170
+ }
171
+ try {
172
+ apply()
173
+ } catch (error) {
174
+ restore()
175
+ throw error
176
+ }
177
+ schedule(() => schedule(restore))
178
+ return restore
179
+ }
180
+
181
+ function defaultSchedule(callback: () => void): void {
182
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(callback)
183
+ else callback()
184
+ }
@@ -0,0 +1,143 @@
1
+ import { normalizeCustomAccent } from '../../lib/accent-roles'
2
+ import { accentPresets } from '../../lib/tokens'
3
+ import { normalizeAppearanceEditorFontSettings } from '../../lib/appearance-font-settings'
4
+ import { defaultDarkThemeId, defaultLightThemeId, isThemeAccentId, themeById } from '../../lib/themes'
5
+ import { defaultThemeSelection, resolvedDensity, type ThemeSelection } from './theme-state'
6
+
7
+ /**
8
+ * Where a selection is kept between sessions.
9
+ *
10
+ * The provider never touches storage itself; it asks a `ThemePersistence`. The
11
+ * default is {@link localStoragePersistence}, which is what the provider always did.
12
+ * A host that stores the preference in a different shape, under a versioned
13
+ * envelope, or somewhere that is not `localStorage` supplies its own and the
14
+ * resolution and application rules stay the shared ones.
15
+ *
16
+ * The contract is deliberately narrow, so that an implementation cannot make the
17
+ * provider less safe than it was:
18
+ *
19
+ * - `read` is synchronous and runs once, when the provider is created. It may
20
+ * repair or quarantine what it finds, and it must not throw: the provider
21
+ * treats a throw as "nothing stored" and starts from the defaults.
22
+ * - `write` runs only from `setSelection`. Never on mount, never from `preview`,
23
+ * never as a side effect of reading, so the first write to a store is always a
24
+ * user's decision. A throw is swallowed, because a full or blocked store must
25
+ * not break the switch.
26
+ */
27
+ export type ThemePersistence = {
28
+ read(): Partial<ThemeSelection> | undefined
29
+ write(next: ThemeSelection): void
30
+ }
31
+
32
+ /**
33
+ * The preference store, or nothing.
34
+ *
35
+ * Only ever called inside a `try`: where storage is blocked — a sandboxed iframe, a
36
+ * privacy mode, a denied site-data permission — merely *reading* the `localStorage`
37
+ * global throws ("Access is denied", `SecurityError`), so even the `typeof` guard
38
+ * has to sit inside the `try` rather than in front of it. Without a store the
39
+ * preference lives only in the provider's signal for the session.
40
+ */
41
+ function preferenceStore(): Storage | undefined {
42
+ return typeof localStorage === 'undefined' ? undefined : localStorage
43
+ }
44
+
45
+ /** The stored preference, validated against the catalogue. */
46
+ function readSelection(storageKey: string): ThemeSelection {
47
+ try {
48
+ const raw = preferenceStore()?.getItem(storageKey)
49
+ if (!raw) return defaultThemeSelection
50
+ const parsed = JSON.parse(raw) as Partial<ThemeSelection>
51
+ const fonts = normalizeAppearanceEditorFontSettings(parsed.fonts, parsed.font).settings
52
+ return {
53
+ appearance:
54
+ parsed.appearance === 'light' ||
55
+ parsed.appearance === 'dark' ||
56
+ parsed.appearance === 'system'
57
+ ? parsed.appearance
58
+ : defaultThemeSelection.appearance,
59
+ // An unknown id would leave the document with no theme at all, so a stale
60
+ // preference from an older catalogue falls back rather than applying nothing.
61
+ lightThemeId:
62
+ themeById(parsed.lightThemeId ?? '')?.appearance === 'light'
63
+ ? (parsed.lightThemeId as string)
64
+ : defaultLightThemeId,
65
+ darkThemeId:
66
+ themeById(parsed.darkThemeId ?? '')?.appearance === 'dark'
67
+ ? (parsed.darkThemeId as string)
68
+ : defaultDarkThemeId,
69
+ // A theme accent is kept whenever it names a slot, even one the stored pair
70
+ // cannot offer: the id is role-shaped so it survives a theme switch, and an
71
+ // unoffered slot already resolves to the theme's own primary when applied. A
72
+ // custom `#rrggbb` is kept in its one stored spelling; its readability is
73
+ // settled when it is painted, not when it is read.
74
+ accent:
75
+ typeof parsed.accent === 'string' &&
76
+ (accentPresets.some((preset) => preset.id === parsed.accent) ||
77
+ isThemeAccentId(parsed.accent))
78
+ ? parsed.accent
79
+ : (normalizeCustomAccent(parsed.accent) ?? defaultThemeSelection.accent),
80
+ font: fonts.ui.family,
81
+ fonts,
82
+ // Every axis has to be read back here, or it is lost twice: the provider's
83
+ // document effect removes the `data-density` that `themeScript` set for the first paint, and
84
+ // the next `setSelection` rewrites the store without it.
85
+ density: resolvedDensity(parsed as ThemeSelection),
86
+ }
87
+ } catch {
88
+ return defaultThemeSelection
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The default persistence: one JSON document in `localStorage` under `key`.
94
+ *
95
+ * `read` is the provider's original `readSelection`, moved here unchanged — every
96
+ * stored id is validated against the catalogue, so a stale preference falls back to
97
+ * a default rather than reaching the document. `key` may be a function so a
98
+ * provider whose `storageKey` prop changes writes to the key it is now given.
99
+ *
100
+ * Blocked, full or absent storage is survivable in both directions: a read answers
101
+ * with the defaults and a write is dropped, so the preference simply lasts the
102
+ * session.
103
+ */
104
+ export function localStoragePersistence(key: string | (() => string)): ThemePersistence {
105
+ const keyOf = () => (typeof key === 'function' ? key() : key)
106
+ return {
107
+ read: () => readSelection(keyOf()),
108
+ write(next) {
109
+ try {
110
+ preferenceStore()?.setItem(keyOf(), JSON.stringify(next))
111
+ } catch {
112
+ // A full, blocked or unavailable store must not break the switch: the
113
+ // preference simply does not survive the reload.
114
+ }
115
+ },
116
+ }
117
+ }
118
+
119
+ /**
120
+ * What a persistence knows, or nothing.
121
+ *
122
+ * The seam's `read` must not throw, and the provider does not rely on that: a host
123
+ * persistence is code the design system has not seen, and a theme provider that
124
+ * fails to mount because a store misbehaved has turned a lost preference into a
125
+ * blank application.
126
+ */
127
+ export function readPersisted(persistence: ThemePersistence): Partial<ThemeSelection> | undefined {
128
+ try {
129
+ return persistence.read()
130
+ } catch {
131
+ return undefined
132
+ }
133
+ }
134
+
135
+ /** Hand a selection to the store; a store that throws loses the preference, not the switch. */
136
+ export function writePersisted(persistence: ThemePersistence, next: ThemeSelection): void {
137
+ try {
138
+ persistence.write(next)
139
+ } catch {
140
+ // Same contract as a full or blocked `localStorage`: the choice holds for the
141
+ // session and does not survive the reload.
142
+ }
143
+ }