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:
@@ -38,6 +38,7 @@ export * from './chat-notification-prefs';
|
||||
export * from './nudges';
|
||||
export * from './ai-provider-usage';
|
||||
export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
@@ -35,6 +35,7 @@ export * from './chat-session-models/schema'; // chat_session_models
|
||||
export * from './chat-notification-prefs/schema'; // chat_notification_prefs
|
||||
export * from './ai-provider-usage/schema'; // ai_provider_usage — last usage reading per AI account
|
||||
export * from './anthropic-accounts/schema'; // anthropic_accounts — switchable OAuth credentials
|
||||
export * from './theme-favorites/schema'; // theme_favorites — starred themes, per account
|
||||
export * from './command-hq/schema'; // command_hq_categories, command_hq_sessions, command_hq_tags, command_hq_session_tags
|
||||
export * from './agent-panels/schema'; // agent_panels
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,28 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { themeFavorites } from './schema';
|
||||
|
||||
/** The theme ids this account has starred. Order is not meaningful — the caller owns the ordering. */
|
||||
export async function listThemeFavorites(userId: number): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ themeId: themeFavorites.themeId })
|
||||
.from(themeFavorites)
|
||||
.where(eq(themeFavorites.userId, userId));
|
||||
return rows.map((r) => r.themeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Star or unstar, returning the state it ended in.
|
||||
*
|
||||
* Idempotent in both directions on purpose: a double-tap, or two tabs racing, must not end with the star
|
||||
* in a state neither of them asked for. `onConflictDoNothing` makes the insert safe to repeat, and the
|
||||
* delete is naturally so.
|
||||
*/
|
||||
export async function setThemeFavorite(userId: number, themeId: string, favorite: boolean): Promise<boolean> {
|
||||
if (favorite) {
|
||||
await db.insert(themeFavorites).values({ userId, themeId }).onConflictDoNothing();
|
||||
return true;
|
||||
}
|
||||
await db.delete(themeFavorites).where(and(eq(themeFavorites.userId, userId), eq(themeFavorites.themeId, themeId)));
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { integer, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
/**
|
||||
* Which themes an account has starred.
|
||||
*
|
||||
* ── Why a table, when a preference could be one JSON column ──
|
||||
*
|
||||
* Because it is per account and unbounded. `user_settings` would hold an array that grows with taste and
|
||||
* has to be read-modify-written by whoever toggles a star, and two tabs doing that at once lose one of the
|
||||
* stars. A row per favourite makes a toggle an INSERT or a DELETE, which cannot race with itself.
|
||||
*
|
||||
* ── Why the theme id is text with no foreign key ──
|
||||
*
|
||||
* Themes are not database rows. Most are compiled into the bundle; the rest are directories under
|
||||
* `platform/themes/` discovered at request time, so a theme can appear or vanish without anything writing
|
||||
* SQL. A foreign key would either be unenforceable or would make installing a theme a migration.
|
||||
*
|
||||
* The consequence is deliberate: a star can outlive its theme. That is why the reader intersects with the
|
||||
* live registry rather than trusting the table — a favourite naming a theme nobody has installed is
|
||||
* ignored, not shown as a broken entry, and it costs one row until the account unstars something.
|
||||
*/
|
||||
export const themeFavorites = pgTable(
|
||||
'theme_favorites',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** The theme's registry id — `duck-dark`, `catppuccin`. Not a foreign key; see above. */
|
||||
themeId: text('theme_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
// uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push.
|
||||
// See src/databases/CLAUDE.md → "Composite keys".
|
||||
(table) => [uniqueIndex('uq_theme_favorites_user_theme').on(table.userId, table.themeId)],
|
||||
);
|
||||
Reference in New Issue
Block a user