Files
platform/src/servers/api/themes/themes.ts
T
pastilhasandClaude Opus 5 bd15a186b5 themes: rotate to the one you are wearing, star the ones you like
Four changes for 200+ themes, and one mistake worth recording.

ORDERING IS A ROTATION, not a sort. The registry order is the list's only structure — families sit together
in it — so lifting the selected theme to the top answers "which am I on" by destroying "what is next to it".
Rotating keeps every neighbour and moves the starting point. Both lists use the same `ordered` array, which
is the point: "the one below the one I have on" has to mean the same thing in the dropdown and in the
ctrl+shift+space browser, or one teaches an order the other contradicts.

FAVOURITES are a table, not a JSON column. theme_favorites is a row per star, so a toggle is an INSERT or a
DELETE and cannot race with itself; an array in user_settings would be read-modify-written and two tabs would
lose one. No foreign key to a theme, because themes are not rows — most are compiled in, the rest are
directories discovered at request time — so a star can outlive its theme and the reader intersects with the
live registry rather than trusting the table.

Favourites-first is a second rotation-preserving pass rather than a filter, so the list can still answer
"what else is there". Filtering to favourites only is a separate, explicit toggle.

THE INSTRUCTIONS NO LONGER SCROLL AWAY. DropdownMenuContent carried overflow-y-auto itself, so the one line
telling you the shortcuts exist was visible for half a second and then gone under two hundred entries —
which is the same as not having written it. Header and footer are now siblings of a scroll box instead of
passengers in it, and the header names alt+space too.

THE MISTAKE: the routes went on /themes first, and that prefix is deliberately UNAUTHENTICATED — the
sign-in screen paints in the chosen theme, before any account exists to ask. An anonymous GET answered 500,
which was the consequence arriving politely. They now live on /theme-favorites, claimed by the `account`
permission, which is core and already owns /user, /dock and /api-keys: a starred theme is the same shape as
a dock preference. I then mounted it one line off, in the unprotected block beside the thing I was moving it
away from, and caught that by reading rather than by testing.

Verified after: /api/theme-favorites is 401 unauthenticated on both verbs, and /api/themes still answers 200
so the login screen keeps its colours.

10 tests on the pure ordering — including the property that makes a rotation a rotation: every adjacency in
the original survives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 04:51:53 +00:00

48 lines
2.4 KiB
TypeScript

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.
*
* **This prefix is UNAUTHENTICATED** (`UNPROTECTED_API_PREFIXES` in hono.ts) and must stay that way: the
* sign-in screen paints in the chosen theme, before any account exists to ask. So nothing per-account can
* live here — theme FAVOURITES are on `/theme-favorites`, behind the `account` permission, for exactly that
* reason. Adding them here returned 500 on an anonymous request, which was the polite version of the
* problem.
*
* 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 <link>.
//
// 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',
});
});