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>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import type { Context } from 'hono';
|
||||
import { listThemeFavorites, setThemeFavorite } from 'officerdb';
|
||||
import { createRouter } from '../../create-router';
|
||||
|
||||
/*
|
||||
Starred themes, per account.
|
||||
|
||||
── Why this is not on `/themes` ──
|
||||
|
||||
Because that prefix is deliberately UNAUTHENTICATED: the sign-in screen renders in the chosen theme, so
|
||||
the list and the stylesheets have to be readable before anyone has an account. A per-account preference on
|
||||
an anonymous prefix has no caller to attribute it to — the first version of this lived there and answered
|
||||
500 to an anonymous GET, which was the honest consequence arriving early.
|
||||
|
||||
── Why the `account` permission ──
|
||||
|
||||
It is `core`, so every account holds it and it cannot be denied, and it already owns `/user`, `/dock` and
|
||||
`/api-keys` — preferences and credentials belonging to the caller themselves. A theme favourite is the
|
||||
same shape as a dock preference: it grants nothing, it is about this account's own eyes, and denying it
|
||||
would only mean an account that can choose a theme cannot remember which ones it liked.
|
||||
|
||||
Every route here reads `user.id` from the token. None of them names an account, so there is nothing to get
|
||||
wrong about whose stars these are.
|
||||
*/
|
||||
export const themeFavoritesRouter = createRouter();
|
||||
|
||||
// GET /theme-favorites — this account's starred theme ids.
|
||||
themeFavoritesRouter.get('/', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
return ctx.json({ favorites: await listThemeFavorites(user.id) });
|
||||
});
|
||||
|
||||
// PUT /theme-favorites/:id — star or unstar one.
|
||||
//
|
||||
// PUT rather than POST because it SETS a state rather than appending: sending `{favorite:true}` twice ends
|
||||
// where sending it once does, which is what a double-tap and two racing tabs both need. The store is
|
||||
// idempotent in both directions for the same reason.
|
||||
themeFavoritesRouter.put('/:id', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
const themeId = ctx.req.param('id');
|
||||
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
|
||||
const favorite = body.favorite !== false;
|
||||
return ctx.json({ themeId, favorite: await setThemeFavorite(user.id, themeId, favorite) });
|
||||
});
|
||||
@@ -5,6 +5,12 @@ 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.
|
||||
|
||||
Reference in New Issue
Block a user