@mohou/panel 1.0.20

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 (113) hide show
  1. package/README.md +8 -0
  2. package/lib/types/chrome/hue.d.ts +3 -0
  3. package/lib/types/chrome/hue.d.ts.map +1 -0
  4. package/lib/types/gallery/card-style.d.ts +8 -0
  5. package/lib/types/gallery/card-style.d.ts.map +1 -0
  6. package/lib/types/gallery/client.d.ts +20 -0
  7. package/lib/types/gallery/client.d.ts.map +1 -0
  8. package/lib/types/gallery/desk.d.ts +16 -0
  9. package/lib/types/gallery/desk.d.ts.map +1 -0
  10. package/lib/types/gallery/list.d.ts +37 -0
  11. package/lib/types/gallery/list.d.ts.map +1 -0
  12. package/lib/types/gallery/sort.d.ts +24 -0
  13. package/lib/types/gallery/sort.d.ts.map +1 -0
  14. package/lib/types/gallery/state.d.ts +90 -0
  15. package/lib/types/gallery/state.d.ts.map +1 -0
  16. package/lib/types/gallery/tabs.d.ts +45 -0
  17. package/lib/types/gallery/tabs.d.ts.map +1 -0
  18. package/lib/types/gallery/view.d.ts +57 -0
  19. package/lib/types/gallery/view.d.ts.map +1 -0
  20. package/lib/types/history/client.d.ts +25 -0
  21. package/lib/types/history/client.d.ts.map +1 -0
  22. package/lib/types/history/state.d.ts +39 -0
  23. package/lib/types/history/state.d.ts.map +1 -0
  24. package/lib/types/history/view.d.ts +15 -0
  25. package/lib/types/history/view.d.ts.map +1 -0
  26. package/lib/types/index.d.ts +24 -0
  27. package/lib/types/index.d.ts.map +1 -0
  28. package/lib/types/labels.d.ts +267 -0
  29. package/lib/types/labels.d.ts.map +1 -0
  30. package/lib/types/settings/author-mcp-view.d.ts +15 -0
  31. package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
  32. package/lib/types/settings/author-mcp.d.ts +4 -0
  33. package/lib/types/settings/author-mcp.d.ts.map +1 -0
  34. package/lib/types/settings/client.d.ts +198 -0
  35. package/lib/types/settings/client.d.ts.map +1 -0
  36. package/lib/types/settings/credentials-view.d.ts +15 -0
  37. package/lib/types/settings/credentials-view.d.ts.map +1 -0
  38. package/lib/types/settings/form.d.ts +27 -0
  39. package/lib/types/settings/form.d.ts.map +1 -0
  40. package/lib/types/settings/install-row.d.ts +10 -0
  41. package/lib/types/settings/install-row.d.ts.map +1 -0
  42. package/lib/types/settings/mcp-view.d.ts +14 -0
  43. package/lib/types/settings/mcp-view.d.ts.map +1 -0
  44. package/lib/types/settings/skill-dirs.d.ts +6 -0
  45. package/lib/types/settings/skill-dirs.d.ts.map +1 -0
  46. package/lib/types/settings/skill-install.d.ts +13 -0
  47. package/lib/types/settings/skill-install.d.ts.map +1 -0
  48. package/lib/types/settings/state.d.ts +94 -0
  49. package/lib/types/settings/state.d.ts.map +1 -0
  50. package/lib/types/settings/update-dialog.d.ts +56 -0
  51. package/lib/types/settings/update-dialog.d.ts.map +1 -0
  52. package/lib/types/settings/view.d.ts +26 -0
  53. package/lib/types/settings/view.d.ts.map +1 -0
  54. package/lib/types/storage/client.d.ts +15 -0
  55. package/lib/types/storage/client.d.ts.map +1 -0
  56. package/lib/types/storage/state.d.ts +42 -0
  57. package/lib/types/storage/state.d.ts.map +1 -0
  58. package/lib/types/storage/view.d.ts +16 -0
  59. package/lib/types/storage/view.d.ts.map +1 -0
  60. package/lib/types/surface/state.d.ts +42 -0
  61. package/lib/types/surface/state.d.ts.map +1 -0
  62. package/lib/types/surface/view.d.ts +34 -0
  63. package/lib/types/surface/view.d.ts.map +1 -0
  64. package/lib/types/theme/client.d.ts +45 -0
  65. package/lib/types/theme/client.d.ts.map +1 -0
  66. package/lib/types/theme/menu.d.ts +25 -0
  67. package/lib/types/theme/menu.d.ts.map +1 -0
  68. package/lib/types/theme/state.d.ts +46 -0
  69. package/lib/types/theme/state.d.ts.map +1 -0
  70. package/lib/types/theme/view.d.ts +16 -0
  71. package/lib/types/theme/view.d.ts.map +1 -0
  72. package/lib/types/ui/dialog.d.ts +12 -0
  73. package/lib/types/ui/dialog.d.ts.map +1 -0
  74. package/lib/types/ui/tooltip.d.ts +12 -0
  75. package/lib/types/ui/tooltip.d.ts.map +1 -0
  76. package/package.json +38 -0
  77. package/src/chrome/hue.ts +6 -0
  78. package/src/gallery/card-style.ts +27 -0
  79. package/src/gallery/client.ts +26 -0
  80. package/src/gallery/desk.tsx +54 -0
  81. package/src/gallery/list.ts +54 -0
  82. package/src/gallery/sort.ts +72 -0
  83. package/src/gallery/state.ts +229 -0
  84. package/src/gallery/tabs.ts +85 -0
  85. package/src/gallery/view.tsx +501 -0
  86. package/src/history/client.ts +27 -0
  87. package/src/history/state.ts +90 -0
  88. package/src/history/view.tsx +131 -0
  89. package/src/index.ts +61 -0
  90. package/src/labels.ts +536 -0
  91. package/src/settings/author-mcp-view.tsx +154 -0
  92. package/src/settings/author-mcp.ts +24 -0
  93. package/src/settings/client.ts +205 -0
  94. package/src/settings/credentials-view.tsx +195 -0
  95. package/src/settings/form.ts +27 -0
  96. package/src/settings/install-row.tsx +35 -0
  97. package/src/settings/mcp-view.tsx +793 -0
  98. package/src/settings/skill-dirs.ts +41 -0
  99. package/src/settings/skill-install.tsx +134 -0
  100. package/src/settings/state.ts +225 -0
  101. package/src/settings/update-dialog.tsx +207 -0
  102. package/src/settings/view.tsx +722 -0
  103. package/src/storage/client.ts +16 -0
  104. package/src/storage/state.ts +94 -0
  105. package/src/storage/view.tsx +190 -0
  106. package/src/surface/state.ts +72 -0
  107. package/src/surface/view.tsx +255 -0
  108. package/src/theme/client.ts +34 -0
  109. package/src/theme/menu.tsx +286 -0
  110. package/src/theme/state.ts +113 -0
  111. package/src/theme/view.tsx +73 -0
  112. package/src/ui/dialog.tsx +25 -0
  113. package/src/ui/tooltip.tsx +19 -0
