shell: the panel, createPanel(site) (moved from wayBetter for X)
packages/shell/src/panel (waybetter/shell/panel): an app's own tab. An app describes itself (PanelSite: brand and the home page's words, its own sections and their order, its theme store, catalog and the preview component a theme document is drawn in, Make's examples and docs, the install controller, its languages, what its native side serves) and createPanel returns the component for ShellSite.panel. It is in the shell package because it is drawn in the frame and shares its stores, native bridge and language. - Panel.tsx (header, settings search over every settings section, navigation, toasts; Accounts and Updates), nav.ts (which built-in sections an app gets: each shows only when the app serves what it needs; the app's sections in its order), Home.tsx (hero, highlights, About from the app's data), controls.tsx + panel.css. - Appearance.tsx, ThemeBuilder.tsx + builderModel.ts + builder.css, fonts/ (FontBrowser, FontRoles, catalog, the offline Google Fonts list; scripts/google-fonts-catalog.mjs regenerates it): the theme documents are the runtime's, the store, catalog, accents, draft palette, file suffix and preview are the app's (PanelSite.themes). - Plugins.tsx (the plugins list and custom CSS over the host's plugins_*/custom_css_*), StoreSection.tsx, MakePanel.tsx + makeModel.ts + MarkdownDoc.tsx + markdown.ts + make.css (the examples, the theme file and the docs are the app's), Settings.tsx (links and session over settings_get/set with `siteLinks`, keyword alerts, the media panel), FeatureToggles.tsx (useFeatures + FeatureGroups over features_get/set: the app names its flags and groups), PowerSettings.tsx (deck defaults from site.deck: its flags and presets; DeckPreset gets `name` and `hint`), BackupPanel.tsx + backup.css, DeveloperPanel.tsx, LanguageSettings.tsx, compare.ts. - frame: language.ts (createLanguage: follow the site / the system / one language, i18n_set; moved from the app), appI18n.ts (createAppI18n: the app's typed i18n instance, useT, dates and numbers; the app passes React's useSyncExternalStore), formatDate in i18n.ts (ShellI18n.date). - tests/panel.test.ts: every panel key has English and is used, sections per app, the builder model, the font catalog, plugin header rules, the UI kit demos, Markdown, compare. - npm package: exports ./shell/panel, ./shell/panel/*, ./shell/panel/*.css; the build is scripts/build.mjs (the shell is compiled with the runtime sources it imports under one root and its imports are pointed at dist/runtime; stylesheets and the font list are copied).
This commit is contained in:
parent
81ab5bd9fc
commit
b2767a8e56
44 changed files with 4864 additions and 2 deletions
332
packages/shell/src/panel/ThemeBuilder.tsx
Normal file
332
packages/shell/src/panel/ThemeBuilder.tsx
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
// The Theme Builder: a full editor for a theme document with a live preview on the real site panes (a
|
||||
// native, unsaved preview; closing the builder gives every tab its own theme back), the local
|
||||
// miniature and the chrome. Three base colors derive the whole palette; a contrast report shows every
|
||||
// pair the site draws and can fix them; fonts per role with the Google Fonts browser; shape, layout,
|
||||
// background, effects and theme CSS. Undo/redo (Ctrl+Z / Ctrl+Shift+Z), Save as theme, Export, Share.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import {
|
||||
MAX_CSS_CHARS, MAX_IMAGE_CHARS, isHex, sanitizeSpec,
|
||||
type Background, type EffectsSpec, type FontRole, type FontRoleName, type FontSpec, type LayoutSpec, type Pattern, type ShapeSpec, type ThemeColors, type ThemeSpec,
|
||||
} from '../../../runtime/src/themeSpec.ts';
|
||||
import { chromeVars } from '../frame/chromeTheme.ts';
|
||||
import { useT } from '../frame/hooks.ts';
|
||||
import { formatNumber, t, tx } from '../frame/i18n.ts';
|
||||
import { ThemeDots } from '../frame/PaneTheme.tsx';
|
||||
import {
|
||||
DERIVED_ROLES, autoFix, contrastReport, draftFrom, modeOf, paletteFrom, pinnedRoles, record, redo, startHistory, themeJson, undo, uniqueId, wcagLevel,
|
||||
type DerivedRole, type DraftSite, type History,
|
||||
} from './builderModel.ts';
|
||||
import { Button, ColorField, Segmented, Select, Slider, Toggle, type Option } from './controls.tsx';
|
||||
import { FontRoles } from './fonts/FontRoles.tsx';
|
||||
import { panelSite, panelThemes, type Notify } from './site.ts';
|
||||
import { useThemeState } from './themes.ts';
|
||||
import './builder.css';
|
||||
|
||||
/** Swatches for the three base colors when the app names none. */
|
||||
const BASE_SWATCHES = {
|
||||
bg: ['#000000', '#0b0d12', '#15202b', '#1a1b26', '#2e3440', '#ffffff', '#fbf8f1', '#fdf6e3'],
|
||||
text: ['#e7e9ea', '#eef0f7', '#ffffff', '#c0caf5', '#0f1419', '#2a241d', '#073642', '#000000'],
|
||||
accent: ['#1d9bf0', '#5b8cff', '#7856ff', '#bd93f9', '#f91880', '#ff7a00', '#00ba7c', '#3dff7a'],
|
||||
};
|
||||
const ROLE_LABEL: Record<DerivedRole, string> = { surface: 'wb.builder.role.surface', elevated: 'wb.builder.role.elevated', border: 'wb.builder.role.border', muted: 'wb.builder.role.muted', inverse: 'wb.builder.role.inverse', onInverse: 'wb.builder.role.onInverse', page: 'wb.builder.role.page' };
|
||||
const px = (v: number) => t('wb.appearance.px', { value: v });
|
||||
const pct = (v: number) => t('wb.appearance.percent', { value: v / 100 });
|
||||
const PATTERNS: Pattern[] = ['dots', 'grid', 'diagonal', 'scanlines', 'paper', 'clouds'];
|
||||
type BackgroundKind = 'none' | 'gradient' | 'pattern' | 'image';
|
||||
|
||||
export function ThemeBuilder({ notify }: { notify: Notify }) {
|
||||
const t = useT();
|
||||
const themes = panelThemes();
|
||||
const { allThemes, missingFonts, setChoice, setPreview, themeState } = themes.store;
|
||||
const resolveTheme = themes.catalog.resolve;
|
||||
const ThemePreview = themes.Preview;
|
||||
const draftSite: DraftSite = { colors: themes.draftColors, siteTheme: themes.catalog.defaultChoice.preset };
|
||||
const swatches = themes.baseSwatches ?? BASE_SWATCHES;
|
||||
const { ready, choice } = useThemeState();
|
||||
const [history, setHistory] = useState<History | null>(null);
|
||||
const [live, setLive] = useState(true);
|
||||
const dragFrom = useRef<ThemeSpec | null>(null);
|
||||
|
||||
// Start from the theme that is on now.
|
||||
useEffect(() => { if (ready && !history) setHistory(startHistory(draftFrom(resolveTheme(choice), draftSite))); }, [ready]);
|
||||
const draft = history?.present ?? null;
|
||||
|
||||
// Live on the real site panes and the chrome (debounced), until the builder closes.
|
||||
useEffect(() => {
|
||||
if (!draft) return;
|
||||
const timer = setTimeout(() => void setPreview(live ? draft : null), 120);
|
||||
return () => clearTimeout(timer);
|
||||
}, [draft, live]);
|
||||
useEffect(() => () => { void setPreview(null); }, []);
|
||||
|
||||
/** Applies a change; `commit: false` while a slider drags (one undo step per drag). */
|
||||
const change = useCallback((next: ThemeSpec, commit = true) => {
|
||||
setHistory(current => {
|
||||
if (!current) return current;
|
||||
if (!commit) { dragFrom.current ??= current.present; return record(current, next, { commit: false }); }
|
||||
const from = dragFrom.current ?? undefined;
|
||||
dragFrom.current = null;
|
||||
return record(current, next, { from });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y anywhere while the builder is open, except inside text fields.
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.isContentEditable || (/^(input|textarea)$/i.test(target.tagName) && !/^(range|color|checkbox)$/.test((target as HTMLInputElement).type)))) return;
|
||||
if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'z' && !event.shiftKey) { event.preventDefault(); setHistory(h => h && undo(h)); }
|
||||
else if ((key === 'z' && event.shiftKey) || key === 'y') { event.preventDefault(); setHistory(h => h && redo(h)); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
if (!draft || !history) return <div className="wb-builder is-loading" aria-busy="true"/>;
|
||||
const colors = draft.colors!;
|
||||
const set = <K extends keyof ThemeSpec>(key: K, value: ThemeSpec[K], commit = true) => change({ ...draft, [key]: value }, commit);
|
||||
const setColors = (next: ThemeColors, commit = true) => change({ ...draft, colors: next, mode: modeOf(next) }, commit);
|
||||
const setBase = (role: 'bg' | 'text' | 'accent', value: string) => {
|
||||
const base = { bg: colors.bg, text: colors.text, accent: colors.accent, [role]: value };
|
||||
// A new base re-derives every other role (hand-set roles belong to the old base).
|
||||
setColors(paletteFrom(base));
|
||||
};
|
||||
const setFont = (patch: Partial<FontSpec>, commit = true) => set('font', sanitizeSpec({ name: 'x', font: { ...draft.font, ...patch } }).font, commit);
|
||||
const setRole = (role: FontRoleName, next: FontRole, commit = true) => setFont({ roles: { ...draft.font.roles, [role]: next } }, commit);
|
||||
const setShape = (patch: Partial<ShapeSpec>, commit = true) => set('shape', { ...draft.shape, ...patch }, commit);
|
||||
const setLayout = (patch: Partial<LayoutSpec>, commit = true) => set('layout', { ...draft.layout, ...patch }, commit);
|
||||
const setEffects = (patch: Partial<EffectsSpec>, commit = true) => set('effects', { ...draft.effects, ...patch }, commit);
|
||||
|
||||
const startFrom = (id: string) => {
|
||||
const theme = allThemes(choice).find(item => item.id === id);
|
||||
if (!theme) return;
|
||||
change(draftFrom(id === choice.preset ? resolveTheme(choice) : theme, draftSite));
|
||||
notify(t('wb.builder.startedFrom', { name: theme.name }));
|
||||
};
|
||||
|
||||
async function save() {
|
||||
const state = themeState();
|
||||
const missing = missingFonts(draft!);
|
||||
const id = uniqueId(draft!.name, [...themes.catalog.builtins, ...state.choice.themes].map(theme => theme.id));
|
||||
const theme = sanitizeSpec({ ...draft!, id });
|
||||
try {
|
||||
await setChoice({ ...state.choice, themes: [...state.choice.themes, theme], preset: id, overrides: {}, accent: null });
|
||||
notify(`${t('wb.builder.saved', { name: theme.name })}${missing.length ? ` ${t('wb.builder.savedMissing', { fonts: missing.join(', '), count: missing.length })}` : ''}`);
|
||||
} catch (error) { notify(error instanceof Error ? error.message : String(error), true); }
|
||||
}
|
||||
function exportFile() {
|
||||
const url = URL.createObjectURL(new Blob([themeJson(draft!)], { type: 'application/json' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url; anchor.download = `${draft!.id}${themes.fileSuffix ?? '.theme.json'}`; anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
async function share() {
|
||||
try { await navigator.clipboard.writeText(themeJson(draft!)); notify(t('wb.builder.shared')); }
|
||||
catch { notify(t('wb.builder.copyFailed'), true); }
|
||||
}
|
||||
|
||||
const report = contrastReport(colors);
|
||||
const failing = report.filter(check => !check.pass).length;
|
||||
const options: Array<Option<string>> = allThemes(choice).map(theme => ({ value: theme.id, label: theme.name, icon: <ThemeDots theme={theme}/> }));
|
||||
|
||||
return <div className="wb-builder">
|
||||
<div className="wb-builder-bar" role="toolbar" aria-label={t('wb.panel.section.builder')}>
|
||||
<input className="wb-builder-name" aria-label={t('wb.builder.name')} value={draft.name} maxLength={60} spellCheck={false}
|
||||
onChange={event => set('name', event.target.value, false)} onBlur={event => set('name', event.target.value.trim() || t('wb.builder.untitled'))}/>
|
||||
<div className="wb-builder-tools">
|
||||
<Select label={t('wb.builder.startFrom')} value={null} placeholder={t('wb.builder.startFromPlaceholder')} options={options} width={176} onChange={startFrom}/>
|
||||
<span className="wb-divider" aria-hidden="true"/>
|
||||
<IconButton label={t('wb.builder.undo')} disabled={!history.past.length} onClick={() => setHistory(h => h && undo(h))} d="M6 4 3 7l3 3M3 7h7a4 4 0 0 1 0 8H8"/>
|
||||
<IconButton label={t('wb.builder.redo')} disabled={!history.future.length} onClick={() => setHistory(h => h && redo(h))} d="m10 4 3 3-3 3M13 7H6a4 4 0 0 0 0 8h2"/>
|
||||
<span className="wb-divider" aria-hidden="true"/>
|
||||
<label className="wb-live"><Toggle label={t('wb.builder.previewLive')} checked={live} onChange={setLive}/><span>{t('wb.builder.liveOnSite')}</span></label>
|
||||
<Button size="sm" tone="ghost" onClick={() => void share()}>{t('wb.builder.share')}</Button>
|
||||
<Button size="sm" tone="ghost" onClick={exportFile}>{t('wb.common.export')}</Button>
|
||||
<Button size="sm" tone="primary" onClick={() => void save()}>{t('wb.appearance.saveAsTheme')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wb-builder-grid">
|
||||
<div className="wb-builder-controls">
|
||||
<Card title={t('wb.builder.colors')} hint={t('wb.builder.colorsHint')}>
|
||||
<div className="wb-base">
|
||||
{(['bg', 'text', 'accent'] as const).map(role => <div key={role} className="wb-base-item">
|
||||
<div className="wb-base-swatch" style={{ background: colors[role] }}><span style={{ color: role === 'bg' ? colors.text : colors.bg }}>{role === 'bg' ? t('wb.appearance.background') : role === 'text' ? t('wb.builder.text') : t('wb.appearance.accent')}</span><code>{colors[role]}</code></div>
|
||||
<ColorField label={role === 'bg' ? t('wb.builder.backgroundColor') : role === 'text' ? t('wb.builder.textColor') : t('wb.appearance.accentColor')} value={colors[role]} swatches={swatches[role]} onChange={value => value && setBase(role, value)}/>
|
||||
</div>)}
|
||||
</div>
|
||||
<Palette colors={colors} onChange={setColors}/>
|
||||
</Card>
|
||||
|
||||
<Card title={t('wb.builder.contrast')} hint={t('wb.builder.contrastHint')}
|
||||
action={failing ? <Button size="sm" tone="primary" onClick={() => { setColors(autoFix(colors)); notify(t('wb.builder.adjusted', { count: failing })); }}>{t('wb.builder.autoFix', { count: failing })}</Button> : <span className="wb-ok">{t('wb.builder.allPass')}</span>}>
|
||||
<ul className="wb-contrast">
|
||||
{report.map(check => <li key={check.id} className={check.pass ? 'is-pass' : 'is-fail'}>
|
||||
<span className="wb-contrast-sample" style={{ color: check.fg, background: check.bg }}>Aa</span>
|
||||
<span className="wb-contrast-label">{tx(`wb.builder.contrast.${check.id}`, undefined, check.label)}</span>
|
||||
<span className="wb-contrast-ratio"><b>{formatNumber(check.ratio, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</b>:1</span>
|
||||
<span className="wb-contrast-need">{t('wb.builder.needs', { ratio: check.need })}</span>
|
||||
<span className={`wb-contrast-badge ${check.pass ? 'is-pass' : 'is-fail'}`}>{check.pass ? wcagLevel(check.ratio) : t('wb.builder.tooLow')}</span>
|
||||
</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={t('wb.builder.type')} hint={t('wb.builder.typeHint')}>
|
||||
<FontRoles font={draft.font} onRole={setRole}/>
|
||||
<div className="wb-fields">
|
||||
<Field label={t('wb.appearance.textSize')}><Slider label={t('wb.appearance.textSize')} min={85} max={120} value={Math.round(draft.font.scale * 100)} format={pct} onChange={v => setFont({ scale: v / 100 }, false)} onCommit={v => setFont({ scale: v / 100 })}/></Field>
|
||||
<Field label={t('wb.builder.regularWeight')}><Slider label={t('wb.builder.regularWeight')} min={300} max={500} step={10} value={draft.font.weight} format={String} onChange={v => setFont({ weight: v }, false)} onCommit={v => setFont({ weight: v })}/></Field>
|
||||
<Field label={t('wb.appearance.bold')}><Slider label={t('wb.appearance.bold')} min={500} max={800} step={10} value={draft.font.bold} format={String} onChange={v => setFont({ bold: v }, false)} onCommit={v => setFont({ bold: v })}/></Field>
|
||||
<Field label={t('wb.builder.tracking')}><Slider label={t('wb.builder.tracking')} min={-30} max={60} step={2} value={Math.round(draft.font.tracking * 1000)} format={v => `${formatNumber(v / 1000, { minimumFractionDigits: 3, maximumFractionDigits: 3 })} em`} onChange={v => setFont({ tracking: v / 1000 }, false)} onCommit={v => setFont({ tracking: v / 1000 })}/></Field>
|
||||
<Field label={t('wb.builder.leading')}><Slider label={t('wb.builder.leading')} min={115} max={180} step={5} value={draft.font.leading ? Math.round(draft.font.leading * 100) : 115} format={v => v === 115 ? t('wb.builder.leadingSite') : formatNumber(v / 100, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} onChange={v => setFont({ leading: v === 115 ? 0 : v / 100 }, false)} onCommit={v => setFont({ leading: v === 115 ? 0 : v / 100 })}/></Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('wb.builder.shape')}>
|
||||
<div className="wb-fields">
|
||||
<Field label={t('wb.builder.cardCorners')}><Slider label={t('wb.builder.cardCorners')} min={0} max={28} value={draft.shape.radius} format={px} onChange={v => setShape({ radius: v }, false)} onCommit={v => setShape({ radius: v })}/></Field>
|
||||
<Field label={t('wb.builder.buttons')}><Segmented label={t('wb.builder.buttonShape')} size="sm" value={draft.shape.button >= 100 ? 'pill' : draft.shape.button === 0 ? 'square' : 'soft'}
|
||||
onChange={v => setShape({ button: v === 'pill' ? 9999 : v === 'square' ? 0 : Math.max(4, Math.min(12, draft.shape.radius)) })}
|
||||
options={[{ value: 'square', label: t('wb.appearance.corners.square') }, { value: 'soft', label: t('wb.appearance.corners.soft') }, { value: 'pill', label: t('wb.builder.pill') }]}/></Field>
|
||||
<Field label={t('wb.appearance.avatars')}><Segmented label={t('wb.appearance.avatars')} size="sm" value={draft.shape.avatar} onChange={avatar => setShape({ avatar })}
|
||||
options={[{ value: 'circle', label: t('wb.appearance.avatars.circle') }, { value: 'rounded', label: t('wb.appearance.avatars.rounded') }, { value: 'square', label: t('wb.appearance.corners.square') }]}/></Field>
|
||||
<Field label={t('wb.appearance.density')}><Segmented label={t('wb.appearance.density')} size="sm" value={draft.layout.density} onChange={density => setLayout({ density })}
|
||||
options={[{ value: 'compact', label: t('wb.appearance.density.compact') }, { value: 'default', label: t('wb.appearance.density.default') }, { value: 'comfortable', label: t('wb.appearance.density.roomy') }]}/></Field>
|
||||
<Field label={t('wb.appearance.timeline')}><Segmented label={t('wb.appearance.timelineStyle')} size="sm" value={draft.layout.cards ? 'cards' : draft.layout.frame ? 'framed' : 'flat'}
|
||||
onChange={v => setLayout({ cards: v === 'cards', frame: v === 'framed' })} options={[{ value: 'flat', label: t('wb.appearance.timeline.flat') }, { value: 'framed', label: t('wb.appearance.timeline.framed') }, { value: 'cards', label: t('wb.appearance.timeline.cards') }]}/></Field>
|
||||
<Field label={t('wb.appearance.timelineWidth')}><Slider label={t('wb.appearance.timelineWidth')} min={520} max={820} step={10} value={draft.layout.column} format={px} onChange={column => setLayout({ column }, false)} onCommit={column => setLayout({ column })}/></Field>
|
||||
<Field label={t('wb.appearance.sidebarWidth')}><Slider label={t('wb.appearance.sidebarWidth')} min={290} max={400} step={10} value={draft.layout.sidebar} format={px} onChange={sidebar => setLayout({ sidebar }, false)} onCommit={sidebar => setLayout({ sidebar })}/></Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('wb.builder.backgroundEffects')}>
|
||||
<BackgroundEditor background={draft.background} colors={colors} notify={notify} onChange={(background, commit) => set('background', background, commit)}/>
|
||||
<div className="wb-fields">
|
||||
<Field label={t('wb.appearance.motion')}><Slider label={t('wb.appearance.motion')} min={0} max={400} step={10} value={draft.effects.motion} format={v => v ? t('wb.builder.ms', { value: v }) : t('wb.common.off')} onChange={motion => setEffects({ motion }, false)} onCommit={motion => setEffects({ motion })}/></Field>
|
||||
<Field label={t('wb.appearance.hover')}><Segmented label={t('wb.appearance.hoverEffect')} size="sm" value={draft.effects.hover} onChange={hover => setEffects({ hover })}
|
||||
options={[{ value: 'none', label: t('wb.appearance.hover.none') }, { value: 'tint', label: t('wb.appearance.hover.tint') }, { value: 'lift', label: t('wb.appearance.hover.lift') }, { value: 'glow', label: t('wb.appearance.bg.glow') }]}/></Field>
|
||||
<Field label={t('wb.builder.shadow')}><Segmented label={t('wb.builder.shadow')} size="sm" value={draft.effects.shadow} onChange={shadow => setEffects({ shadow })}
|
||||
options={[{ value: 'none', label: t('wb.appearance.hover.none') }, { value: 'soft', label: t('wb.appearance.corners.soft') }, { value: 'deep', label: t('wb.builder.deep') }]}/></Field>
|
||||
<Field label={t('wb.appearance.crt')}><Toggle label={t('wb.appearance.crt')} checked={draft.effects.crt} onChange={crt => setEffects({ crt })}/></Field>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title={t('wb.appearance.themeCss')} hint={t('wb.builder.cssHint')}>
|
||||
<div className="bt-code is-compact">
|
||||
<textarea aria-label={t('wb.appearance.themeCss')} spellCheck={false} value={draft.css} maxLength={MAX_CSS_CHARS} placeholder={panelSite().cssExamples?.builder ?? '/* CSS that travels with this theme */'}
|
||||
onChange={event => set('css', event.target.value, false)} onBlur={event => set('css', event.target.value)}/>
|
||||
<div className="bt-code-bar"><small>{t('wb.builder.cssLines', { count: draft.css.split('\n').length })}</small></div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<aside className="wb-builder-preview" aria-label={t('wb.builder.preview')}>
|
||||
<div className="wb-preview-frame">
|
||||
<ChromePreview theme={draft}/>
|
||||
<ThemePreview theme={draft}/>
|
||||
</div>
|
||||
<p className="wb-preview-note">{live ? <><i className="wb-live-dot"/>{t('wb.builder.liveNote')}</> : t('wb.builder.liveOff')}</p>
|
||||
<div className="wb-meta">
|
||||
<label><span>{t('wb.builder.author')}</span><input value={draft.author} maxLength={60} onChange={event => set('author', event.target.value, false)} onBlur={event => set('author', event.target.value.trim())}/></label>
|
||||
<label><span>{t('wb.builder.description')}</span><textarea rows={2} value={draft.description} maxLength={200} onChange={event => set('description', event.target.value, false)} onBlur={event => set('description', event.target.value.trim())}/></label>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Card({ title, hint, action, children }: { title: string; hint?: string; action?: ReactNode; children: ReactNode }) {
|
||||
return <section className="wb-card" aria-label={title}>
|
||||
<header><div><h3>{title}</h3>{hint && <p>{hint}</p>}</div>{action}</header>
|
||||
{children}
|
||||
</section>;
|
||||
}
|
||||
const Field = ({ label, children }: { label: string; children: ReactNode }) => <div className="wb-field"><span>{label}</span>{children}</div>;
|
||||
|
||||
function IconButton({ label, d, onClick, disabled }: { label: string; d: string; onClick(): void; disabled?: boolean }) {
|
||||
return <button type="button" className="wb-icon-button" aria-label={label} title={label} disabled={disabled} onClick={onClick}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d={d} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>;
|
||||
}
|
||||
|
||||
/** Every derived role as a swatch; a click opens its color field, "Auto" un-pins it. */
|
||||
function Palette({ colors, onChange }: { colors: ThemeColors; onChange(next: ThemeColors): void }) {
|
||||
const t = useT();
|
||||
const [editing, setEditing] = useState<DerivedRole | null>(null);
|
||||
const pinned = pinnedRoles(colors);
|
||||
const base = { bg: colors.bg, text: colors.text, accent: colors.accent };
|
||||
return <div className="wb-palette">
|
||||
<div className="wb-palette-row" role="list" aria-label={t('wb.builder.paletteRoles')}>
|
||||
{DERIVED_ROLES.map(role => <button key={role} type="button" role="listitem" className={`wb-chip ${editing === role ? 'is-on' : ''}`} aria-expanded={editing === role} onClick={() => setEditing(editing === role ? null : role)}>
|
||||
<span className="wb-chip-swatch" style={{ background: colors[role] }}/>
|
||||
<span className="wb-chip-copy"><b>{t(ROLE_LABEL[role])}</b><small>{pinned[role] ? t('wb.builder.setByHand') : t('wb.builder.derived')} · {colors[role]}</small></span>
|
||||
</button>)}
|
||||
</div>
|
||||
{editing && <div className="wb-palette-edit">
|
||||
<span>{t(ROLE_LABEL[editing])}</span>
|
||||
<ColorField label={t(ROLE_LABEL[editing])} value={pinned[editing] ? colors[editing]! : null} autoLabel={t('wb.builder.auto')} swatches={[]}
|
||||
onChange={value => {
|
||||
const next = { ...pinned } as Partial<Record<DerivedRole, string>>;
|
||||
if (value && isHex(value)) next[editing] = value; else delete next[editing];
|
||||
onChange(paletteFrom(base, next));
|
||||
}}/>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function BackgroundEditor({ background, colors, notify, onChange }: { background: Background; colors: ThemeColors; notify: Notify; onChange(next: Background, commit?: boolean): void }) {
|
||||
const t = useT();
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const kind: BackgroundKind = background.kind;
|
||||
const page = colors.page ?? colors.bg;
|
||||
const switchTo = (next: BackgroundKind) => {
|
||||
if (next === 'none') onChange({ kind: 'none' });
|
||||
else if (next === 'gradient') onChange({ kind: 'gradient', from: page, to: page, angle: 180, glow: colors.accent });
|
||||
else if (next === 'pattern') onChange({ kind: 'pattern', pattern: 'dots', ink: colors.text, opacity: 0.12, size: 22 });
|
||||
else file.current?.click();
|
||||
};
|
||||
async function pick(input: File | undefined) {
|
||||
if (!input) return;
|
||||
if (!/^image\/(png|jpeg|webp|gif|avif)$/.test(input.type)) { notify(t('wb.appearance.imageType'), true); return; }
|
||||
const url = await new Promise<string>((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(input); });
|
||||
if (url.length > MAX_IMAGE_CHARS) { notify(t('wb.appearance.imageTooLarge'), true); return; }
|
||||
onChange({ kind: 'image', url, fit: 'cover', dim: modeOf(colors) === 'light' ? 0.55 : 0.6 });
|
||||
}
|
||||
return <div className="wb-bg">
|
||||
<Segmented<BackgroundKind> label={t('wb.appearance.background')} size="sm" value={kind} onChange={switchTo}
|
||||
options={[{ value: 'none', label: t('wb.appearance.bg.none') }, { value: 'gradient', label: t('wb.builder.gradient') }, { value: 'pattern', label: t('wb.builder.pattern') }, { value: 'image', label: t('wb.appearance.bg.image') }]}/>
|
||||
<input ref={file} type="file" hidden accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onChange={event => { void pick(event.target.files?.[0]); event.target.value = ''; }}/>
|
||||
{background.kind === 'gradient' && <div className="wb-fields">
|
||||
<Field label={t('wb.builder.from')}><ColorField label={t('wb.builder.gradientStart')} value={background.from} swatches={[page, colors.bg, colors.surface]} onChange={v => v && onChange({ ...background, from: v })}/></Field>
|
||||
<Field label={t('wb.builder.to')}><ColorField label={t('wb.builder.gradientEnd')} value={background.to} swatches={[page, colors.bg, colors.elevated]} onChange={v => v && onChange({ ...background, to: v })}/></Field>
|
||||
<Field label={t('wb.appearance.bg.glow')}><ColorField label={t('wb.appearance.bg.glow')} value={background.glow ?? null} autoLabel={t('wb.appearance.hover.none')} swatches={[colors.accent]} onChange={v => onChange({ ...background, glow: v ?? undefined })}/></Field>
|
||||
<Field label={t('wb.builder.angle')}><Slider label={t('wb.builder.gradientAngle')} min={0} max={360} step={5} value={background.angle} format={v => `${formatNumber(v)}°`} onChange={angle => onChange({ ...background, angle }, false)} onCommit={angle => onChange({ ...background, angle })}/></Field>
|
||||
</div>}
|
||||
{background.kind === 'pattern' && <div className="wb-fields">
|
||||
<Field label={t('wb.builder.pattern')}><Select<Pattern> label={t('wb.builder.pattern')} value={background.pattern} width={160} onChange={pattern => onChange({ ...background, pattern })} options={PATTERNS.map(value => ({ value, label: tx(`wb.appearance.bg.${value}`, undefined, value) }))}/></Field>
|
||||
<Field label={t('wb.builder.ink')}><ColorField label={t('wb.builder.patternInk')} value={background.ink} swatches={[colors.text, colors.accent, '#ffffff']} onChange={v => v && onChange({ ...background, ink: v })}/></Field>
|
||||
<Field label={t('wb.builder.strength')}><Slider label={t('wb.builder.patternStrength')} min={0} max={60} value={Math.round(background.opacity * 100)} format={pct} onChange={v => onChange({ ...background, opacity: v / 100 }, false)} onCommit={v => onChange({ ...background, opacity: v / 100 })}/></Field>
|
||||
<Field label={t('wb.builder.scale')}><Slider label={t('wb.builder.patternScale')} min={2} max={400} value={background.size} format={px} onChange={size => onChange({ ...background, size }, false)} onCommit={size => onChange({ ...background, size })}/></Field>
|
||||
</div>}
|
||||
{background.kind === 'image' && <div className="wb-fields">
|
||||
<Field label={t('wb.builder.dimming')}><Slider label={t('wb.appearance.dim')} min={35} max={95} value={Math.round(background.dim * 100)} format={pct} onChange={v => onChange({ ...background, dim: v / 100 }, false)} onCommit={v => onChange({ ...background, dim: v / 100 })}/></Field>
|
||||
<Field label={t('wb.builder.fit')}><Segmented label={t('wb.builder.photoFit')} size="sm" value={background.fit} onChange={fit => onChange({ ...background, fit })} options={[{ value: 'cover', label: t('wb.builder.cover') }, { value: 'tile', label: t('wb.builder.tile') }]}/></Field>
|
||||
<Field label={t('wb.builder.photo')}><Button size="sm" onClick={() => file.current?.click()}>{t('wb.builder.replace')}</Button></Field>
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** The app's window in the draft: a tab strip and an address bar drawn with the chrome tokens. */
|
||||
function ChromePreview({ theme }: { theme: ThemeSpec }) {
|
||||
const themes = panelThemes();
|
||||
const vars = useMemo(() => chromeVars(themes.store.adapter.chrome(theme)), [theme]);
|
||||
const sample = themes.chromePreview ?? { tabs: ['Home', '@waybetter', panelSite().brand.name] as [string, string, string], chip: panelSite().brand.name, page: 'Home' };
|
||||
return <div className="wb-chrome-preview" style={vars as CSSProperties} aria-hidden="true">
|
||||
<div className="wb-cp-tabs">
|
||||
<span className="wb-cp-tab is-on"><i/>{sample.tabs[0]}</span><span className="wb-cp-tab"><i/>{sample.tabs[1]}</span><span className="wb-cp-tab"><i/>{sample.tabs[2]}</span>
|
||||
</div>
|
||||
<div className="wb-cp-bar"><span className="wb-cp-dot"/><span className="wb-cp-omni"><b>{sample.chip}</b><em>/</em>{sample.page}</span><span className="wb-cp-brand"/></div>
|
||||
</div>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue