@adea-ai/ui 0.28.0 → 0.29.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.
@@ -9,108 +9,192 @@ import {
9
9
  type ParentProps,
10
10
  } from 'solid-js'
11
11
 
12
- export type Theme = 'light' | 'dark' | 'system'
13
- export type ResolvedTheme = 'light' | 'dark'
12
+ import {
13
+ appearanceThemeScript,
14
+ applyAppearanceToDocument,
15
+ DARK_QUERY,
16
+ defaultAppearancePreferences,
17
+ type AppearanceMode,
18
+ type AppearancePreferencesV2,
19
+ type ResolvedAppearance,
20
+ readAppearancePreferences,
21
+ REDUCED_TRANSPARENCY_QUERY,
22
+ resolveAppearanceState,
23
+ writeAppearancePreferences,
24
+ } from './appearance'
25
+
26
+ export type Theme = AppearanceMode
27
+ export type ResolvedTheme = ResolvedAppearance
28
+
29
+ export type { AppearanceMode, AppearancePreferencesV2, ResolvedAppearance } from './appearance'
14
30
 
15
31
  type ThemeContextValue = {
32
+ /** The persisted v2 appearance preferences. */
33
+ preferences: Accessor<AppearancePreferencesV2>
34
+ /** Mode after combining the user choice with the OS report. */
35
+ resolvedMode: Accessor<ResolvedAppearance>
36
+ /** The active theme variant id (`documentElement.dataset.theme`). */
37
+ variantId: Accessor<string>
38
+ /** Surface after reduced-transparency and capability resolution. */
39
+ effectiveSurface: Accessor<'opaque' | 'frosted' | 'translucent'>
40
+ /** True while the OS or the user forces opaque surfaces. */
41
+ reduceTransparencyActive: Accessor<boolean>
42
+ /** Persist a field update immediately (Zeron's `SavePolicy::Immediate`). */
43
+ update: (patch: Partial<AppearancePreferencesV2>) => void
44
+ /** Apply a draft against the visible app without persisting it. */
45
+ preview: (preferences: AppearancePreferencesV2 | undefined) => void
46
+
47
+ /** Legacy single-mode seam (settings toggle and existing consumers). */
16
48
  theme: Accessor<Theme>
17
49
  resolvedTheme: Accessor<ResolvedTheme>
18
50
  setTheme: (theme: Theme) => void
19
51
  }
20
52
 
21
- const STORAGE_KEY = 'theme'
22
- const DARK_QUERY = '(prefers-color-scheme: dark)'
23
-
24
53
  const ThemeContext = createContext<ThemeContextValue>()
25
54
 
26
- function readStoredTheme(): Theme {
27
- if (typeof window === 'undefined') return 'system'
28
- try {
29
- const stored = window.localStorage.getItem(STORAGE_KEY)
30
- return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system'
31
- } catch {
32
- return 'system'
33
- }
34
- }
35
-
36
55
  function systemPrefersDark(): boolean {
37
56
  return typeof window !== 'undefined' && window.matchMedia(DARK_QUERY).matches
38
57
  }
39
58
 
