From da395cc0bf5800f38a5d675d5774f39a9d58eac8 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 10:54:04 +0000 Subject: [PATCH 1/2] An explicit theme choice is distinguishable and wins over every sky state settings.themeChosen is set by a pick in the theme menu, so a picked Midnight Sky no longer reads as the default. sceneThemeId lets a chosen theme win over a fixed sky state as well as over auto. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/scene/skyInk.test.ts | 8 +++-- src/app/features/scene/skyTheme.test.ts | 18 +++++++---- src/app/features/scene/skyTheme.ts | 7 +++-- src/app/features/settings/general/General.tsx | 2 ++ src/app/hooks/useTheme.test.ts | 31 +++++++++++++++++++ src/app/hooks/useTheme.ts | 14 ++++++--- src/app/state/settings.ts | 2 ++ 7 files changed, 66 insertions(+), 16 deletions(-) create mode 100644 src/app/hooks/useTheme.test.ts diff --git a/src/app/features/scene/skyInk.test.ts b/src/app/features/scene/skyInk.test.ts index 3a583090..888744b3 100644 --- a/src/app/features/scene/skyInk.test.ts +++ b/src/app/features/scene/skyInk.test.ts @@ -146,8 +146,12 @@ describe('sceneInk: an explicit theme wins over the auto ink', () => { }); // auto, but the person chose a theme: it wins, the theme's own ink stands. expect(sceneInk(sceneThemeId('auto', 'dusk', true, true), true, 'dusk', true)).toBeUndefined(); - // a fixed sky state is itself a choice and wins over the theme (skyTheme.ts), so its ink applies. - expect(sceneInk(sceneThemeId('night', 'night', true, true), true, 'night', false)).toEqual({ + // a fixed sky state does not override the person's theme either: the theme's own ink stands. + expect( + sceneInk(sceneThemeId('night', 'night', true, true), true, 'night', false) + ).toBeUndefined(); + // a fixed sky state with no theme of the person's own: the sky theme's ink applies. + expect(sceneInk(sceneThemeId('night', 'night', true, false), true, 'night', false)).toEqual({ state: 'night', phone: false, }); diff --git a/src/app/features/scene/skyTheme.test.ts b/src/app/features/scene/skyTheme.test.ts index c7e460f3..aab972fb 100644 --- a/src/app/features/scene/skyTheme.test.ts +++ b/src/app/features/scene/skyTheme.test.ts @@ -53,12 +53,18 @@ describe('skyThemeId', () => { }); describe('sceneThemeId, the theme the page wears', () => { - it('a fixed state always picks its theme, whatever the scene or the person chose', () => { - [true, false].forEach((scene) => - [true, false].forEach((chose) => { - expect(sceneThemeId('day', 'night', scene, chose)).toBe('noonday-sky'); - expect(sceneThemeId('night', 'day', scene, chose)).toBe('midnight-sky'); - }) + it('a fixed state picks its theme, whatever the scene, while the person chose no theme', () => { + [true, false].forEach((scene) => { + expect(sceneThemeId('day', 'night', scene, false)).toBe('noonday-sky'); + expect(sceneThemeId('night', 'day', scene, false)).toBe('midnight-sky'); + }); + }); + + it('a theme the person chose wins over every sky state, fixed or auto', () => { + (['auto', 'day', 'dusk', 'night'] as const).forEach((state) => + [true, false].forEach((scene) => + expect(sceneThemeId(state, 'night', scene, true)).toBeUndefined() + ) ); }); diff --git a/src/app/features/scene/skyTheme.ts b/src/app/features/scene/skyTheme.ts index 5dff3e06..e1934d81 100644 --- a/src/app/features/scene/skyTheme.ts +++ b/src/app/features/scene/skyTheme.ts @@ -24,8 +24,8 @@ export const skyThemeId = (state: SkyState): SkyThemeId | undefined => state === 'auto' ? undefined : BY_STATE[state]; /** - * The theme the page wears: a fixed sky state always picks its theme; 'auto' follows the resolved sky (`resolved`, the - * clock's state) only while a scene is on and the person has not chosen a theme of their own, and otherwise picks none. + * The theme the page wears: a theme the person chose always wins, and picks none here. Otherwise a fixed sky state + * picks its theme, and 'auto' follows the resolved sky (`resolved`, the clock's state) only while a scene is on. * Signed-out pages choose no theme, so they pass `userChoseTheme: false`. */ export const sceneThemeId = ( @@ -34,6 +34,7 @@ export const sceneThemeId = ( sceneOn: boolean, userChoseTheme: boolean ): SkyThemeId | undefined => { + if (userChoseTheme) return undefined; if (state !== 'auto') return BY_STATE[state]; - return sceneOn && !userChoseTheme ? BY_STATE[resolved] : undefined; + return sceneOn ? BY_STATE[resolved] : undefined; }; diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 35184656..49b09a60 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -81,6 +81,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) { const themes = useThemes(); const themeNames = useThemeNames(); const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId'); + const [, setThemeChosen] = useSetting(settingsAtom, 'themeChosen'); const [menuCords, setMenuCords] = useState(); const selectedTheme = themes.find((theme) => theme.id === themeId) ?? LightTheme; @@ -90,6 +91,7 @@ function SelectTheme({ disabled }: { disabled?: boolean }) { const handleThemeSelect = (theme: Theme) => { setThemeId(theme.id); + setThemeChosen(true); setMenuCords(undefined); }; diff --git a/src/app/hooks/useTheme.test.ts b/src/app/hooks/useTheme.test.ts new file mode 100644 index 00000000..54f6ad4b --- /dev/null +++ b/src/app/hooks/useTheme.test.ts @@ -0,0 +1,31 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +// settings.ts reads localStorage when it loads, and this suite runs without a DOM. +vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined }); +let chosenTheme: typeof import('./useTheme').chosenTheme; +let defaultThemeId: string; +beforeAll(async () => { + ({ chosenTheme } = await import('./useTheme')); + ({ defaultThemeId } = await import('../state/settings')); +}); + +describe('chosenTheme: what counts as a theme the person chose', () => { + it('is not a theme before anything was picked: no id saved, or the default saved', () => { + expect(chosenTheme(false, undefined, undefined)).toBe(false); + expect(chosenTheme(false, defaultThemeId, undefined)).toBe(false); + expect(chosenTheme(false, defaultThemeId, false)).toBe(false); + }); + + it('is a pick of Midnight Sky made in the theme menu, which sets the flag', () => { + expect(chosenTheme(false, defaultThemeId, true)).toBe(true); + }); + + it('is a saved id other than the default, with or without the flag', () => { + expect(chosenTheme(false, 'dark-theme', undefined)).toBe(true); + expect(chosenTheme(false, 'noonday-sky', true)).toBe(true); + }); + + it('is the system scheme', () => { + expect(chosenTheme(true, defaultThemeId, undefined)).toBe(true); + }); +}); diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts index 1ef0278d..730b12d4 100644 --- a/src/app/hooks/useTheme.ts +++ b/src/app/hooks/useTheme.ts @@ -107,9 +107,12 @@ export const useSystemThemeKind = (): ThemeKind => { const toTheme = (id: ReturnType): Theme | undefined => id === NoondaySkyTheme.id ? NoondaySkyTheme : id && MidnightSkyTheme; -/** A theme the person chose: the system scheme, or one other than the default. A saved Midnight Sky is the default. */ -const chosenTheme = (useSystemTheme: boolean, themeId?: string) => - useSystemTheme || (themeId !== undefined && themeId !== defaultThemeId); +/** + * A theme the person chose: the system scheme, any pick made in the theme menu (`themeChosen`, which also covers + * Midnight Sky, the default), or a saved id other than the default (picks from before the flag existed). + */ +export const chosenTheme = (useSystemTheme: boolean, themeId?: string, themeChosen?: boolean) => + useSystemTheme || themeChosen === true || (themeId !== undefined && themeId !== defaultThemeId); /** The theme the sky asks for on a page with no theme of its own (signed out), or undefined for none. */ export const useSkyTheme = (): Theme | undefined => @@ -122,8 +125,9 @@ const useSceneSkyThemeId = (): ReturnType => { const sceneOn = useSceneOn(); const [systemTheme] = useSetting(settingsAtom, 'useSystemTheme'); const [themeId] = useSetting(settingsAtom, 'themeId'); - // A person's own theme wins over 'auto'; a fixed sky state is itself a choice and wins over the theme. - return sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId)); + const [themeChosen] = useSetting(settingsAtom, 'themeChosen'); + // A person's own theme wins over the sky, fixed or 'auto'. + return sceneThemeId(sky, resolved, sceneOn, chosenTheme(systemTheme, themeId, themeChosen)); }; /** The ink the page wears over the scene (`sceneInk`, skyInk.ts), or undefined when the theme's own ink stands. */ diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b5165929..21c449af 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -18,6 +18,8 @@ export enum MessageLayout { export interface Settings { themeId?: string; + /** Set when a theme is picked in the menu, so a picked default (Midnight Sky) reads as chosen. Absent until then. */ + themeChosen?: boolean; useSystemTheme: boolean; lightThemeId?: string; darkThemeId?: string; -- 2.47.3 From 16ccd2132e5fb54438d8af6da9aad16a05021d53 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 10:56:59 +0000 Subject: [PATCH 2/2] Pin the theme menu's themeChosen wiring Co-Authored-By: Claude Sonnet 5.5 --- src/app/hooks/useTheme.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/hooks/useTheme.test.ts b/src/app/hooks/useTheme.test.ts index 54f6ad4b..dcb96e49 100644 --- a/src/app/hooks/useTheme.test.ts +++ b/src/app/hooks/useTheme.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'fs'; import { beforeAll, describe, expect, it, vi } from 'vitest'; // settings.ts reads localStorage when it loads, and this suite runs without a DOM. @@ -28,4 +29,12 @@ describe('chosenTheme: what counts as a theme the person chose', () => { it('is the system scheme', () => { expect(chosenTheme(true, defaultThemeId, undefined)).toBe(true); }); + + it('the theme menu sets the flag together with the id', () => { + const tsx = readFileSync( + `${process.cwd()}/src/app/features/settings/general/General.tsx`, + 'utf8' + ); + expect(tsx).toMatch(/setThemeId\(theme\.id\);\s*setThemeChosen\(true\);/); + }); }); -- 2.47.3