@@ -0,0 +1,286 @@
1
+ import { useEffect, useState, type ReactNode } from 'react'
2
+
3
+ import { galleryHue } from '../chrome/hue.ts'
4
+ import { panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
5
+ import type { PanelPolicy, PanelSettingsClient } from '../settings/client.ts'
6
+ import type { PaletteChip, ThemeClient, ThemePin } from './client.ts'
7
+
8
+ /**
9
+ * Theme menu in the toolbar. Global scope writes host appearance.
10
+ * App scope writes that app's pin. The pressed row is the current choice.
11
+ * Appearance (light/dark/system) is always host-global; only palette can be per-app.
12
+ * @param props - theme client, settings client, the open app, and chrome locale
13
+ */
14
+ export function ThemeMenu(props: {
15
+ readonly theme?: ThemeClient
16
+ readonly settings?: PanelSettingsClient
17
+ readonly locale: PanelLocale
18
+ readonly mode: PanelLabelMode
19
+ readonly appId?: string
20
+ readonly appTitle?: string
21
+ readonly hostPolicy?: PanelPolicy
22
+ readonly onHostPolicy?: (policy: PanelPolicy) => void
23
+ }): ReactNode {
24
+ const [palettes, setPalettes] = useState<readonly PaletteChip[]>([])
25
+ const [failed, setFailed] = useState('')
26
+ const [scope, setScope] = useState<'global' | 'app'>('global')
27
+ const [appearance, setAppearance] = useState<'system' | 'light' | 'dark'>('system')
28
+ const [paletteId, setPaletteId] = useState('')
29
+ const [pin, setPin] = useState<ThemePin>({ kind: 'default' })
30
+ const [appFile, setAppFile] = useState(false)
31
+ const [appTheme, setAppTheme] = useState<{ name?: string; nameZh?: string; swatch?: string; style?: string } | null>(null)
32
+ const label = (key: string) => panelText(props.locale, key, props.mode)
33
+ const appScope = props.appId !== undefined && scope === 'app'
34
+ useEffect(() => {
35
+ if (props.theme === undefined) return
36
+ void props.theme.listPalettes().then(
37
+ listed => setPalettes(listed.palettes),
38
+ (error: unknown) => setFailed(error instanceof Error && error.message.length > 0 ? error.message : label('theme-save-failed')),
39
+ )
40
+ }, [props.theme])
41
+ useEffect(() => {
42
+ if (props.settings === undefined) return
43
+ void props.settings.readPolicy().then((policy) => {
44
+ if (policy.theme === 'light' || policy.theme === 'dark' || policy.theme === 'system') setAppearance(policy.theme)
45
+ setPaletteId(policy.palette)
46
+ }).catch(() => undefined)
47
+ }, [props.settings])
48
+ useEffect(() => {
49
+ if (props.hostPolicy === undefined) return
50
+ if (props.hostPolicy.theme === 'light' || props.hostPolicy.theme === 'dark' || props.hostPolicy.theme === 'system') {
51
+ setAppearance(props.hostPolicy.theme)
52
+ }
53
+ setPaletteId(props.hostPolicy.palette)
54
+ }, [props.hostPolicy])
55
+ useEffect(() => {
56
+ if (props.theme === undefined || props.appId === undefined) return
57
+ const appId = props.appId
58
+ if (props.theme.readPin !== undefined) {
59
+ void props.theme.readPin(appId).then(setPin).catch(() => setPin({ kind: 'default' }))
60
+ }
61
+ if (props.theme.appFile !== undefined) {
62
+ void props.theme.appFile(appId).then(setAppFile).catch(() => setAppFile(false))
63
+ }
64
+ if (props.theme.readAppTheme !== undefined) {
65
+ void props.theme.readAppTheme(appId).then(setAppTheme).catch(() => setAppTheme(null))
66
+ }
67
+ }, [props.theme, props.appId])
68
+ return (
69
+ <>
70
+ {props.appId === undefined ? null : (
71
+ <>
72
+ <p className="mb-2 text-[10px] tracking-widest text-muted-foreground uppercase">{label('theme')}</p>
73
+ <div className="mb-3 flex gap-1 rounded-lg bg-muted p-1">
74
+ <Seg label={label('scope-global')} on={scope === 'global'} onClick={() => setScope('global')} />
75
+ <Seg label={props.appTitle ?? label('scope-app')} on={scope === 'app'} onClick={() => setScope('app')} />
76
+ </div>
77
+ </>
78
+ )}
79
+ {appScope ? null : (
80
+ <>
81
+ <p className="mb-2 text-[10px] tracking-widest text-muted-foreground uppercase">{label('appearance')}</p>
82
+ <div className="mb-3 flex gap-1 rounded-lg bg-muted p-1">
83
+ {(['system', 'light', 'dark'] as const).map(theme => (
84
+ <Seg
85
+ key={theme}
86
+ label={label(themeKey(theme))}
87
+ on={appearance === theme}
88
+ onClick={() => { void writeTheme(props.settings, theme, setAppearance, setFailed, label, props.onHostPolicy) }}
89
+ />
90
+ ))}
91
+ </div>
92
+ </>
93
+ )}
94
+ <p className="mb-2 text-[10px] tracking-widest text-muted-foreground uppercase">{label('palette')}</p>
95
+ {appScope ? <p className="mb-2 text-[11px] leading-4 text-muted-foreground">{label('theme-app-hint')}</p> : null}
96
+ <div className="flex max-h-64 flex-col gap-1 overflow-auto">
97
+ {appScope ? (
98
+ <Swatch
99
+ label={label('follow-global')}
100
+ swatch="linear-gradient(135deg,#888,#ddd)"
101
+ on={pin.kind === 'follow-host' || pin.kind === 'default'}
102
+ onClick={() => { void writePin(props.theme, props.appId, { kind: 'follow-host' }, setPin, setFailed, label, palettes.find(item => item.id === paletteId)?.style) }}
103
+ />
104
+ ) : null}
105
+ {palettes.map(palette => (
106
+ <Swatch
107
+ key={palette.id}
108
+ label={paletteLabel(props.locale, palette)}
109
+ swatch={palette.swatch ?? `hsl(${galleryHue(palette.id)} 55% 48%)`}
110
+ badge={label(palette.origin === 'custom' ? 'chip-custom' : 'chip-system')}
111
+ on={paletteOn(appScope, pin, paletteId, palette.id)}
112
+ onClick={() => { void choosePalette(props, appScope, palette, setPaletteId, setPin, setFailed, label) }}
113
+ />
114
+ ))}
115
+ {appScope && (appFile || appTheme !== null) ? (
116
+ <Swatch
117
+ label={appTheme?.name !== undefined && appTheme.name.length > 0 ? paletteLabel(props.locale, { id: 'app', name: appTheme.name, ...appTheme.nameZh === undefined ? {} : { nameZh: appTheme.nameZh } }) : label('app-file')}
118
+ swatch={appTheme?.swatch ?? 'linear-gradient(135deg,var(--primary),var(--muted))'}
119
+ badge={label('chip-app')}
120
+ on={pin.kind === 'app-file'}
121
+ onClick={() => { void writePin(props.theme, props.appId, { kind: 'app-file' }, setPin, setFailed, label, appTheme?.style) }}
122
+ />
123
+ ) : null}
124
+ </div>
125
+ {failed.length > 0 ? <p className="mt-2 text-xs text-destructive">{failed}</p> : null}
126
+ </>
127
+ )
128
+ }
129
+
130
+ function Seg(props: { readonly label: string; readonly on: boolean; readonly onClick: () => void }): ReactNode {
131
+ return (
132
+ <button type="button" className="h-7 flex-1 truncate rounded-md px-2 text-xs transition-colors duration-150 hover:bg-card data-[on=1]:bg-card data-[on=1]:font-semibold data-[on=1]:shadow-sm" data-on={props.on ? '1' : '0'} aria-pressed={props.on} onClick={props.onClick}>
133
+ {props.label}
134
+ </button>
135
+ )
136
+ }
137
+
138
+ function Swatch(props: {
139
+ readonly label: string
140
+ readonly swatch: string
141
+ readonly badge?: string
142
+ readonly on: boolean
143
+ readonly onClick: () => void
144
+ }): ReactNode {
145
+ return (
146
+ <button type="button" className="flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2.5 text-left text-sm transition-colors duration-150 hover:bg-muted data-[on=1]:bg-muted data-[on=1]:font-semibold" data-on={props.on ? '1' : '0'} aria-pressed={props.on} onClick={props.onClick}>
147
+ <i className="size-3.5 shrink-0 rounded-full border" style={{ background: props.swatch }} />
148
+ <span className="min-w-0 flex-1 truncate">{props.label}</span>
149
+ {props.badge === undefined ? null : <span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">{props.badge}</span>}
150
+ </button>
151
+ )
152
+ }
153
+
154
+ function paletteOn(appScope: boolean, pin: ThemePin, paletteId: string, id: string): boolean {
155
+ if (appScope) return pin.kind === 'palette' && pin.id === id
156
+ return paletteId === id
157
+ }
158
+
159
+ function paletteLabel(locale: PanelLocale, palette: PaletteChip): string {
160
+ if (locale === 'zh-CN' && palette.nameZh !== undefined && palette.nameZh.length > 0) return palette.nameZh
161
+ return palette.name
162
+ }
163
+
164
+ function themeKey(theme: 'system' | 'light' | 'dark'): 'theme-system' | 'theme-light' | 'theme-dark' {
165
+ if (theme === 'light') return 'theme-light'
166
+ if (theme === 'dark') return 'theme-dark'
167
+ return 'theme-system'
168
+ }
169
+
170
+ async function choosePalette(
171
+ props: {
172
+ readonly theme?: ThemeClient
173
+ readonly settings?: PanelSettingsClient
174
+ readonly appId?: string
175
+ readonly onHostPolicy?: (policy: PanelPolicy) => void
176
+ },
177
+ appScope: boolean,
178
+ palette: PaletteChip,
179
+ setPaletteId: (id: string) => void,
180
+ setPin: (pin: ThemePin) => void,
181
+ setFailed: (message: string) => void,
182
+ label: (key: string) => string,
183
+ ): Promise<void> {
184
+ if (appScope) {
185
+ await writePin(props.theme, props.appId, { kind: 'palette', id: palette.id }, setPin, setFailed, label, palette.style)
186
+ return
187
+ }
188
+ await writePalette(props.settings, palette.id, setPaletteId, setFailed, label, palette.style, props.onHostPolicy)
189
+ }
190
+
191
+ async function writeTheme(
192
+ settings: PanelSettingsClient | undefined,
193
+ theme: 'system' | 'light' | 'dark',
194
+ setAppearance: (theme: 'system' | 'light' | 'dark') => void,
195
+ setFailed: (message: string) => void,
196
+ label: (key: string) => string,
197
+ onHostPolicy?: (policy: PanelPolicy) => void,
198
+ ): Promise<void> {
199
+ if (settings === undefined) return
200
+ try {
201
+ const current = await settings.readPolicy()
202
+ const written = await settings.writePolicy({ ...current, theme })
203
+ applyDocumentMode(theme)
204
+ setAppearance(theme)
205
+ onHostPolicy?.(written.policy)
206
+ setFailed('')
207
+ } catch (error) {
208
+ setFailed(error instanceof Error && error.message.length > 0 ? error.message : label('theme-save-failed'))
209
+ }
210
+ }
211
+
212
+ async function writePalette(
213
+ settings: PanelSettingsClient | undefined,
214
+ palette: string,
215
+ setPaletteId: (id: string) => void,
216
+ setFailed: (message: string) => void,
217
+ label: (key: string) => string,
218
+ style?: string,
219
+ onHostPolicy?: (policy: PanelPolicy) => void,
220
+ ): Promise<void> {
221
+ if (settings === undefined) return
222
+ try {
223
+ const current = await settings.readPolicy()
224
+ const written = await settings.writePolicy({ ...current, palette })
225
+ setPaletteId(palette)
226
+ onHostPolicy?.(written.policy)
227
+ setFailed('')
228
+ if (style !== undefined) paintStyle(style)
229
+ } catch (error) {
230
+ setFailed(error instanceof Error && error.message.length > 0 ? error.message : label('theme-save-failed'))
231
+ }
232
+ }
233
+
234
+ async function writePin(
235
+ theme: ThemeClient | undefined,
236
+ appId: string | undefined,
237
+ pin: ThemePin,
238
+ setPin: (pin: ThemePin) => void,
239
+ setFailed: (message: string) => void,
240
+ label: (key: string) => string,
241
+ style?: string,
242
+ ): Promise<void> {
243
+ if (theme === undefined || appId === undefined) return
244
+ try {
245
+ setPin(await theme.setPin(appId, pin))
246
+ setFailed('')
247
+ if (style !== undefined) paintStyle(style, appId)
248
+ } catch (error) {
249
+ setFailed(error instanceof Error && error.message.length > 0 ? error.message : label('theme-save-failed'))
250
+ }
251
+ }
252
+
253
+ function paintStyle(style: string, appId?: string): void {
254
+ if (typeof document === 'undefined') return
255
+ if (appId === undefined) {
256
+ const node = document.getElementById('mma-theme')
257
+ if (node) node.textContent = style
258
+ }
259
+ const frames = appId === undefined
260
+ ? [...document.querySelectorAll('iframe')]
261
+ : [document.querySelector(`iframe[title="${CSS.escape(appId)}"]`)]
262
+ for (const frame of frames) {
263
+ if (frame instanceof HTMLIFrameElement) frame.contentWindow?.postMessage({ type: 'theme', style, variables: {} }, window.location.origin)
264
+ }
265
+ }
266
+
267
+ /** Host-wide palette CSS preview (panel + all app iframes). */
268
+ export function paintHostStyle(style: string): void {
269
+ paintStyle(style)
270
+ }
271
+
272
+ /** Paint `data-mode` on the panel document. `system` follows the OS. */
273
+ export function applyDocumentMode(theme: 'system' | 'light' | 'dark'): void {
274
+ if (typeof document === 'undefined') return
275
+ const mode = theme === 'system' ? systemDocumentMode() : theme
276
+ document.documentElement.dataset.mode = mode
277
+ for (const frame of document.querySelectorAll('iframe')) {
278
+ if (!(frame instanceof HTMLIFrameElement)) continue
279
+ frame.contentWindow?.postMessage({ type: 'theme', mode, variables: {} }, window.location.origin)
280
+ }
281
+ }
282
+
283
+ function systemDocumentMode(): 'light' | 'dark' {
284
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return 'light'
285
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
286
+ }
@@ -0,0 +1,113 @@
1
+ import type { PaletteChip, IgnoredPalette, ThemeClient, ThemePin } from './client.ts'
2
+
3
+ export interface ThemeState {
4
+ readonly palettes: readonly PaletteChip[]
5
+ readonly ignored: readonly IgnoredPalette[]
6
+ readonly pin: ThemePin
7
+ readonly failed: boolean
8
+ readonly appFile: boolean
9
+ readonly error?: string
10
+ }
11
+
12
+ export type ThemeAction =
13
+ | { readonly type: 'loaded'; readonly palettes: readonly PaletteChip[]; readonly ignored: readonly IgnoredPalette[] }
14
+ | { readonly type: 'failed'; readonly message?: string }
15
+ | { readonly type: 'saved'; readonly pin: ThemePin }
16
+ | { readonly type: 'save-failed'; readonly message?: string }
17
+ | { readonly type: 'app-file'; readonly parsed: boolean }
18
+ | { readonly type: 'pin'; readonly pin: ThemePin }
19
+
20
+ /** Picker before the palette list loads. */
21
+ export function themeState(): ThemeState {
22
+ return { palettes: [], ignored: [], pin: { kind: 'default' }, failed: false, appFile: false }
23
+ }
24
+
25
+ /**
26
+ * Theme picker transition. A failed save leaves the previous pin.
27
+ * @param state - current picker
28
+ * @param action - one client result
29
+ */
30
+ export function reduceTheme(state: ThemeState, action: ThemeAction): ThemeState {
31
+ switch (action.type) {
32
+ case 'loaded':
33
+ return { ...omitError(state), palettes: action.palettes, ignored: action.ignored, failed: false }
34
+ case 'failed':
35
+ return { ...omitError(state), failed: true, ...action.message ? { error: action.message } : {} }
36
+ case 'saved':
37
+ return { ...omitError(state), pin: action.pin, failed: false }
38
+ case 'save-failed':
39
+ return { ...omitError(state), failed: true, ...action.message ? { error: action.message } : {} }
40
+ case 'app-file':
41
+ return { ...state, appFile: action.parsed }
42
+ case 'pin':
43
+ return { ...omitError(state), pin: action.pin, failed: false }
44
+ default:
45
+ return action satisfies never
46
+ }
47
+ }
48
+
49
+ /** Refetch palettes when the picker opens. */
50
+ export async function loadPalettes(
51
+ client: ThemeClient,
52
+ dispatch: (action: ThemeAction) => void,
53
+ ): Promise<void> {
54
+ try {
55
+ const listed = await client.listPalettes()
56
+ dispatch({ type: 'loaded', palettes: listed.palettes, ignored: listed.ignored })
57
+ } catch (error) {
58
+ dispatch({ type: 'failed', ...thrown(error) })
59
+ }
60
+ }
61
+
62
+ /** Read the stored pin when the picker opens. A missing method leaves the default. */
63
+ export async function loadPin(
64
+ client: ThemeClient,
65
+ appId: string,
66
+ dispatch: (action: ThemeAction) => void,
67
+ ): Promise<void> {
68
+ if (client.readPin === undefined) return
69
+ try {
70
+ dispatch({ type: 'pin', pin: await client.readPin(appId) })
71
+ } catch (error) {
72
+ dispatch({ type: 'failed', ...thrown(error) })
73
+ }
74
+ }
75
+
76
+ /** Ask whether this app's `theme.css` parsed. A missing method leaves the row hidden. */
77
+ export async function loadAppFile(
78
+ client: ThemeClient,
79
+ appId: string,
80
+ dispatch: (action: ThemeAction) => void,
81
+ ): Promise<void> {
82
+ if (client.appFile === undefined) return
83
+ try {
84
+ dispatch({ type: 'app-file', parsed: await client.appFile(appId) })
85
+ } catch {
86
+ dispatch({ type: 'app-file', parsed: false })
87
+ }
88
+ }
89
+
90
+ /** Save a pin. Failure does not change the previous pin. */
91
+ export async function savePin(
92
+ client: ThemeClient,
93
+ appId: string,
94
+ pin: ThemePin,
95
+ dispatch: (action: ThemeAction) => void,
96
+ ): Promise<void> {
97
+ try {
98
+ dispatch({ type: 'saved', pin: await client.setPin(appId, pin) })
99
+ } catch (error) {
100
+ dispatch({ type: 'save-failed', ...thrown(error) })
101
+ }
102
+ }
103
+
104
+ function thrown(error: unknown): { message: string } | Record<string, never> {
105
+ const message = error instanceof Error ? error.message : ''
106
+ return message.length > 0 ? { message } : {}
107
+ }
108
+
109
+ function omitError(state: ThemeState): Omit<ThemeState, 'error'> {
110
+ const { error: gone, ...rest } = state
111
+ void gone
112
+ return rest
113
+ }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useReducer, type ReactNode } from 'react'
2
+
3
+ import { panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
4
+ import type { ThemeClient, ThemePin } from './client.ts'
5
+ import { loadAppFile, loadPalettes, loadPin, reduceTheme, savePin, themeState } from './state.ts'
6
+
7
+ /**
8
+ * Theme picker. Hidden when Host exposes no theme client. No route string lives here.
9
+ * The app-file row appears only in app scope when `theme.css` parsed.
10
+ * @param props - client, scope, and chrome locale
11
+ */
12
+ export function PanelTheme(props: {
13
+ readonly client?: ThemeClient
14
+ readonly appId?: string
15
+ readonly appFile?: boolean
16
+ readonly locale: PanelLocale
17
+ readonly mode: PanelLabelMode
18
+ }): ReactNode {
19
+ const [state, dispatch] = useReducer(reduceTheme, undefined, themeState)
20
+ useEffect(() => {
21
+ if (props.client === undefined) return
22
+ void loadPalettes(props.client, dispatch)
23
+ if (props.appId === undefined) return
24
+ void loadPin(props.client, props.appId, dispatch)
25
+ if (props.client.appFile === undefined) return
26
+ void loadAppFile(props.client, props.appId, dispatch)
27
+ }, [props.client, props.appId])
28
+ if (props.client === undefined) return null
29
+ const label = (key: string) => panelText(props.locale, key, props.mode)
30
+ const client = props.client
31
+ const appId = props.appId
32
+ const choose = (pin: ThemePin) => {
33
+ if (appId === undefined) return
34
+ void savePin(client, appId, pin, dispatch)
35
+ }
36
+ return (
37
+ <section className="flex max-h-80 w-72 flex-col gap-1 overflow-auto rounded-xl border bg-card p-2 text-card-foreground shadow-lg">
38
+ {state.failed ? <p className="px-2 py-1 text-xs text-destructive">{state.error !== undefined && state.error.length > 0 ? state.error : label('theme-save-failed')}</p> : null}
39
+ <PinButton label={label('clear-pin')} pressed={pressed(state.pin, { kind: 'default' }, appId)} onClick={() => choose({ kind: 'default' })} />
40
+ <PinButton label={label('follow-host')} pressed={pressed(state.pin, { kind: 'follow-host' }, appId)} onClick={() => choose({ kind: 'follow-host' })} />
41
+ {(state.appFile || props.appFile === true) && appId !== undefined ? (
42
+ <PinButton label={label('app-file')} pressed={pressed(state.pin, { kind: 'app-file' }, appId)} onClick={() => choose({ kind: 'app-file' })} />
43
+ ) : null}
44
+ <ul className="flex flex-col gap-1">
45
+ {state.palettes.map(palette => (
46
+ <li key={palette.id}>
47
+ <PinButton
48
+ label={palette.name}
49
+ pressed={pressed(state.pin, { kind: 'palette', id: palette.id }, appId)}
50
+ onClick={() => choose({ kind: 'palette', id: palette.id })}
51
+ />
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ {state.ignored.map(item => <p key={item.file} className="px-2 text-[11px] text-muted-foreground">{item.reason}</p>)}
56
+ </section>
57
+ )
58
+ }
59
+
60
+ function PinButton(props: { readonly label: string; readonly pressed: boolean; readonly onClick: () => void }): ReactNode {
61
+ return (
62
+ <button type="button" className="flex h-9 w-full items-center rounded-lg px-2 text-left text-sm hover:bg-muted data-[on=1]:bg-muted" data-on={props.pressed ? '1' : '0'} aria-pressed={props.pressed} onClick={props.onClick}>
63
+ <span>{props.label}</span>
64
+ </button>
65
+ )
66
+ }
67
+
68
+ function pressed(current: ThemePin, next: ThemePin, appId: string | undefined): boolean {
69
+ if (appId === undefined) return false
70
+ if (current.kind !== next.kind) return false
71
+ if (current.kind === 'palette' && next.kind === 'palette') return current.id === next.id
72
+ return true
73
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * One modal. The scrim fades in and the card scales in.
5
+ * Callers pass content only. They do not repeat the motion classes.
6
+ * @param props - close handler, width, and the card body
7
+ */
8
+ export function Dialog(props: {
9
+ readonly children: ReactNode
10
+ readonly onClose: () => void
11
+ readonly width?: 'sm' | 'md' | 'wide'
12
+ }): ReactNode {
13
+ const width = props.width === 'sm'
14
+ ? 'max-w-sm'
15
+ : props.width === 'md'
16
+ ? 'max-w-2xl'
17
+ : props.width === 'wide' ? 'max-w-4xl' : 'max-w-xl'
18
+ return (
19
+ <div className="fixed inset-0 z-30 flex animate-in items-center justify-center bg-foreground/35 p-4 duration-150 fade-in-0" onClick={props.onClose}>
20
+ <div className={`max-h-[85vh] w-full overflow-auto rounded-2xl border bg-card p-5 shadow-lg animate-in duration-150 fade-in-0 zoom-in-95 ${width}`} onClick={event => event.stopPropagation()}>
21
+ {props.children}
22
+ </div>
23
+ </div>
24
+ )
25
+ }
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ /**
4
+ * Fast hover/focus hint. Native `title` is slow and easy to miss on icon chrome.
5
+ * @param props - hint text, the control, and optional alignment
6
+ */
7
+ export function Tooltip(props: {
8
+ readonly text: string
9
+ readonly children: ReactNode
10
+ readonly align?: 'center' | 'end'
11
+ readonly className?: string
12
+ }): ReactNode {
13
+ return (
14
+ <span className={props.className === undefined ? 'mma-tip' : `mma-tip ${props.className}`} data-align={props.align ?? 'center'}>
15
+ {props.children}
16
+ <span className="mma-tip-label" role="tooltip">{props.text}</span>
17
+ </span>
18
+ )
19
+ }