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:
@@ -9,3 +9,5 @@ export { useThemeAsset } from './useThemeAsset';
|
||||
export { useThemeKeys } from './useThemeKeys';
|
||||
export { THEMES, DEFAULT_THEME, findTheme, isKnownTheme, isDarkTheme, themeFromLegacyColorMode } from './themes';
|
||||
export type { Theme, ThemeId } from './themes';
|
||||
// Ordering helpers for the pickers — pure, and tested in rotate.test.ts.
|
||||
export { rotateToSelected, favouritesFirst, groupByFamily } from './rotate';
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { favouritesFirst, groupByFamily, rotateToSelected } from './rotate';
|
||||
|
||||
const ids = (list: Array<{ id: string }>) => list.map((t) => t.id);
|
||||
const list = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }];
|
||||
|
||||
describe('rotating to the selected theme', () => {
|
||||
test('puts the selected one first and wraps the rest behind it', () => {
|
||||
expect(ids(rotateToSelected(list, 'c'))).toEqual(['c', 'd', 'a', 'b']);
|
||||
});
|
||||
|
||||
// The property that makes this a rotation rather than a sort: nobody loses a neighbour.
|
||||
test('every adjacency survives, only the starting point moves', () => {
|
||||
const rotated = ids(rotateToSelected(list, 'c'));
|
||||
const doubled = [...ids(list), ...ids(list)].join(',');
|
||||
expect(doubled).toContain(rotated.join(','));
|
||||
});
|
||||
|
||||
test('leaves the order alone when the selected one is already first', () => {
|
||||
expect(ids(rotateToSelected(list, 'a'))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
// An id from an uninstalled theme, or none chosen yet. Rotating by a guess would be worse than not.
|
||||
test('leaves the order alone for an unknown or missing id', () => {
|
||||
expect(ids(rotateToSelected(list, 'gone'))).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(ids(rotateToSelected(list, undefined))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
test('does not mutate the input', () => {
|
||||
const original = [...ids(list)];
|
||||
rotateToSelected(list, 'd');
|
||||
expect(ids(list)).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('favourites first', () => {
|
||||
test('lifts favourites, keeping the order within each half', () => {
|
||||
expect(ids(favouritesFirst(list, new Set(['d', 'b'])))).toEqual(['b', 'd', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('changes nothing when there are none', () => {
|
||||
expect(ids(favouritesFirst(list, new Set()))).toEqual(['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
// Composed with the rotation: the theme you are wearing leads its half.
|
||||
test('composes with the rotation', () => {
|
||||
expect(ids(favouritesFirst(rotateToSelected(list, 'c'), new Set(['c', 'a'])))).toEqual(['c', 'a', 'd', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grouping by family', () => {
|
||||
const themed = [
|
||||
{ id: 'duck-dark', name: 'Duck Dark', family: 'Duck' },
|
||||
{ id: 'cat', name: 'Catppuccin', family: 'Catppuccin' },
|
||||
{ id: 'duck-light', name: 'Duck Light', family: 'Duck' },
|
||||
] as never as Parameters<typeof groupByFamily>[0];
|
||||
|
||||
test('keeps families in first-seen order, so a rotation decides them', () => {
|
||||
expect(groupByFamily(themed).map(([family]) => family)).toEqual(['Duck', 'Catppuccin']);
|
||||
});
|
||||
|
||||
// Members of a family separated by the rotation still collect together — the alternative is the same
|
||||
// family heading appearing twice.
|
||||
test('collects a family split by the rotation', () => {
|
||||
const groups = groupByFamily(themed);
|
||||
expect(groups[0]![1].map((t) => t.id)).toEqual(['duck-dark', 'duck-light']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Theme, ThemeId } from './themes';
|
||||
|
||||
/**
|
||||
* The theme list, rotated so the one you are wearing is first.
|
||||
*
|
||||
* With 200+ themes the list is far taller than any container, and the theme you have on is the anchor for
|
||||
* every decision you are about to make — "what did I just come from", "what is next to this". Sorting the
|
||||
* selected one to the top would answer the first question and destroy the second, because it takes that
|
||||
* theme out of its family and leaves a hole where it was.
|
||||
*
|
||||
* A ROTATION keeps every neighbour: the order is unchanged, the starting point moves. Duck Dark's
|
||||
* neighbours are still Duck Light and whatever followed it; they have simply wrapped around behind you.
|
||||
*
|
||||
* The registry order is the ordering to preserve — families sit together in it, and that grouping is the
|
||||
* only structure a list of two hundred names has.
|
||||
*/
|
||||
export function rotateToSelected<T extends { id: ThemeId }>(list: readonly T[], selected: ThemeId | undefined): T[] {
|
||||
if (!selected) return [...list];
|
||||
const at = list.findIndex((entry) => entry.id === selected);
|
||||
// Not present: an id from a theme that has been uninstalled, or none set yet. Leave the order alone
|
||||
// rather than rotate by a guess.
|
||||
if (at <= 0) return [...list];
|
||||
return [...list.slice(at), ...list.slice(0, at)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Favourites first, then everything else — each half keeping the rotation it was given.
|
||||
*
|
||||
* Two-tier rather than a filter, because hiding the rest makes the list unable to answer "what else is
|
||||
* there". A filter is a separate, explicit act; this is the default shape.
|
||||
*/
|
||||
export function favouritesFirst<T extends { id: ThemeId }>(list: readonly T[], favourites: ReadonlySet<ThemeId>): T[] {
|
||||
if (favourites.size === 0) return [...list];
|
||||
const starred: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const entry of list) (favourites.has(entry.id) ? starred : rest).push(entry);
|
||||
return [...starred, ...rest];
|
||||
}
|
||||
|
||||
/** Grouped by family for the dropdown, over an already-ordered list. Families appear in first-seen order. */
|
||||
export function groupByFamily(list: readonly Theme[]): Array<[string, Theme[]]> {
|
||||
const groups = new Map<string, Theme[]>();
|
||||
for (const theme of list) {
|
||||
const existing = groups.get(theme.family);
|
||||
if (existing) existing.push(theme);
|
||||
else groups.set(theme.family, [theme]);
|
||||
}
|
||||
return [...groups.entries()];
|
||||
}
|
||||
Reference in New Issue
Block a user