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).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-30 14:03:40 +07:00
parent 7a3f23aa82
commit ad2a3ad375
44 changed files with 4864 additions and 2 deletions

View file

@ -0,0 +1,412 @@
// Appearance: the theme gallery (live previews rendered from each theme document), per-group
// customization on top of the selected theme (fonts per role with the Google Fonts browser),
// account and pane themes, downloaded fonts, import/export of theme JSON and Custom CSS. The theme
// documents, the store and the miniature are the app's (`PanelSite.themes`).
import { useEffect, useRef, useState } from 'react';
import { THEME_SCHEMA_URL } from '../../../runtime/src/themeApi.ts';
import type { ThemeChoice } from '../../../runtime/src/themeChoice.ts';
import {
FONT_CHOICES, MAX_IMAGE_CHARS, fontChoiceOf, googleFontsUsed, parseThemeJson, slug,
type Background, type FontRole, type FontRoleName, type Pattern, type ThemeOverrides, type ThemeSpec,
} from '../../../runtime/src/themeSpec.ts';
import { AccountAvatar, accountName, useProfiles } from '../frame/AccountSwitcher.tsx';
import { useT } from '../frame/hooks.ts';
import { formatDate, t, tx } from '../frame/i18n.ts';
import { ThemeDots } from '../frame/PaneTheme.tsx';
import { shell, type WebParams } from '../frame/state.ts';
import { assign } from '../frame/themeScopes.ts';
import { tabProfile } from '../profileModel.ts';
import { Button, ColorField, Row, Section, Segmented, Select, Slider, Toggle, useSettingsQuery, type Option } from './controls.tsx';
import { bytesLabel, defaultWeights, loadCatalog, weightsLabel } from './fonts/catalog.ts';
import { FontRoles } from './fonts/FontRoles.tsx';
import { CustomCssEditor } from './Plugins.tsx';
import { panelSite, panelThemes, type Notify } from './site.ts';
import { useThemeState } from './themes.ts';
import './builder.css';
const getError = (error: unknown) => error instanceof Error ? error.message : String(error);
const CORNERS = [
{ value: 'square', label: 'wb.appearance.corners.square', radius: 0, button: 0 },
{ value: 'crisp', label: 'wb.appearance.corners.crisp', radius: 5, button: 5 },
{ value: 'soft', label: 'wb.appearance.corners.soft', radius: 12, button: 10 },
{ value: 'round', label: 'wb.appearance.corners.round', radius: 16, button: 9999 },
] as const;
type BackgroundChoice = 'none' | 'glow' | Pattern | 'image';
const backgrounds = (): Array<Option<BackgroundChoice>> => [
{ value: 'none', label: t('wb.appearance.bg.none') }, { value: 'glow', label: t('wb.appearance.bg.glow') }, { value: 'dots', label: t('wb.appearance.bg.dots') }, { value: 'grid', label: t('wb.appearance.bg.grid') },
{ value: 'diagonal', label: t('wb.appearance.bg.diagonal') }, { value: 'scanlines', label: t('wb.appearance.bg.scanlines') }, { value: 'paper', label: t('wb.appearance.bg.paper') },
{ value: 'clouds', label: t('wb.appearance.bg.clouds') }, { value: 'image', label: t('wb.appearance.bg.image'), hint: t('wb.appearance.bg.imageHint') },
];
const modeLabel = (theme: ThemeSpec) => theme.mode === 'auto' ? t('wb.appearance.mode.auto') : theme.mode === 'light' ? t('wb.appearance.mode.light') : t('wb.appearance.mode.dark');
const fontLabel = (theme: ThemeSpec) => {
const family = theme.font.family;
const choices = panelThemes().fontChoices ?? FONT_CHOICES;
const choice = choices.find(item => item.id === fontChoiceOf(family, choices));
return (choice?.key ? tx(choice.key, undefined, choice.label) : choice?.label) ?? family[0] ?? panelThemes().siteFont;
};
/** Built-in themes have translated descriptions (`theme.<id>.description` in the app's catalog); imported ones keep their own. */
export const themeDescription = (theme: ThemeSpec) => tx(`theme.${theme.id}.description`, undefined, theme.description);
const formatPercent = (value: number) => t('wb.appearance.percent', { value: value / 100 });
const hasOverrides = (choice: ThemeChoice) => !!choice.accent || Object.keys(choice.overrides).length > 0;
function download(name: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const anchor = document.createElement('a');
anchor.href = url; anchor.download = name; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function backgroundFor(kind: BackgroundChoice, theme: ThemeSpec, image?: string): Background {
const base = panelThemes().draftColors;
const text = theme.colors?.text ?? '#8b98a5';
const light = theme.mode === 'light';
switch (kind) {
case 'none': return { kind: 'none' };
case 'glow': return { kind: 'gradient', from: theme.colors?.page ?? theme.colors?.bg ?? base.bg, to: theme.colors?.page ?? theme.colors?.bg ?? base.bg, angle: 180, glow: theme.colors?.accent ?? base.accent };
case 'image': return { kind: 'image', url: image ?? '', fit: 'cover', dim: light ? 0.55 : 0.6 };
case 'clouds': return { kind: 'pattern', pattern: 'clouds', ink: light ? '#ffffff' : text, opacity: light ? 0.55 : 0.07, size: 220 };
case 'dots': return { kind: 'pattern', pattern: 'dots', ink: text, opacity: 0.16, size: 18 };
case 'grid': return { kind: 'pattern', pattern: 'grid', ink: text, opacity: 0.06, size: 28 };
case 'diagonal': return { kind: 'pattern', pattern: 'diagonal', ink: text, opacity: 0.06, size: 10 };
case 'scanlines': return { kind: 'pattern', pattern: 'scanlines', ink: theme.colors?.accent ?? text, opacity: 0.05, size: 4 };
case 'paper': return { kind: 'pattern', pattern: 'paper', ink: light ? '#7a6a50' : text, opacity: 0.1, size: 180 };
}
}
const backgroundChoice = (background: Background): BackgroundChoice =>
background.kind === 'none' ? 'none' : background.kind === 'gradient' ? 'glow' : background.kind === 'image' ? 'image' : background.pattern;
/** Asks the panel to show another section (the Theme Builder). */
const openSection = (section: string) => window.dispatchEvent(new CustomEvent('bt-panel-section', { detail: section }));
export function Appearance({ notify }: { notify: Notify }) {
const t = useT();
const themes = panelThemes();
const { allThemes, installFont, missingFonts, setChoice, setScopes } = themes.store;
const ThemePreview = themes.Preview;
const { ready, choice, scopes } = useThemeState();
const [importing, setImporting] = useState(false);
const [asking, setAsking] = useState<{ theme: ThemeSpec; families: string[] } | null>(null);
const query = useSettingsQuery();
if (!ready) return null;
const effective = themes.catalog.resolve(choice);
const base = themes.catalog.base(choice);
const all = allThemes(choice);
const siteTheme = themes.catalog.defaultChoice.preset;
const suffix = themes.fileSuffix ?? '.theme.json';
const update = (next: ThemeChoice, commit = true) => setChoice(next, commit).catch(error => notify(getError(error), true));
const override = (patch: ThemeOverrides, commit = true) => {
const overrides: ThemeOverrides = { ...choice.overrides };
for (const [key, value] of Object.entries(patch) as Array<[keyof ThemeOverrides, unknown]>) {
(overrides as Record<string, unknown>)[key] = key === 'background' ? value : { ...(choice.overrides[key] as object | undefined), ...(value as object) };
}
return update({ ...choice, overrides }, commit);
};
const setRole = (role: FontRoleName, next: FontRole, commit = true) =>
override({ font: { roles: { ...(choice.overrides.font?.roles ?? {}), [role]: next } } }, commit);
function select(id: string) {
if (id === choice.preset) return;
// Customizations belong to the theme they were made on; switching starts clean.
void update({ ...choice, preset: id, overrides: {}, accent: null });
}
function remove(theme: ThemeSpec) {
const themes = choice.themes.filter(item => item.id !== theme.id);
void update({ ...choice, themes, ...(choice.preset === theme.id ? { preset: siteTheme, overrides: {}, accent: null } : {}) }).then(() => notify(t('wb.appearance.removedTheme', { name: theme.name })));
}
function add(theme: ThemeSpec, message: string) {
let id = theme.id;
for (let n = 2; all.some(item => item.id === id); n++) id = `${theme.id.slice(0, 36)}-${n}`;
void update({ ...choice, themes: [...choice.themes, { ...theme, id }], preset: id, overrides: {}, accent: null }).then(() => notify(message));
}
function exportTheme() {
const custom = hasOverrides(choice);
const doc: ThemeSpec = { ...effective, ...(custom ? { id: slug(`${effective.name} custom`), name: t('wb.appearance.customName', { name: effective.name }) } : {}) };
// `$schema` lets editors validate and complete the file; imports ignore it.
download(`${doc.id}${suffix}`, JSON.stringify({ $schema: THEME_SCHEMA_URL, ...doc }, null, 2));
}
function saveAsNew() {
const name = t('wb.appearance.savedName', { name: base.name });
add({ ...effective, id: slug(`${base.name} custom`), name, author: t('wb.appearance.you'), description: t('wb.appearance.basedOn', { name: base.name }) }, t('wb.appearance.saved', { name }));
}
/** An imported theme that names Google fonts asks before anything is downloaded. */
function imported(theme: ThemeSpec, warnings: string[]) {
setImporting(false);
add(theme, `${t('wb.appearance.imported', { name: theme.name })}${warnings.length ? ` ${warnings.join(' ')}` : ''}`);
const families = missingFonts(theme);
if (families.length) setAsking({ theme, families });
}
async function downloadFonts(families: string[]) {
setAsking(null);
const catalog = await loadCatalog();
for (const family of families) {
const font = catalog.find(item => item.family === family);
try {
await installFont(family, font ? defaultWeights(font) : { kind: 'list', values: [400, 700] });
notify(t('wb.appearance.downloadedFont', { family }));
} catch (error) { notify(`${family}: ${getError(error)}`, true); }
}
}
const cornerValue = CORNERS.find(corner => corner.radius === effective.shape.radius && corner.button === effective.shape.button)?.value ?? null;
const timeline = effective.layout.cards ? 'cards' : effective.layout.frame ? 'framed' : 'flat';
async function pickImage(file: File | undefined) {
if (!file) return;
if (!/^image\/(png|jpeg|webp|gif|avif)$/.test(file.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(file); });
if (url.length > MAX_IMAGE_CHARS) { notify(t('wb.appearance.imageTooLarge'), true); return; }
void override({ background: backgroundFor('image', effective, url) });
}
const searching = !!query;
return <>
{!searching && <Section title={t('wb.appearance.theme')} id="themes" hint={t('wb.appearance.themeHint')}
action={<div className="bt-inline">
<Button size="sm" tone="ghost" onClick={() => setImporting(!importing)} icon={<Glyph d="M8 2v8m0 0L5 7m3 3 3-3M3 13h10"/>}>{t('wb.common.import')}</Button>
<Button size="sm" tone="ghost" onClick={exportTheme} icon={<Glyph d="M8 11V3m0 0L5 6m3-3 3 3M3 13h10"/>}>{t('wb.common.export')}</Button>
<Button size="sm" tone="primary" onClick={() => openSection('builder')} icon={<Glyph d="M3 13l.6-2.6L10.5 3.5l2 2-6.9 6.9zM9 5l2 2"/>}>{t('wb.panel.section.builder')}</Button>
</div>}>
{importing && <ImportPanel onCancel={() => setImporting(false)} onImport={imported}/>}
{asking && <div className="bt-import wb-ask" role="alertdialog" aria-label={t('wb.appearance.askFonts')}>
<div className="bt-import-drop">
<strong>{t('wb.appearance.askFontsTitle', { name: asking.theme.name, fonts: asking.families.join(', ') })}</strong>
<p>{t('wb.appearance.askFontsText', { count: asking.families.length })}</p>
<div className="bt-inline"><Button size="sm" tone="primary" onClick={() => void downloadFonts(asking.families)}>{t('wb.panel.updates.download')}</Button><Button size="sm" tone="ghost" onClick={() => setAsking(null)}>{t('wb.common.notNow')}</Button></div>
</div>
</div>}
<div className="bt-gallery" role="radiogroup" aria-label={t('wb.appearance.theme')}>
{all.map(theme => {
const on = theme.id === choice.preset;
const isImported = choice.themes.some(item => item.id === theme.id);
return <div key={theme.id} className={`bt-theme ${on ? 'is-on' : ''}`}>
<button type="button" role="radio" aria-checked={on} className="bt-theme-hit" onClick={() => select(theme.id)} aria-label={`${theme.name}. ${themeDescription(theme)}`}>
<ThemePreview theme={on ? effective : theme}/>
<span className="bt-theme-info">
<span className="bt-theme-title"><strong>{theme.name}</strong>{on && <span className="bt-theme-check" aria-hidden="true"><Glyph d="m4 8.5 2.5 2.5L12 5.5"/></span>}</span>
<small>{themeDescription(theme)}</small>
<span className="bt-theme-tags"><em>{modeLabel(theme)}</em><em>{fontLabel(theme)}</em>{theme.layout.cards && <em>{t('wb.appearance.timeline.cards')}</em>}{googleFontsUsed(theme).length > 0 && <em>Google Fonts</em>}{isImported && <em className="is-accent">{t('wb.appearance.yours')}</em>}{theme.css && <em>CSS</em>}</span>
</span>
</button>
{isImported && <button type="button" className="bt-theme-remove" aria-label={t('wb.appearance.removeNamed', { name: theme.name })} title={t('wb.appearance.removeTheme')} onClick={() => remove(theme)}><Glyph d="m4.5 4.5 7 7m0-7-7 7"/></button>}
</div>;
})}
</div>
</Section>}
<Section title={searching ? t('wb.appearance.theme') : t('wb.appearance.customize', { name: base.name })} id="customize"
hint={searching ? undefined : t('wb.appearance.customizeHint')}
action={!searching && hasOverrides(choice) ? <div className="bt-inline">
<Button size="sm" tone="ghost" onClick={() => void update({ ...choice, overrides: {}, accent: null })}>{t('wb.common.reset')}</Button>
<Button size="sm" onClick={saveAsNew}>{t('wb.appearance.saveAsTheme')}</Button>
</div> : undefined}>
<Row label={t('wb.appearance.accent')} hint={t('wb.appearance.accentHint')} keywords="color colour highlight link" wide>
<ColorField label={t('wb.appearance.accentColor')} value={choice.accent} swatches={[...themes.accents]} autoLabel={t('wb.appearance.theme')} onChange={accent => void update({ ...choice, accent })}/>
</Row>
<Row label={t('wb.appearance.fonts')} hint={t('wb.appearance.fontsHint')}
keywords="typeface typography family font google fonts post text heading numbers code interface window dialogs" wide>
<FontRoles font={effective.font} onRole={setRole} compact/>
</Row>
<Row label={t('wb.appearance.textSize')} hint={t('wb.appearance.textSizeHint')} keywords="font scale bigger smaller zoom">
<Slider label={t('wb.appearance.textSize')} min={85} max={120} value={Math.round(effective.font.scale * 100)} format={v => formatPercent(v)}
onChange={v => void override({ font: { scale: v / 100 } }, false)} onCommit={v => void override({ font: { scale: v / 100 } })}/>
</Row>
<Row label={t('wb.appearance.bold')} hint={t('wb.appearance.boldHint')} keywords="font weight heavy">
<Segmented label={t('wb.appearance.bold')} size="sm" value={effective.font.bold >= 750 ? 800 : effective.font.bold >= 675 ? 700 : 600}
onChange={bold => void override({ font: { bold } })} options={[{ value: 600, label: t('wb.appearance.bold.medium') }, { value: 700, label: t('wb.appearance.bold.bold') }, { value: 800, label: t('wb.appearance.bold.heavy') }]}/>
</Row>
<Row label={t('wb.appearance.corners')} hint={t('wb.appearance.cornersHint')} keywords="radius rounded square shape">
<Segmented label={t('wb.appearance.corners')} size="sm" value={cornerValue} onChange={value => { const corner = CORNERS.find(item => item.value === value)!; void override({ shape: { radius: corner.radius, button: corner.button } }); }}
options={CORNERS.map(corner => ({ value: corner.value, label: t(corner.label) }))}/>
</Row>
<Row label={t('wb.appearance.avatars')} keywords="profile picture shape">
<Segmented label={t('wb.appearance.avatars')} size="sm" value={effective.shape.avatar} onChange={avatar => void override({ shape: { 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') }]}/>
</Row>
<Row label={t('wb.appearance.density')} hint={t('wb.appearance.densityHint')} keywords="compact spacing padding">
<Segmented label={t('wb.appearance.density')} size="sm" value={effective.layout.density} onChange={density => void override({ layout: { 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') }]}/>
</Row>
<Row label={t('wb.appearance.timeline')} hint={t('wb.appearance.timelineHint')} keywords="cards layout frame">
<Segmented label={t('wb.appearance.timelineStyle')} size="sm" value={timeline} onChange={value => void override({ layout: { cards: value === 'cards', frame: value === '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') }]}/>
</Row>
<Row label={t('wb.appearance.timelineWidth')} keywords="column layout wide narrow">
<Slider label={t('wb.appearance.timelineWidth')} min={520} max={820} step={10} value={effective.layout.column} format={v => t('wb.appearance.px', { value: v })}
onChange={column => void override({ layout: { column } }, false)} onCommit={column => void override({ layout: { column } })}/>
</Row>
<Row label={t('wb.appearance.sidebarWidth')} keywords="column layout trends">
<Slider label={t('wb.appearance.sidebarWidth')} min={290} max={400} step={10} value={effective.layout.sidebar} format={v => t('wb.appearance.px', { value: v })}
onChange={sidebar => void override({ layout: { sidebar } }, false)} onCommit={sidebar => void override({ layout: { sidebar } })}/>
</Row>
<Row label={t('wb.appearance.background')} hint={t('wb.appearance.backgroundHint')} keywords="wallpaper pattern image photo canvas">
<div className="bt-inline">
<Select label={t('wb.appearance.background')} value={backgroundChoice(effective.background)} options={backgrounds()} width={170}
onChange={kind => { if (kind === 'image') document.getElementById('bt-bg-file')?.click(); else void override({ background: backgroundFor(kind, effective) }); }}/>
<input id="bt-bg-file" type="file" hidden accept="image/png,image/jpeg,image/webp,image/gif,image/avif" onChange={event => { void pickImage(event.target.files?.[0]); event.target.value = ''; }}/>
</div>
</Row>
{effective.background.kind === 'image' && <Row label={t('wb.appearance.dim')} hint={t('wb.appearance.dimHint')} keywords="background image">
<Slider label={t('wb.appearance.dim')} min={35} max={95} value={Math.round(effective.background.dim * 100)} format={v => formatPercent(v)}
onChange={v => void override({ background: { ...(effective.background as Extract<Background, { kind: 'image' }>), dim: v / 100 } }, false)}
onCommit={v => void override({ background: { ...(effective.background as Extract<Background, { kind: 'image' }>), dim: v / 100 } })}/>
</Row>}
<Row label={t('wb.appearance.motion')} hint={t('wb.appearance.motionHint')} keywords="animation transition">
<Segmented label={t('wb.appearance.motion')} size="sm" value={effective.effects.motion === 0 ? 0 : effective.effects.motion < 200 ? 140 : 260} onChange={motion => void override({ effects: { motion } })}
options={[{ value: 0, label: t('wb.common.off') }, { value: 140, label: t('wb.appearance.motion.subtle') }, { value: 260, label: t('wb.appearance.motion.lively') }]}/>
</Row>
<Row label={t('wb.appearance.hover')} hint={t('wb.appearance.hoverHint')} keywords="effect highlight lift glow">
<Segmented label={t('wb.appearance.hoverEffect')} size="sm" value={effective.effects.hover} onChange={hover => void override({ effects: { 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') }]}/>
</Row>
<Row label={t('wb.appearance.crt')} hint={t('wb.appearance.crtHint')} keywords="retro terminal effect">
<Toggle label={t('wb.appearance.crt')} checked={effective.effects.crt} onChange={crt => void override({ effects: { crt } })}/>
</Row>
<ThemeCssEditor key={choice.preset} css={effective.css} onApply={text => { void override({ css: { text } }); notify(t('wb.appearance.cssApplied', { name: base.name })); }}/>
</Section>
<Section title={t('wb.appearance.scopes')} id="theme-scopes" hint={searching ? undefined : t('wb.appearance.scopesHint')}>
<Row label={t('wb.appearance.chrome')} hint={t('wb.appearance.chromeHint')}
keywords="chrome window tabs titlebar panel colors own style">
<Toggle label={t('wb.appearance.chrome')} checked={scopes.chrome === 'theme'} onChange={on => void setScopes({ ...scopes, chrome: on ? 'theme' : 'own' }).catch(error => notify(getError(error), true))}/>
</Row>
<AccountThemes notify={notify}/>
<PaneThemes notify={notify}/>
</Section>
<DownloadedFonts notify={notify}/>
<CustomCssEditor notify={notify}/>
</>;
}
/** Theme choices for a scope row: follow (the level above) plus every theme with its swatch. */
function scopeOptions(follow: string): Array<Option<string>> {
// Called during render of components that use useT(), so the labels follow the language.
return [{ value: '', label: follow }, ...panelThemes().store.allThemes().map(theme => ({ value: theme.id, label: theme.name, icon: <ThemeDots theme={theme}/> }))];
}
function AccountThemes({ notify }: { notify: Notify }) {
const t = useT();
const { setScopes, themeName, themeState } = panelThemes().store;
const profiles = useProfiles();
const { scopes, choice } = useThemeState();
return <Row label={t('wb.panel.section.accounts')} hint={t('wb.appearance.accountsHint')} keywords="account profile per account theme login" wide>
<div className="wb-scope-list">
{profiles.profiles.map(profile => <div key={profile.id} className="wb-scope">
<span className="wb-scope-who"><AccountAvatar profile={profile} size={26}/><span><b>{accountName(profile)}</b><small>{scopes.profiles[profile.id] ? t('wb.appearance.ownTheme') : t('wb.appearance.followsApp', { name: themeName(choice.preset) })}</small></span></span>
<Select label={t('wb.appearance.themeFor', { name: accountName(profile) })} value={scopes.profiles[profile.id] ?? ''} width={210} options={scopeOptions(t('wb.appearance.followApp'))}
onChange={id => void setScopes(assign(themeState().scopes, 'profiles', profile.id, id || null)).catch(error => notify(getError(error), true))}/>
</div>)}
</div>
</Row>;
}
/** Open site panes, each with the theme it shows and where that comes from. */
function PaneThemes({ notify }: { notify: Notify }) {
const t = useT();
const { setScopes, tabTheme, themeName, themeState } = panelThemes().store;
const { scopes } = useThemeState();
const [, refresh] = useState(0);
useEffect(() => {
const api = shell.api;
if (!api) return;
const bump = () => refresh(n => n + 1);
const subs = [api.onDidAddPanel(bump), api.onDidRemovePanel(bump), api.onDidLayoutChange(bump)];
return () => subs.forEach(sub => sub.dispose());
}, []);
const panes = (shell.api?.panels ?? []).filter(panel => (panel.params as WebParams | undefined)?.kind === 'web');
return <Row label={t('wb.appearance.panes')} hint={panes.length ? t('wb.appearance.panesHint') : t('wb.appearance.panesEmpty')} keywords="pane split tab per pane theme" wide>
{panes.length > 0 && <div className="wb-scope-list">
{panes.map(panel => {
const params = panel.params as WebParams;
const profile = tabProfile(shell.profiles, params.profile);
const current = tabTheme({ id: panel.id, profile: profile.id });
return <div key={panel.id} className="wb-scope">
<span className="wb-scope-who"><ThemeDots theme={current.theme}/><span><b>{panel.api.title || params.url}</b><small>{accountName(profile)} · {current.source === 'pane' ? t('wb.appearance.ownTheme') : current.source === 'account' ? t('wb.appearance.accountTheme', { name: themeName(current.id) }) : t('wb.appearance.appTheme', { name: themeName(current.id) })}</small></span></span>
<Select label={t('wb.appearance.themeFor', { name: panel.api.title || t('wb.appearance.pane') })} value={scopes.panes[panel.id] ?? ''} width={210} options={scopeOptions(t('wb.appearance.followAccount'))}
onChange={id => void setScopes(assign(themeState().scopes, 'panes', panel.id, id || null)).catch(error => notify(getError(error), true))}/>
</div>;
})}
</div>}
</Row>;
}
function DownloadedFonts({ notify }: { notify: Notify }) {
const t = useT();
const { removeFont } = panelThemes().store;
const { fonts } = useThemeState();
const query = useSettingsQuery();
const title = t('wb.appearance.downloaded');
if (query && !`downloaded fonts google fonts typeface remove storage ${title}`.toLowerCase().includes(query.toLowerCase().trim())) return null;
const total = fonts.reduce((sum, font) => sum + font.bytes, 0);
return <Section title={title} id="fonts" hint={fonts.length ? t('wb.appearance.downloadedHint', { count: fonts.length, size: bytesLabel(total) }) : t('wb.appearance.downloadedEmpty')}>
<Row label={fonts.length ? t('wb.appearance.families') : t('wb.appearance.noneYet')} hint={t('wb.appearance.familiesHint')} keywords="google fonts downloaded remove" wide>
{fonts.length > 0 && <ul className="wb-fontlist">
{fonts.map(font => <li key={font.family}>
<span><b style={{ fontFamily: `"${font.family}", system-ui` }}>{font.family}</b><br/><small>{weightsLabel(font.weights)} · {bytesLabel(font.bytes)}</small></span>
<small>{formatDate(font.installedAt, { dateStyle: 'medium' })}</small>
<Button size="sm" tone="ghost" onClick={() => void removeFont(font.family).then(() => notify(t('wb.appearance.removedFont', { family: font.family }))).catch(error => notify(getError(error), true))}>{t('wb.common.remove')}</Button>
</li>)}
</ul>}
</Row>
</Section>;
}
/** The selected theme's own CSS: travels with the theme (export, Save as theme); switching themes starts clean, like every customization. */
function ThemeCssEditor({ css, onApply }: { css: string; onApply(text: string): void }) {
const t = useT();
const [text, setText] = useState(css);
const query = useSettingsQuery();
useEffect(() => setText(css), [css]);
const label = t('wb.appearance.themeCss');
if (query && !`theme css stylesheet style code custom ${label}`.toLowerCase().includes(query.toLowerCase().trim())) return null;
const dirty = text !== css;
return <Row label={label} hint={t('wb.appearance.themeCssHint')} keywords="css stylesheet style code custom" wide>
<div className="bt-code is-compact" style={{ width: '100%' }}>
<textarea aria-label={t('wb.appearance.themeCssLabel')} spellCheck={false} value={text} placeholder={panelSite().cssExamples?.theme ?? '/* CSS that travels with this theme */'}
onChange={event => setText(event.target.value)}
onKeyDown={event => { if ((event.ctrlKey || event.metaKey) && event.key === 's') { event.preventDefault(); if (dirty) onApply(text); } }}/>
<div className="bt-code-bar">
<small>{t('wb.appearance.cssLines', { count: text.split('\n').length })}</small>
{dirty && <Button size="sm" tone="ghost" onClick={() => setText(css)}>{t('wb.common.discard')}</Button>}
<Button size="sm" tone="primary" disabled={!dirty} onClick={() => onApply(text)}>{t('wb.common.apply')}</Button>
</div>
</div>
</Row>;
}
function Glyph({ d }: { d: string }) {
return <svg width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d={d} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>;
}
function ImportPanel({ onImport, onCancel }: { onImport(theme: ThemeSpec, warnings: string[]): void; onCancel(): void }) {
const t = useT();
const [text, setText] = useState('');
const [error, setError] = useState('');
const file = useRef<HTMLInputElement>(null);
const run = (json: string) => {
const parsed = parseThemeJson(json);
if ('error' in parsed) { setError(parsed.error); return; }
onImport(parsed.theme, parsed.warnings);
};
return <div className="bt-import">
<div className="bt-import-drop" onDragOver={event => event.preventDefault()}
onDrop={event => { event.preventDefault(); void event.dataTransfer.files[0]?.text().then(run); }}>
<strong>{t('wb.appearance.importTitle')}</strong>
<p>{t('wb.appearance.importText')}</p>
<div className="bt-inline"><Button size="sm" tone="primary" onClick={() => file.current?.click()}>{t('wb.common.chooseFile')}</Button><Button size="sm" tone="ghost" onClick={onCancel}>{t('wb.common.cancel')}</Button></div>
<input ref={file} type="file" hidden accept="application/json,.json" onChange={event => { void event.target.files?.[0]?.text().then(run); event.target.value = ''; }}/>
</div>
<div className="bt-code is-compact">
<textarea aria-label={t('wb.appearance.themeJson')} spellCheck={false} placeholder={`${t('wb.appearance.pasteJson')}\n{ "name": "My theme", "colors": { … } }`} value={text} onChange={event => { setText(event.target.value); setError(''); }}/>
<div className="bt-code-bar">
{error ? <small className="is-error" role="alert">{error}</small> : <small>{t('wb.appearance.importSafe')}</small>}
<Button size="sm" disabled={!text.trim()} onClick={() => run(text)}>{t('wb.appearance.importJson')}</Button>
</div>
</div>
</div>;
}