diff --git a/.gitignore b/.gitignore index 9ec1bada..98e8c221 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,8 @@ src/databases/officer_db/migrations/ # Visual baselines for the theme extraction — large, machine-specific, regenerate with scripts/theme-snapshot.ts .snapshots/ + +# Installed themes, same rule as plugins: a theme is somebody's own work dropped into this directory, not +# this repository's content. Only the instructions are tracked. +themes/* +!themes/*.md diff --git a/src/apps/officer-web/frontend.tsx b/src/apps/officer-web/frontend.tsx index 7a6db451..0c336496 100644 --- a/src/apps/officer-web/frontend.tsx +++ b/src/apps/officer-web/frontend.tsx @@ -5,7 +5,7 @@ import { App } from './App'; import { seedAppRegistry, seedWidgetRegistry } from 'officerdev'; import { plugins } from './Plugins.gen'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { ColorModeProvider } from '@/components/ui/ThemeProvider'; +import { InstalledThemes } from '@/state/InstalledThemes'; import { I18nBridge } from '@/lib/I18nBridge'; import { Toaster as Sonner } from 'sonner'; import { TooltipProvider } from '@/components/ui/tooltip'; @@ -32,14 +32,14 @@ const elem = document.getElementById('root')!; const app = ( - + - + ); diff --git a/src/apps/officer-web/state/InstalledThemes.tsx b/src/apps/officer-web/state/InstalledThemes.tsx new file mode 100644 index 00000000..10a88b29 --- /dev/null +++ b/src/apps/officer-web/state/InstalledThemes.tsx @@ -0,0 +1,80 @@ +import type { ReactNode } from 'react'; +import { useEffect, useState } from 'react'; +import { ThemeProvider, type Theme } from '@/components/ui/ThemeProvider'; + +/** + * Themes installed on this machine, folded in beside the ones this build ships. + * + * `platform/themes//` — untracked, like `plugins/` — holds a `theme.css` and a `theme.json`. This asks + * the server what is there, injects each stylesheet, and hands the list to the provider so they appear in + * the picker and under the keyboard shortcuts. + * + * ── Why no rebuild is needed ── + * + * A plugin is code and has to reach the bundler. A theme is a block of CSS custom properties, so it can be + * a `` added at runtime — drop a directory in, reload, it is there. That difference is the whole + * reason installing a theme is cheaper than installing a plugin. + * + * ── Why the fetch lives here and not in the package ── + * + * The `theme` package performs no IO, for the same reason `workspaces` does not: it has to work for a host + * that has no server. Officer knows it has one, so Officer asks. + * + * A failure is not an error. No server, no session, no themes directory — all of them mean "you get the + * built-in themes", which is a complete product. + */ + +type InstalledTheme = { id: string; name: string; family: string; dark: boolean }; + +const STYLESHEET_ID = (id: string) => `officer-theme-${id}`; + +export const InstalledThemes = ({ children }: { children: ReactNode }) => { + const [installed, setInstalled] = useState([]); + + useEffect(() => { + let alive = true; + + const load = async () => { + let payload: { themes?: InstalledTheme[]; problems?: { id: string; problem: string }[] }; + try { + // The same token `useClient` uses. Read directly because this runs above the app's providers. + const token = + localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') || + localStorage.getItem('BEARER_TOKEN') || + sessionStorage.getItem('BEARER_TOKEN'); + const headers: Record = token ? { authorization: `Bearer ${token}` } : {}; + + const res = await fetch('/api/themes', { headers }); + if (!res.ok) return; + payload = await res.json(); + } catch { + return; // Offline, signed out, or no server. The built-ins are enough. + } + if (!alive || !payload.themes?.length) return; + + // Loud about a theme that nearly works: a typo in theme.json otherwise just means it never appears, + // and the author has nowhere to look. + for (const p of payload.problems ?? []) { + console.warn(`[themes] ${p.id}: ${p.problem}`); + } + + for (const theme of payload.themes) { + if (document.getElementById(STYLESHEET_ID(theme.id))) continue; + const link = document.createElement('link'); + link.id = STYLESHEET_ID(theme.id); + link.rel = 'stylesheet'; + link.href = `/api/themes/${theme.id}/theme.css`; + document.head.appendChild(link); + } + + setInstalled(payload.themes.map((t) => ({ id: t.id, name: t.name, family: t.family, applyDarkClass: t.dark }))); + }; + + void load(); + return () => { + alive = false; + }; + }, []); + + return {children}; +}; diff --git a/src/servers/api/themes/themes.ts b/src/servers/api/themes/themes.ts new file mode 100644 index 00000000..dbef1c53 --- /dev/null +++ b/src/servers/api/themes/themes.ts @@ -0,0 +1,41 @@ +import type { Context } from 'hono'; +import { createRouter } from '../../create-router'; +import { discoverThemes, readThemeCss } from '../../themes/discover'; + +/** + * Installed themes — the ones in `platform/themes/`, not the ones the platform ships. + * + * The shipped themes are compiled into the bundle and need no server. These are read off disk at request + * time, so dropping a directory in makes a theme appear on the next page load. No install step, no + * rebuild, no restart — a theme is CSS custom properties, and none of that needs compiling. + * + * Read-only on purpose. Installing a theme is `git clone` into a directory, exactly as it is for plugins; + * an upload endpoint would be a way to write arbitrary CSS into the page from a request, which is a worse + * trade than a shell command. + */ +export const themesRouter = createRouter(); + +// GET /themes — what is installed, and what is nearly installed. +// +// `problems` is returned rather than swallowed: a theme with a typo in its JSON otherwise just fails to +// appear, and the author has nowhere to look. The app store can render these. +themesRouter.get('/', async (ctx: Context) => { + const { themes, problems } = discoverThemes(); + return ctx.json({ themes, problems }); +}); + +// GET /themes/:id/theme.css — the stylesheet, injected by the client as a . +// +// Served as CSS rather than embedded in the JSON above so the browser caches it, and so a theme with a +// syntax error breaks only itself rather than the payload that lists every theme. +themesRouter.get('/:id/theme.css', async (ctx: Context) => { + const css = readThemeCss(ctx.req.param('id')); + if (css === null) return ctx.text('/* no such theme */', 404, { 'content-type': 'text/css; charset=utf-8' }); + + return ctx.body(css, 200, { + 'content-type': 'text/css; charset=utf-8', + // Short, because editing a theme and reloading is the whole authoring loop. Long enough that a page + // load does not refetch fourteen stylesheets. + 'cache-control': 'public, max-age=60', + }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index acdb4c03..2480a140 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -5,6 +5,7 @@ import type { HonoVariables } from './create-router'; import { authRouter } from './api/auth'; import { serverSettingsRouter } from './api/server-settings/server-settings'; import { landingPageDataRouter } from './api/landing-page-data/landing-page-data'; +import { themesRouter } from './api/themes/themes'; import { waitlistRouter } from './api/waitlist/waitlist'; import { usersRouter } from './api/users/users-router'; import { apiKeysRouter } from './api/api-keys/router'; @@ -198,6 +199,7 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' })); app.route('/api/auth', authRouter); app.route('/api/landing-page-data', landingPageDataRouter); + app.route('/api/themes', themesRouter); app.route('/api/waitlist', waitlistRouter); // Vaultwarden reverse-proxy — mounted TOP-LEVEL (not under protectedRouter): the Bitwarden client // carries its own bearer token, not a platform session JWT, so userMiddleware would 401 it. The diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 9d2598d7..01a19464 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -123,6 +123,17 @@ const CORE_REGISTRY: Permission[] = [ api: ['/user', '/dock', '/api-keys'], routes: ['/settings/profile'], }, + { + key: 'themes', + label: 'Themes', + description: 'Read the themes installed on this machine', + // `core`, and not deniable. Themes are how the interface looks; an account that could not read the + // list would render unstyled, which is not a permission decision anyone means to make. It is read-only + // — installing a theme is a directory on disk, not a request. + kind: 'core', + api: ['/themes'], + routes: [], + }, { key: 'bug-report', label: 'Report a problem', diff --git a/src/servers/themes/discover.ts b/src/servers/themes/discover.ts new file mode 100644 index 00000000..abf52929 --- /dev/null +++ b/src/servers/themes/discover.ts @@ -0,0 +1,128 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { PLATFORM_DIR } from '../data-path'; + +/** + * Themes installed on this machine, as opposed to the ones the platform ships. + * + * ── Why this is simpler than plugin discovery ── + * + * A plugin is code: it has a manifest module, routers, tables, a sidecar, panels, and the platform has to + * import and mount it. A theme is a block of CSS custom properties. Nothing to import, nothing to mount, + * nothing to bundle — which is why an installed theme takes effect without a frontend rebuild, and why + * this file is eighty lines rather than four hundred. + * + * Discovery is by CONVENTION, the same as plugins. Presence is the declaration: + * + * themes//theme.css required — `[data-theme=''] { … }` + * themes//theme.json required — the name a person sees, and how to group it + * themes//assets/ optional — a wallpaper, a logo; served at /themes//assets/… + * + * The id is the DIRECTORY NAME and is not declared in the manifest, for the reason `appName` is not + * declared in a plugin's: an id that can disagree with where the files sit eventually will. + * + * ── What this does not do ── + * + * It answers "what is on disk". It does not read the database, validate colour values, or care whether a + * theme is any good. A theme whose CSS is nonsense will simply look wrong, which is the author's business + * and is visible immediately. + */ + +/** Where installed themes live. Sibling of `plugins/`, and gitignored the same way. */ +export const THEMES_DIR = join(PLATFORM_DIR, 'themes'); + +export type InstalledTheme = { + /** The directory name. Also the `data-theme` value its CSS must target. */ + id: string; + name: string; + family: string; + /** + * Whether to put `.dark` on the document alongside `data-theme`. + * + * A compatibility flag, not a description: several hundred `dark:` utilities are still written into + * components and resolve against that class. A dark theme wants it. + */ + dark: boolean; +}; + +export type ThemeProblem = { id: string; problem: string }; + +const isDir = (path: string): boolean => { + try { + return statSync(path).isDirectory(); + } catch { + return false; + } +}; + +/** + * Read one directory as a theme, or say why it is not one. + * + * Returns problems rather than throwing, and never stops the scan: one malformed theme must not hide the + * eleven good ones next to it. The problems are reported so the app store can render them, which is the + * same posture `discoverPlugins` takes. + */ +function readTheme(id: string): InstalledTheme | ThemeProblem { + const dir = join(THEMES_DIR, id); + const cssPath = join(dir, 'theme.css'); + const manifestPath = join(dir, 'theme.json'); + + if (!existsSync(cssPath)) return { id, problem: 'no theme.css' }; + if (!existsSync(manifestPath)) return { id, problem: 'no theme.json' }; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(manifestPath, 'utf-8')); + } catch (err) { + return { id, problem: `theme.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }; + } + + const manifest = parsed as { name?: unknown; family?: unknown; dark?: unknown }; + if (typeof manifest.name !== 'string' || !manifest.name.trim()) return { id, problem: 'theme.json needs a "name"' }; + + /* + The CSS must target its own directory name. + + Checked because the failure is otherwise silent and baffling: the theme appears in the picker, is + selected, and nothing changes — because its rules are scoped to a `data-theme` value nobody sets. + */ + const css = readFileSync(cssPath, 'utf-8'); + if (!css.includes(`[data-theme='${id}']`) && !css.includes(`[data-theme="${id}"]`)) { + return { id, problem: `theme.css does not target [data-theme='${id}'] — it must match the directory name` }; + } + + return { + id, + name: manifest.name, + family: typeof manifest.family === 'string' && manifest.family.trim() ? manifest.family : manifest.name, + dark: manifest.dark === true, + }; +} + +export function discoverThemes(): { themes: InstalledTheme[]; problems: ThemeProblem[] } { + if (!existsSync(THEMES_DIR)) return { themes: [], problems: [] }; + + const themes: InstalledTheme[] = []; + const problems: ThemeProblem[] = []; + + for (const id of readdirSync(THEMES_DIR).sort()) { + if (id.startsWith('.') || !isDir(join(THEMES_DIR, id))) continue; + const result = readTheme(id); + if ('problem' in result) problems.push(result); + else themes.push(result); + } + + return { themes, problems }; +} + +/** The CSS of one installed theme, or null. `id` is validated, so it cannot climb out of the directory. */ +export function readThemeCss(id: string): string | null { + if (!/^[a-z0-9][a-z0-9-]*$/i.test(id)) return null; + const path = join(THEMES_DIR, id, 'theme.css'); + if (!existsSync(path)) return null; + try { + return readFileSync(path, 'utf-8'); + } catch { + return null; + } +} diff --git a/src/workspaces/theme/src/ThemeProvider.tsx b/src/workspaces/theme/src/ThemeProvider.tsx index df144bfc..4ccb8612 100644 --- a/src/workspaces/theme/src/ThemeProvider.tsx +++ b/src/workspaces/theme/src/ThemeProvider.tsx @@ -60,10 +60,30 @@ function initialTheme(): ThemeId { return DEFAULT_THEME; } -export const ThemeProvider = ({ children }: { children: ReactNode }) => { +export type ThemeProviderProps = { + children: ReactNode; + /** + * Themes installed on the machine, discovered at runtime and merged with the ones this build ships. + * + * The package does no IO — the host fetches them and injects their stylesheets, exactly as it supplies + * the panel registry to `workspaces`. All this needs is their names, so it can list them and so + * `isKnownTheme` stops rejecting a perfectly real theme it has never heard of. + */ + extraThemes?: readonly Theme[]; +}; + +export const ThemeProvider = ({ children, extraThemes }: ThemeProviderProps) => { const [themeId, setThemeId] = useState(initialTheme); - const theme = useMemo(() => findTheme(themeId) ?? findTheme(DEFAULT_THEME)!, [themeId]); + // Shipped first, installed second: a local theme may deliberately replace one of ours by using its id. + const all = useMemo(() => { + if (!extraThemes?.length) return THEMES; + const byId = new Map(THEMES.map((t) => [t.id, t])); + for (const t of extraThemes) byId.set(t.id, t); + return [...byId.values()]; + }, [extraThemes]); + + const theme = useMemo(() => all.find((t) => t.id === themeId) ?? findTheme(DEFAULT_THEME)!, [all, themeId]); useEffect(() => { const root = document.documentElement; @@ -74,19 +94,23 @@ export const ThemeProvider = ({ children }: { children: ReactNode }) => { root.style.colorScheme = theme.applyDarkClass ? 'dark' : 'light'; }, [theme]); - const setTheme = useCallback((id: ThemeId) => { - if (!isKnownTheme(id)) return; - setThemeId(id); - try { - window.localStorage.setItem(STORAGE_KEY, id); - } catch { - // Not fatal: the theme still applies for this session, it just will not survive a reload. - } - }, []); + const setTheme = useCallback( + (id: ThemeId) => { + // Against the MERGED list, not the shipped one: an installed theme is as real as a built-in. + if (!all.some((t) => t.id === id)) return; + setThemeId(id); + try { + window.localStorage.setItem(STORAGE_KEY, id); + } catch { + // Not fatal: the theme still applies for this session, it just will not survive a reload. + } + }, + [all], + ); const value = useMemo( - () => ({ theme, themeId: theme.id, setTheme, themes: THEMES }), - [theme, setTheme], + () => ({ theme, themeId: theme.id, setTheme, themes: all }), + [theme, setTheme, all], ); return {children}; diff --git a/themes/CLAUDE.md b/themes/CLAUDE.md new file mode 100644 index 00000000..60c03bd0 --- /dev/null +++ b/themes/CLAUDE.md @@ -0,0 +1,118 @@ +# Themes + +Drop a theme in here and it appears in the picker on the next page load. No install step, no rebuild, no +restart — a theme is a block of CSS custom properties, and none of that needs compiling. + +**This directory is untracked**, exactly like `plugins/`. Only this file is in the repository. Your themes +are yours; the platform's own live in `src/workspaces/theme/css/themes/`. + +--- + +## The shape + +``` +themes/ + my-theme/ + theme.css required — the values + theme.json required — the name a person reads + assets/ optional — a wallpaper, a logo +``` + +The directory name **is** the theme's id. It is not repeated in the manifest, because an id that can +disagree with where its files sit eventually will. + +### `theme.json` + +```json +{ + "name": "My Theme", + "family": "My Theme", + "dark": true +} +``` + +| | | +|---|---| +| `name` | required — what the picker shows | +| `family` | optional — groups variants together, like `Duck Light` and `Duck Dark`. Defaults to `name` | +| `dark` | optional — adds the `.dark` class. Set it for a dark theme; several hundred `dark:` utilities still resolve against it | + +### `theme.css` + +Must target its own directory name, or nothing will happen and it will be baffling: + +```css +[data-theme='my-theme'] { + --background: #1e1e2e; + --foreground: #cdd6f4; + /* …the rest */ +} +``` + +The server checks this on discovery and reports it rather than letting you wonder. + +--- + +## What to put in it + +**Twenty tokens are required.** They are the standard shadcn set, which is what every published palette +already ships — Tokyo Night, Catppuccin, Gruvbox, Nord all come in this vocabulary, so porting one is +mostly copy-paste. + +``` +background foreground · card card-foreground · popover popover-foreground +primary primary-foreground · secondary secondary-foreground +muted muted-foreground · accent accent-foreground +destructive destructive-foreground · border input ring · radius +``` + +**Everything else is derived from those.** Panels, the header, the dock, glass, grid lines, scrollbars — +all computed from `card`, `border`, `background` and `foreground`. A palette theme never mentions them. + +**Four more are worth setting**, because the standard set has no notion of them and the defaults are +generic: `--success`, `--warning`, `--info`, `--busy`, each with a `-foreground`. `busy` means *running*, +not *warning* — it is what a live job wears. + +**Anything else you want to change, you can.** Declaring a variable in your theme beats the derived +default, because both land on `` and yours is more specific. That is how a theme gets a *texture* +rather than just a palette: + +```css +[data-theme='my-theme'] { + --panel-bg: rgba(255, 255, 255, 0.08); /* glass instead of a flat card */ + --panel-blur: 20px; + --panel-radius: 0; /* square panels */ + --page-bg-src: '/themes/my-theme/assets/wallpaper.webp'; + --logo-src: '/themes/my-theme/assets/logo.svg'; +} +``` + +The full list of what you can override is in `src/workspaces/theme/README.md`, and +`src/workspaces/theme/css/themes/duck-dark.css` is a worked example of a textured theme. + +**Assets are quoted paths, not `url(...)`.** The bundler resolves `url()` at build time and your files are +served at runtime, so a `url()` token would break. The app wraps the path for you. + +--- + +## Writing one + +Start from `src/workspaces/theme/css/themes/plain.css` rather than from a Duck theme. Plain is white, +black and grey throughout, so every value you change is one you decided about, with nothing inherited from +someone else's brand. + +Then: reload the page, open the picker or press `ctrl+shift+space`, and use `j`/`k` to browse. `l` applies +the focused theme to the app behind the list, so you can watch your own theme against real screens. + +Editing `theme.css` and reloading is the whole loop. The stylesheet is cached for a minute. + +--- + +## Checking your work + +Render the app under **Plain** first if something looks wrong under yours. Anything still coloured under +Plain is a component painting itself rather than reading a token — that is a platform bug, not yours, and +worth reporting. + +`bun run check:theme` counts hardcoded colours in the platform. It does not check themes, but it is why +your theme has a chance of working: the number can only go down.