40
- /**
41
- * Applies the resolved theme to the document and suppresses transitions for the
42
- * frame that carries the change, so a theme switch cannot animate page-wide.
43
- */
44
- function applyTheme(theme: ResolvedTheme, disableTransitionOnChange: boolean): void {
45
- if (typeof document === 'undefined') return
46
-
47
- const root = document.documentElement
48
- let restoreTransitions: (() => void) | undefined
49
-
50
- if (disableTransitionOnChange) {
51
- const style = document.createElement('style')
52
- style.appendChild(document.createTextNode('*{transition:none !important}'))
53
- document.head.appendChild(style)
54
- void style.offsetHeight
55
- restoreTransitions = () => style.remove()
56
- }
57
-
58
- root.classList.toggle('dark', theme === 'dark')
59
- root.style.colorScheme = theme
60
-
61
- if (restoreTransitions) {
62
- requestAnimationFrame(() => requestAnimationFrame(() => restoreTransitions?.()))
63
- }
59
+ function systemReducesTransparency(): boolean {
60
+ return typeof window !== 'undefined' && window.matchMedia(REDUCED_TRANSPARENCY_QUERY).matches
64
61
  }
65
62
 
66
63
  type ThemeProviderProps = ParentProps<{
67
64
  defaultTheme?: Theme
68
65
  disableTransitionOnChange?: boolean
66
+ /**
67
+ * Whether the host window can render OS translucency. The web lane keeps
68
+ * the default `false` and renders tokenized frosted surfaces instead of
69
+ * pretending to OS vibrancy.
70
+ */
71
+ nativeTranslucency?: boolean
69
72
  }>
70
73
 
71
74
  /**
72
- * The shared Solid theme provider. It owns the same contract the previous
73
- * provider exposed to the token layer: a `dark` class on the document element
74
- * plus a `color-scheme` style, persisted in `localStorage` and defaulting to
75
- * the system preference.
75
+ * The shared Solid appearance provider. It owns the versioned appearance
76
+ * preference (mode, independent light/dark themes, accent, surface) on top of
77
+ * the contract the previous provider exposed to the token layer: a `dark`
78
+ * class on the document element plus a `color-scheme` style, persisted in
79
+ * `localStorage` and defaulting to the system preference. The legacy single
80
+ * `theme` key migrates on read and is never deleted.
76
81
  */
77
82
  export function ThemeProvider(props: ThemeProviderProps) {
78
- const [theme, setThemeSignal] = createSignal<Theme>(props.defaultTheme ?? readStoredTheme())
83
+ const storage = typeof window === 'undefined' ? undefined : window.localStorage
84
+ const stored = readAppearancePreferences(storage)
85
+ // A legacy or default record keeps the historical `defaultTheme` escape
86
+ // hatch meaningful for embedders.
87
+ const initial =
88
+ stored === defaultAppearancePreferences && props.defaultTheme
89
+ ? { ...stored, mode: props.defaultTheme }
90
+ : stored
91
+ const [preferences, setPreferences] = createSignal<AppearancePreferencesV2>(initial)
79
92
  const [prefersDark, setPrefersDark] = createSignal(systemPrefersDark())
93
+ const [osReducedTransparency, setOsReducedTransparency] = createSignal(
94
+ systemReducesTransparency()
95
+ )
96
+ const nativeTranslucency = () => props.nativeTranslucency ?? false
80
97
 
81
- const resolvedTheme = (): ResolvedTheme => {
82
- const current = theme()
83
- if (current === 'system') return prefersDark() ? 'dark' : 'light'
84
- return current
98
+ if (typeof window !== 'undefined') {
99
+ const darkMedia = window.matchMedia(DARK_QUERY)
100
+ const onDarkChange = (event: MediaQueryListEvent) => setPrefersDark(event.matches)
101
+ darkMedia.addEventListener('change', onDarkChange)
102
+ onCleanup(() => darkMedia.removeEventListener('change', onDarkChange))
103
+
104
+ const transparencyMedia = window.matchMedia(REDUCED_TRANSPARENCY_QUERY)
105
+ const onTransparencyChange = (event: MediaQueryListEvent) =>
106
+ setOsReducedTransparency(event.matches)
107
+ transparencyMedia.addEventListener('change', onTransparencyChange)
108
+ onCleanup(() => transparencyMedia.removeEventListener('change', onTransparencyChange))
85
109
  }
86
110
 
87
- if (typeof window !== 'undefined') {
88
- const media = window.matchMedia(DARK_QUERY)
89
- const onChange = (event: MediaQueryListEvent) => setPrefersDark(event.matches)
90
- media.addEventListener('change', onChange)
91
- onCleanup(() => media.removeEventListener('change', onChange))
111
+ const resolvedState = () =>
112
+ resolveAppearanceState(preferences(), {
113
+ systemAppearance: prefersDark() ? 'dark' : 'light',
114
+ osReducedTransparency: osReducedTransparency(),
115
+ nativeTranslucency: nativeTranslucency(),
116
+ })
117
+
118
+ const persist = (next: AppearancePreferencesV2) => {
119
+ setPreferences(next)
120
+ writeAppearancePreferences(storage, next)
92
121
  }
93
122
 
94
123
  createEffect(() => {
95
- applyTheme(resolvedTheme(), props.disableTransitionOnChange ?? true)
124
+ applyResolved(resolvedState(), props.disableTransitionOnChange ?? true)
96
125
  })
97
126
 
98
- const setTheme = (next: Theme) => {
99
- setThemeSignal(next)
100
- try {
101
- window.localStorage.setItem(STORAGE_KEY, next)
102
- } catch {
103
- // A blocked storage API must not break the in-memory theme.
127
+ const update = (patch: Partial<AppearancePreferencesV2>) => {
128
+ persist({ ...preferences(), ...patch })
129
+ }
130
+
131
+ const preview = (draft: AppearancePreferencesV2 | undefined) => {
132
+ if (!draft) {
133
+ applyResolved(resolvedState(), props.disableTransitionOnChange ?? true)
134
+ return
104
135
  }
136
+ applyResolved(
137
+ resolveAppearanceState(draft, {
138
+ systemAppearance: prefersDark() ? 'dark' : 'light',
139
+ osReducedTransparency: osReducedTransparency(),
140
+ nativeTranslucency: nativeTranslucency(),
141
+ }),
142
+ props.disableTransitionOnChange ?? true
143
+ )
105
144
  }
106
145
 
146
+ // Legacy seam: the single `theme` value is the v2 mode.
147
+ const theme = (): Theme => preferences().mode
148
+ const resolvedTheme = (): ResolvedTheme => resolvedState().resolvedMode
149
+ const setTheme = (next: Theme) => update({ mode: next })
150
+
107
151
  return (
108
- <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
152
+ <ThemeContext.Provider
153
+ value={{
154
+ preferences,
155
+ resolvedMode: resolvedTheme,
156
+ variantId: () => resolvedState().variant.id,
157
+ effectiveSurface: () => resolvedState().effectiveSurface,
158
+ reduceTransparencyActive: () => resolvedState().reduceTransparencyActive,
159
+ update,
160
+ preview,
161
+ theme,
162
+ resolvedTheme,
163
+ setTheme,
164
+ }}
165
+ >
109
166
  {props.children}
110
167
  </ThemeContext.Provider>
111
168
  )
112
169
  }
113
170
 
171
+ /**
172
+ * Applies the resolved theme to the document and suppresses transitions for the
173
+ * frame that carries the change, so a theme switch cannot animate page-wide.
174
+ */
175
+ function applyResolved(
176
+ state: ReturnType<typeof resolveAppearanceState>,
177
+ disableTransitionOnChange: boolean
178
+ ): void {
179
+ if (typeof document === 'undefined') return
180
+
181
+ let restoreTransitions: (() => void) | undefined
182
+
183
+ if (disableTransitionOnChange) {
184
+ const style = document.createElement('style')
185
+ style.appendChild(document.createTextNode('*{transition:none !important}'))
186
+ document.head.appendChild(style)
187
+ void style.offsetHeight
188
+ restoreTransitions = () => style.remove()
189
+ }
190
+
191
+ applyAppearanceToDocument(document, state)
192
+
193
+ if (restoreTransitions) {
194
+ requestAnimationFrame(() => requestAnimationFrame(() => restoreTransitions?.()))
195
+ }
196
+ }
197
+
114
198
  export function useTheme(): ThemeContextValue {
115
199
  const context = useContext(ThemeContext)
116
200
  if (!context) {
@@ -120,11 +204,11 @@ export function useTheme(): ThemeContextValue {
120
204
  }
121
205
 
122
206
  /**
123
- * Pre-paint theme restore for server-rendered documents. Rendered in the
124
- * document head, it applies the stored or system theme before first paint so
125
- * hydration never shows the wrong palette.
207
+ * Pre-paint appearance restore for server-rendered documents. Rendered in the
208
+ * document head, it resolves the stored (or legacy) preference against the OS
209
+ * and applies the palette before first paint so hydration never shows the
210
+ * wrong theme.
126
211
  */
127
212
  export function ThemeScript(): JSX.Element {
128
- const script = `(function(){try{var t=localStorage.getItem('${STORAGE_KEY}')||'system';var d=t==='dark'||(t==='system'&&window.matchMedia('${DARK_QUERY}').matches);var r=document.documentElement;r.classList.toggle('dark',d);r.style.colorScheme=d?'dark':'light'}catch(e){}})();`
129
- return <script innerHTML={script} />
213
+ return <script innerHTML={appearanceThemeScript()} />
130
214
  }
@@ -0,0 +1,49 @@
1
+ import { For } from 'solid-js'
2
+
3
+ import type { ThemeVariant } from './appearance'
4
+
5
+ /*
6
+ * Preview swatches for the appearance pickers: small role-colored dots so a
7
+ * palette can be previewed without applying it. Colors arrive as runtime
8
+ * token data from the theme manifest, so painting them must inline them —
9
+ * this component is the one sanctioned bridge between token data and the DOM
10
+ * (token data may never become a component color literal).
11
+ */
12
+
13
+ /** The three-dot preview for a theme variant (background, primary, card). */
14
+ export function ThemeSwatch(props: { class?: string; variant: ThemeVariant }) {
15
+ const swatches = () => [
16
+ { role: 'Background', color: props.variant.colors.background },
17
+ { role: 'Primary', color: props.variant.colors.primary },
18
+ { role: 'Card', color: props.variant.colors.card },
19
+ ]
20
+ return (
21
+ // Decorative next to the variant name; the dots duplicate information the
22
+ // adjacent text already gives assistive technology.
23
+ <span class={props.class} aria-hidden="true">
24
+ <For each={swatches()}>
25
+ {(swatch) => (
26
+ <span
27
+ class="theme-swatch__dot"
28
+ title={swatch.role}
29
+ // oxlint-disable-next-line no-inline-styles -- dynamic token data, see file docstring
30
+ style={{ 'background-color': swatch.color }}
31
+ />
32
+ )}
33
+ </For>
34
+ </span>
35
+ )
36
+ }
37
+
38
+ /** Single-color swatch (accent presets and custom accents). */
39
+ export function ColorSwatch(props: { class?: string; color: string; label: string }) {
40
+ return (
41
+ <span class={props.class} role="img" aria-label={props.label}>
42
+ <span
43
+ class="theme-swatch__dot"
44
+ // oxlint-disable-next-line no-inline-styles -- dynamic token data, see file docstring
45
+ style={{ 'background-color': props.color }}
46
+ />
47
+ </span>
48
+ )
49
+ }
package/src/index.tsx CHANGED
@@ -4,6 +4,7 @@ export { Toggle } from './components/ui/toggle'
4
4
  export { ToggleGroup, ToggleGroupItem } from './components/ui/toggle-group'
5
5
  export { RadioGroup, RadioGroupItem } from './components/ui/radio-group'
6
6
  export { ThemeToggle } from './components/theme-toggle'
7
+ export { ColorSwatch, ThemeSwatch } from './components/theme-swatch'
7
8
  export {
8
9
  ThemeProvider,
9
10
  ThemeScript,
@@ -2063,11 +2063,17 @@
2063
2063
  padding: 0;
2064
2064
  }
2065
2065
  .plugins-browser {
2066
- display: grid;
2067
- grid-template: auto auto minmax(0, 1fr) / minmax(0, 1fr);
2066
+ display: flex;
2067
+ flex-direction: column;
2068
2068
  gap: 0;
2069
2069
  min-height: 0;
2070
2070
  }
2071
+ .plugins-browser__body {
2072
+ display: flex;
2073
+ flex: 1;
2074
+ flex-direction: column;
2075
+ min-height: 0;
2076
+ }
2071
2077
  .plugins-browser__tabs {
2072
2078
  align-self: end;
2073
2079
  margin: 0.5rem 1.25rem 0;
@@ -2113,9 +2119,64 @@
2113
2119
  }
2114
2120
  .plugins-browser__list {
2115
2121
  min-height: 0;
2122
+ flex: 1;
2116
2123
  overflow: auto;
2117
2124
  padding: 0.8rem 1.25rem 1.25rem;
2118
2125
  }
2126
+ .plugins-navigation {
2127
+ display: flex;
2128
+ flex: 1;
2129
+ flex-direction: column;
2130
+ gap: 0.9rem;
2131
+ min-height: 0;
2132
+ overflow: auto;
2133
+ padding: 0.9rem 1.25rem 1.25rem;
2134
+ }
2135
+ .plugins-navigation__hint {
2136
+ margin: 0;
2137
+ color: var(--cw-muted);
2138
+ font-size: 0.8rem;
2139
+ }
2140
+ .plugins-navigation__list {
2141
+ display: flex;
2142
+ flex-direction: column;
2143
+ gap: 0.4rem;
2144
+ margin: 0;
2145
+ padding: 0;
2146
+ list-style: none;
2147
+ }
2148
+ .plugins-navigation__row {
2149
+ display: flex;
2150
+ align-items: center;
2151
+ justify-content: space-between;
2152
+ gap: 0.75rem;
2153
+ padding: 0.55rem 0.7rem;
2154
+ border: 1px solid var(--cw-border);
2155
+ border-radius: 0.6rem;
2156
+ }
2157
+ .plugins-navigation__label {
2158
+ display: flex;
2159
+ align-items: center;
2160
+ gap: 0.5rem;
2161
+ color: var(--cw-text);
2162
+ font-size: 0.85rem;
2163
+ }
2164
+ .plugins-navigation__name {
2165
+ color: var(--cw-text);
2166
+ }
2167
+ .plugins-navigation__controls {
2168
+ display: flex;
2169
+ align-items: center;
2170
+ gap: 0.2rem;
2171
+ }
2172
+ .plugins-navigation__visibility {
2173
+ display: flex;
2174
+ align-items: center;
2175
+ gap: 0.4rem;
2176
+ margin-left: 0.5rem;
2177
+ color: var(--cw-muted);
2178
+ font-size: 0.78rem;
2179
+ }
2119
2180
  .plugins-browser[data-filter-open='true'] .plugins-browser__list {
2120
2181
  pointer-events: none;
2121
2182
  }
@@ -72,6 +72,62 @@
72
72
  --z-dialog: 120;
73
73
  --z-menu: 140;
74
74
  --z-tooltip: 160;
75
+ /* Surface glass. The appearance provider resolves the effective surface
76
+ (opaque/frosted/translucent) and reduced-transparency policy; these
77
+ declarations are the opaque defaults plus the frost amounts the resolved
78
+ values modulate. */
79
+ --surface-alpha: 1;
80
+ --surface-frost: 0.92;
81
+ --surface-translucent: 0.8;
82
+ /* Terminal roles for the default light variant (xterm ANSI, cursor,
83
+ selection). Non-default variants apply their manifest values inline; the
84
+ terminal consumes the custom properties so a theme switch updates a live
85
+ session without remounting. */
86
+ --terminal-background: #ffffff;
87
+ --terminal-foreground: #1b1f24;
88
+ --terminal-cursor: #24292f;
89
+ --terminal-selection: #b6c7ff;
90
+ --terminal-ansi-black: #1b1f24;
91
+ --terminal-ansi-red: #b91c1c;
92
+ --terminal-ansi-green: #116a2e;
93
+ --terminal-ansi-yellow: #8a5a1b;
94
+ --terminal-ansi-blue: #0b57d0;
95
+ --terminal-ansi-magenta: #a0186f;
96
+ --terminal-ansi-cyan: #0e7490;
97
+ --terminal-ansi-white: #57606a;
98
+ --terminal-ansi-bright-black: #57606a;
99
+ --terminal-ansi-bright-red: #c94d4d;
100
+ --terminal-ansi-bright-green: #1f9d4f;
101
+ --terminal-ansi-bright-yellow: #a9752c;
102
+ --terminal-ansi-bright-blue: #3b82f6;
103
+ --terminal-ansi-bright-magenta: #c04a92;
104
+ --terminal-ansi-bright-cyan: #0891b2;
105
+ --terminal-ansi-bright-white: #24292f;
106
+ /* Editor roles (CodeMirror syntax/diff/search) for the default light
107
+ variant. */
108
+ --editor-keyword: #0b57d0;
109
+ --editor-string: #116a2e;
110
+ --editor-number: #8a5a1b;
111
+ --editor-comment: #57606a;
112
+ --editor-function: #a0186f;
113
+ --editor-variable: #1b1f24;
114
+ --editor-type: #0e7490;
115
+ --editor-tag: #b91c1c;
116
+ --editor-attribute: #8a5a1b;
117
+ --editor-operator: #1b1f24;
118
+ --editor-heading: #1b1f24;
119
+ --editor-link: #0b57d0;
120
+ --editor-diff-add: #116a2e;
121
+ --editor-diff-delete: #b91c1c;
122
+ --editor-diff-hunk: #57606a;
123
+ --editor-search-match: #8a5a1b;
124
+ /* Chart series roles. */
125
+ --chart-1: #0b57d0;
126
+ --chart-2: #116a2e;
127
+ --chart-3: #8a5a1b;
128
+ --chart-4: #a0186f;
129
+ --chart-5: #0e7490;
130
+ --chart-6: #57606a;
75
131
  }
76
132
 
77
133
  .dark {
@@ -94,6 +150,72 @@
94
150
  --border: oklch(1 0 0 / 10%);
95
151
  --input: oklch(1 0 0 / 15%);
96
152
  --ring: oklch(0.556 0 0);
153
+ /* Terminal/editor/chart roles for the default dark variant. */
154
+ --terminal-background: #0d1117;
155
+ --terminal-foreground: #e6edf3;
156
+ --terminal-cursor: #e6edf3;
157
+ --terminal-selection: #264f78;
158
+ --terminal-ansi-black: #2f3742;
159
+ --terminal-ansi-red: #ff8183;
160
+ --terminal-ansi-green: #56d364;
161
+ --terminal-ansi-yellow: #e3b341;
162
+ --terminal-ansi-blue: #6ca4f8;
163
+ --terminal-ansi-magenta: #db61a2;
164
+ --terminal-ansi-cyan: #39c5cf;
165
+ --terminal-ansi-white: #d5dde5;
166
+ --terminal-ansi-bright-black: #57606a;
167
+ --terminal-ansi-bright-red: #ff9494;
168
+ --terminal-ansi-bright-green: #79dd8a;
169
+ --terminal-ansi-bright-yellow: #f0c264;
170
+ --terminal-ansi-bright-blue: #8db9ff;
171
+ --terminal-ansi-bright-magenta: #e87cb4;
172
+ --terminal-ansi-bright-cyan: #66d3dc;
173
+ --terminal-ansi-bright-white: #eef2f6;
174
+ --editor-keyword: #6ca4f8;
175
+ --editor-string: #56d364;
176
+ --editor-number: #e3b341;
177
+ --editor-comment: #8b949e;
178
+ --editor-function: #db61a2;
179
+ --editor-variable: #e6edf3;
180
+ --editor-type: #39c5cf;
181
+ --editor-tag: #ff8183;
182
+ --editor-attribute: #e3b341;
183
+ --editor-operator: #e6edf3;
184
+ --editor-heading: #e6edf3;
185
+ --editor-link: #6ca4f8;
186
+ --editor-diff-add: #56d364;
187
+ --editor-diff-delete: #ff8183;
188
+ --editor-diff-hunk: #8b949e;
189
+ --editor-search-match: #e3b341;
190
+ --chart-1: #6ca4f8;
191
+ --chart-2: #56d364;
192
+ --chart-3: #e3b341;
193
+ --chart-4: #db61a2;
194
+ --chart-5: #39c5cf;
195
+ --chart-6: #8b949e;
196
+ }
197
+
198
+ /* Surface glass amounts. The provider resolves the effective surface and the
199
+ reduced-transparency policy; this media query is the OS-level backstop so a
200
+ frost amount can never outlive a system Reduce Transparency setting. */
201
+ @media (prefers-reduced-transparency: reduce) {
202
+ :root {
203
+ --surface-alpha: 1;
204
+ }
205
+ }
206
+
207
+ /* Appearance picker swatches (see components/theme-swatch.tsx). The dot
208
+ color itself is runtime token data applied by the component. */
209
+ .theme-swatch {
210
+ display: inline-flex;
211
+ gap: 0.25rem;
212
+ }
213
+ .theme-swatch__dot {
214
+ display: inline-block;
215
+ width: 0.875rem;
216
+ height: 0.875rem;
217
+ border-radius: calc(infinity * 1px);
218
+ border: 1px solid var(--border);
97
219
  }
98
220
 
99
221
  @layer base {