@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.
- package/README.md +8 -0
- package/lib/types/chrome/hue.d.ts +3 -0
- package/lib/types/chrome/hue.d.ts.map +1 -0
- package/lib/types/gallery/card-style.d.ts +8 -0
- package/lib/types/gallery/card-style.d.ts.map +1 -0
- package/lib/types/gallery/client.d.ts +20 -0
- package/lib/types/gallery/client.d.ts.map +1 -0
- package/lib/types/gallery/desk.d.ts +16 -0
- package/lib/types/gallery/desk.d.ts.map +1 -0
- package/lib/types/gallery/list.d.ts +37 -0
- package/lib/types/gallery/list.d.ts.map +1 -0
- package/lib/types/gallery/sort.d.ts +24 -0
- package/lib/types/gallery/sort.d.ts.map +1 -0
- package/lib/types/gallery/state.d.ts +90 -0
- package/lib/types/gallery/state.d.ts.map +1 -0
- package/lib/types/gallery/tabs.d.ts +45 -0
- package/lib/types/gallery/tabs.d.ts.map +1 -0
- package/lib/types/gallery/view.d.ts +57 -0
- package/lib/types/gallery/view.d.ts.map +1 -0
- package/lib/types/history/client.d.ts +25 -0
- package/lib/types/history/client.d.ts.map +1 -0
- package/lib/types/history/state.d.ts +39 -0
- package/lib/types/history/state.d.ts.map +1 -0
- package/lib/types/history/view.d.ts +15 -0
- package/lib/types/history/view.d.ts.map +1 -0
- package/lib/types/index.d.ts +24 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/labels.d.ts +267 -0
- package/lib/types/labels.d.ts.map +1 -0
- package/lib/types/settings/author-mcp-view.d.ts +15 -0
- package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
- package/lib/types/settings/author-mcp.d.ts +4 -0
- package/lib/types/settings/author-mcp.d.ts.map +1 -0
- package/lib/types/settings/client.d.ts +198 -0
- package/lib/types/settings/client.d.ts.map +1 -0
- package/lib/types/settings/credentials-view.d.ts +15 -0
- package/lib/types/settings/credentials-view.d.ts.map +1 -0
- package/lib/types/settings/form.d.ts +27 -0
- package/lib/types/settings/form.d.ts.map +1 -0
- package/lib/types/settings/install-row.d.ts +10 -0
- package/lib/types/settings/install-row.d.ts.map +1 -0
- package/lib/types/settings/mcp-view.d.ts +14 -0
- package/lib/types/settings/mcp-view.d.ts.map +1 -0
- package/lib/types/settings/skill-dirs.d.ts +6 -0
- package/lib/types/settings/skill-dirs.d.ts.map +1 -0
- package/lib/types/settings/skill-install.d.ts +13 -0
- package/lib/types/settings/skill-install.d.ts.map +1 -0
- package/lib/types/settings/state.d.ts +94 -0
- package/lib/types/settings/state.d.ts.map +1 -0
- package/lib/types/settings/update-dialog.d.ts +56 -0
- package/lib/types/settings/update-dialog.d.ts.map +1 -0
- package/lib/types/settings/view.d.ts +26 -0
- package/lib/types/settings/view.d.ts.map +1 -0
- package/lib/types/storage/client.d.ts +15 -0
- package/lib/types/storage/client.d.ts.map +1 -0
- package/lib/types/storage/state.d.ts +42 -0
- package/lib/types/storage/state.d.ts.map +1 -0
- package/lib/types/storage/view.d.ts +16 -0
- package/lib/types/storage/view.d.ts.map +1 -0
- package/lib/types/surface/state.d.ts +42 -0
- package/lib/types/surface/state.d.ts.map +1 -0
- package/lib/types/surface/view.d.ts +34 -0
- package/lib/types/surface/view.d.ts.map +1 -0
- package/lib/types/theme/client.d.ts +45 -0
- package/lib/types/theme/client.d.ts.map +1 -0
- package/lib/types/theme/menu.d.ts +25 -0
- package/lib/types/theme/menu.d.ts.map +1 -0
- package/lib/types/theme/state.d.ts +46 -0
- package/lib/types/theme/state.d.ts.map +1 -0
- package/lib/types/theme/view.d.ts +16 -0
- package/lib/types/theme/view.d.ts.map +1 -0
- package/lib/types/ui/dialog.d.ts +12 -0
- package/lib/types/ui/dialog.d.ts.map +1 -0
- package/lib/types/ui/tooltip.d.ts +12 -0
- package/lib/types/ui/tooltip.d.ts.map +1 -0
- package/package.json +38 -0
- package/src/chrome/hue.ts +6 -0
- package/src/gallery/card-style.ts +27 -0
- package/src/gallery/client.ts +26 -0
- package/src/gallery/desk.tsx +54 -0
- package/src/gallery/list.ts +54 -0
- package/src/gallery/sort.ts +72 -0
- package/src/gallery/state.ts +229 -0
- package/src/gallery/tabs.ts +85 -0
- package/src/gallery/view.tsx +501 -0
- package/src/history/client.ts +27 -0
- package/src/history/state.ts +90 -0
- package/src/history/view.tsx +131 -0
- package/src/index.ts +61 -0
- package/src/labels.ts +536 -0
- package/src/settings/author-mcp-view.tsx +154 -0
- package/src/settings/author-mcp.ts +24 -0
- package/src/settings/client.ts +205 -0
- package/src/settings/credentials-view.tsx +195 -0
- package/src/settings/form.ts +27 -0
- package/src/settings/install-row.tsx +35 -0
- package/src/settings/mcp-view.tsx +793 -0
- package/src/settings/skill-dirs.ts +41 -0
- package/src/settings/skill-install.tsx +134 -0
- package/src/settings/state.ts +225 -0
- package/src/settings/update-dialog.tsx +207 -0
- package/src/settings/view.tsx +722 -0
- package/src/storage/client.ts +16 -0
- package/src/storage/state.ts +94 -0
- package/src/storage/view.tsx +190 -0
- package/src/surface/state.ts +72 -0
- package/src/surface/view.tsx +255 -0
- package/src/theme/client.ts +34 -0
- package/src/theme/menu.tsx +286 -0
- package/src/theme/state.ts +113 -0
- package/src/theme/view.tsx +73 -0
- package/src/ui/dialog.tsx +25 -0
- 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
|
+
}
|