From 1f06b25deef735e22bf41d596b2d9091097105d4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Tue, 19 May 2026 22:29:09 +0200 Subject: [PATCH] feat: add automatic Pi Web theme pairs --- .changeset/auto-pi-web-themes.md | 5 + .changeset/isolate-plugin-registration.md | 5 + src/client/src/components/PiWebApp.ts | 111 +++++++++++++++---- src/client/src/plugins/registry.test.ts | 15 ++- src/client/src/plugins/registry.ts | 25 ++++- src/client/src/plugins/themes/index.ts | 44 +++++--- src/client/src/plugins/types.ts | 18 ++++ src/client/src/theme.test.ts | 124 ++++++++++++++++++++++ src/client/src/theme.ts | 89 ++++++++++++++-- 9 files changed, 385 insertions(+), 51 deletions(-) create mode 100644 .changeset/auto-pi-web-themes.md create mode 100644 .changeset/isolate-plugin-registration.md create mode 100644 src/client/src/theme.test.ts diff --git a/.changeset/auto-pi-web-themes.md b/.changeset/auto-pi-web-themes.md new file mode 100644 index 0000000..5959098 --- /dev/null +++ b/.changeset/auto-pi-web-themes.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Make the Pi Web light/dark themes the default automatic theme pair and keep Classic as the fallback for missing theme selections. diff --git a/.changeset/isolate-plugin-registration.md b/.changeset/isolate-plugin-registration.md new file mode 100644 index 0000000..7bf6e6d --- /dev/null +++ b/.changeset/isolate-plugin-registration.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep loading other external plugins when one plugin fails during registration. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 0a253f7..592ec4a 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -13,8 +13,8 @@ import { SessionController } from "../controllers/sessionController"; import { WorkspaceController } from "../controllers/workspaceController"; import { KeyboardShortcutDispatcher } from "../keyboardShortcuts"; import { RealtimeSocket } from "../sessionSocket"; -import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, PluginRuntimeContext, WorkspacePanelContext } from "../plugins/types"; -import { DEFAULT_THEME_ID, applyPiWebTheme, readStoredThemeId } from "../theme"; +import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspacePanelContribution, PluginRuntimeContext, WorkspacePanelContext } from "../plugins/types"; +import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, applyPiWebTheme, findThemePairForTheme, readStoredThemePreference, resolveThemePreference, writeStoredThemePreference, type ThemePreference, type ThemePreferenceResolution } from "../theme"; import { corePlugin } from "../plugins/core"; import { themePackPlugin } from "../plugins/themes"; import { loadExternalPlugins } from "../plugins/external"; @@ -39,6 +39,9 @@ import { appStyles } from "./shared"; type NavigationSection = "projects" | "workspaces" | "sessions"; const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000; +const THEME_AUTO_ON_VALUE = "auto:on"; +const THEME_AUTO_OFF_VALUE = "auto:off"; +const THEME_OPTION_PREFIX = "theme:"; @customElement("pi-web-app") export class PiWebApp extends LitElement { @@ -87,6 +90,7 @@ export class PiWebApp extends LitElement { private readonly realtime = new RealtimeSocket(); private readonly activeTerminalIds = new Set(); private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined; + private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined; private observedContextItems: HTMLElement | undefined; private observedMobileTabs: HTMLElement | undefined; private contextItemsResizeObserver: ResizeObserver | undefined; @@ -94,8 +98,8 @@ export class PiWebApp extends LitElement { private terminalAutoStartWorkspaceId: string | undefined; private piWebStatusTimer: number | undefined; private readonly plugins = createPluginRegistry(); - private preferredThemeId: QualifiedContributionId = readStoredThemeId() ?? DEFAULT_THEME_ID; - @state() private activeThemeId: QualifiedContributionId = DEFAULT_THEME_ID; + private themePreference: ThemePreference = readStoredThemePreference() ?? DEFAULT_THEME_PREFERENCE; + @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; @state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined; @state() private contextCanScrollLeft = false; @@ -120,6 +124,9 @@ export class PiWebApp extends LitElement { this.updateContextScrollState(); this.updateMobileTabsScrollState(); }; + private readonly onSystemLightThemeChange = () => { + if (this.themePreference.auto) this.applyPreferredTheme(false); + }; private readonly onContextScroll = () => { this.updateContextScrollState(); }; @@ -140,6 +147,7 @@ export class PiWebApp extends LitElement { document.addEventListener("visibilitychange", this.onVisibilityChange); window.addEventListener("keydown", this.onKeyDown); this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); + this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange); this.applyPreferredTheme(false); this.connectRealtime(); this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS); @@ -155,6 +163,7 @@ export class PiWebApp extends LitElement { document.removeEventListener("visibilitychange", this.onVisibilityChange); window.removeEventListener("keydown", this.onKeyDown); this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange); + this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange); this.keyboard.reset(); this.auth.dispose(); this.sessions.dispose(); @@ -474,7 +483,14 @@ export class PiWebApp extends LitElement { private async loadExternalPlugins(): Promise { try { - for (const registration of await loadExternalPlugins()) this.plugins.register(registration); + const registrations = await loadExternalPlugins(); + for (const registration of registrations) { + try { + this.plugins.register(registration); + } catch (error) { + console.warn(`Failed to register Pi Web plugin ${registration.id}`, error); + } + } this.applyPreferredTheme(false); this.requestUpdate(); } catch (error) { @@ -533,36 +549,91 @@ export class PiWebApp extends LitElement { private openThemeDialog() { const themes = this.plugins.getThemes(); + const resolution = this.resolveCurrentThemePreference(themes); + const selectedThemeId = resolution.selectedTheme?.id; + const autoValue = this.themePreference.auto ? THEME_AUTO_OFF_VALUE : THEME_AUTO_ON_VALUE; this.setState({ themeDialog: { title: "Select Theme", - selectedValue: this.activeThemeId, - options: themes.map((theme) => ({ - value: theme.id, - label: `${theme.name}${theme.id === this.activeThemeId ? " ✓ current" : ""}`, - description: theme.description === undefined ? theme.colorScheme : `${theme.colorScheme} · ${theme.description}`, - })), + selectedValue: selectedThemeId === undefined ? autoValue : `${THEME_OPTION_PREFIX}${selectedThemeId}`, + options: [ + { + value: autoValue, + label: `Auto ${this.themePreference.auto ? "✓ on" : "off"}`, + description: this.autoThemeDescription(resolution), + }, + ...themes.map((theme) => ({ + value: `${THEME_OPTION_PREFIX}${theme.id}`, + label: this.themeOptionLabel(theme, selectedThemeId), + description: this.themeOptionDescription(theme), + })), + ], }, }); } private pickTheme(value: string) { - const theme = this.plugins.getThemes().find((candidate) => candidate.id === value); this.setState({ themeDialog: undefined }); + if (value === THEME_AUTO_ON_VALUE || value === THEME_AUTO_OFF_VALUE) { + const selectedThemeId = this.resolveCurrentThemePreference().selectedTheme?.id; + if (selectedThemeId === undefined) return; + this.themePreference = { themeId: selectedThemeId, auto: value === THEME_AUTO_ON_VALUE }; + this.applyPreferredTheme(true); + return; + } + if (!value.startsWith(THEME_OPTION_PREFIX)) return; + const themeId = value.slice(THEME_OPTION_PREFIX.length); + const theme = this.plugins.getThemes().find((candidate) => candidate.id === themeId); if (theme === undefined) return; - this.preferredThemeId = theme.id; - this.activeThemeId = theme.id; - applyPiWebTheme(theme); + this.themePreference = { themeId: theme.id, auto: this.themePreference.auto }; + this.applyPreferredTheme(true); } private applyPreferredTheme(persist: boolean): void { - const themes = this.plugins.getThemes(); - const theme = themes.find((candidate) => candidate.id === this.preferredThemeId) - ?? themes.find((candidate) => candidate.id === DEFAULT_THEME_ID) - ?? themes[0]; + const theme = this.resolveCurrentThemePreference().activeTheme; if (theme === undefined) return; this.activeThemeId = theme.id; - applyPiWebTheme(theme, { persist }); + applyPiWebTheme(theme); + if (persist) writeStoredThemePreference(this.themePreference); + } + + private resolveCurrentThemePreference(themes = this.plugins.getThemes()): ThemePreferenceResolution { + return resolveThemePreference({ + themes, + themePairs: this.plugins.getThemePairs(), + preference: this.themePreference, + prefersLight: this.systemPrefersLight(), + }); + } + + private themePairForTheme(themeId: QualifiedContributionId): QualifiedThemePairContribution | undefined { + return findThemePairForTheme(this.plugins.getThemePairs(), themeId); + } + + private systemPrefersLight(): boolean { + return this.systemLightThemeMedia?.matches ?? false; + } + + private autoThemeDescription(resolution: ThemePreferenceResolution): string { + if (!this.themePreference.auto) return "Follow the system light/dark preference when the selected theme has a pair."; + if (resolution.selectedTheme === undefined) return "Follow the system light/dark preference when the selected theme has a pair."; + if (resolution.selectedThemePair === undefined) return "On, but the selected theme has no light/dark pair, so it will stay selected."; + return `On · ${resolution.selectedThemePair.name} follows the system ${this.systemPrefersLight() ? "light" : "dark"} preference.`; + } + + private themeOptionLabel(theme: QualifiedThemeContribution, selectedThemeId: QualifiedContributionId | undefined): string { + const markers = [ + ...(theme.id === selectedThemeId ? ["selected"] : []), + ...(theme.id === this.activeThemeId && theme.id !== selectedThemeId ? ["active"] : []), + ]; + return markers.length === 0 ? theme.name : `${theme.name} ✓ ${markers.join(" · ")}`; + } + + private themeOptionDescription(theme: QualifiedThemeContribution): string { + const parts: string[] = [theme.colorScheme]; + if (this.themePairForTheme(theme.id) !== undefined) parts.push("auto pair"); + if (theme.description !== undefined) parts.push(theme.description); + return parts.join(" · "); } private async openThinkingDialog() { diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 2ed1544..f2b6222 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -88,9 +88,12 @@ describe("PluginRegistry", () => { registry.register({ id: "themes", plugin: themePackPlugin }); expect(registry.getThemes().map((theme) => ({ id: theme.id, colorScheme: theme.colorScheme }))).toEqual([ - { id: "themes:current", colorScheme: "dark" }, - { id: "themes:docs-dark", colorScheme: "dark" }, - { id: "themes:docs-light", colorScheme: "light" }, + { id: "themes:pi-web-dark", colorScheme: "dark" }, + { id: "themes:pi-web-light", colorScheme: "light" }, + { id: "themes:classic", colorScheme: "dark" }, + ]); + expect(registry.getThemePairs().map((pair) => ({ id: pair.id, light: pair.light, dark: pair.dark }))).toEqual([ + { id: "themes:pi-web", light: "themes:pi-web-light", dark: "themes:pi-web-dark" }, ]); }); @@ -107,6 +110,9 @@ describe("PluginRegistry", () => { { id: "last", name: "Last", order: 20, colorScheme: "dark", tokens: testThemeTokens() }, { id: "first", name: "First", order: 10, colorScheme: "light", tokens: testThemeTokens() }, ], + themePairs: [ + { id: "pair", name: "Pair", light: "first", dark: "last" }, + ], }, }), }, @@ -116,6 +122,9 @@ describe("PluginRegistry", () => { { id: "example:first", pluginId: "example", localId: "first", name: "First" }, { id: "example:last", pluginId: "example", localId: "last", name: "Last" }, ]); + expect(registry.getThemePairs().map((pair) => ({ id: pair.id, pluginId: pair.pluginId, localId: pair.localId, light: pair.light, dark: pair.dark }))).toEqual([ + { id: "example:pair", pluginId: "example", localId: "pair", light: "example:first", dark: "example:last" }, + ]); }); it("collects workspace label items in contribution order", () => { diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 26d821d..a22c1b4 100644 --- a/src/client/src/plugins/registry.ts +++ b/src/client/src/plugins/registry.ts @@ -1,7 +1,7 @@ import { html } from "lit"; import type { AppState } from "../appState"; import type { Workspace } from "../api"; -import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types"; +import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedThemePairContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, ThemePairContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types"; const idPattern = /^[a-z][a-z0-9.-]*$/u; const localIdPattern = /^[a-z][a-z0-9.-]*$/u; @@ -17,6 +17,7 @@ export class PluginRegistry { private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = []; private readonly workspaceLabels: QualifiedWorkspaceLabelContribution[] = []; private readonly themes: QualifiedThemeContribution[] = []; + private readonly themePairs: QualifiedThemePairContribution[] = []; private readonly pluginIds = new Set(); private readonly contributionIds = new Set(); @@ -34,6 +35,7 @@ export class PluginRegistry { for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel)); for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution)); for (const theme of contributions.themes ?? []) this.themes.push(this.qualifyTheme(id, theme)); + for (const pair of contributions.themePairs ?? []) this.themePairs.push(this.qualifyThemePair(id, pair)); } getActions(context: PluginRuntimeContext): QualifiedPluginAction[] { @@ -62,6 +64,10 @@ export class PluginRegistry { return [...this.themes].sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.name.localeCompare(right.name)); } + getThemePairs(): QualifiedThemePairContribution[] { + return [...this.themePairs].sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.name.localeCompare(right.name)); + } + getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] { const context = { state, workspace }; return [...this.workspaceLabels] @@ -92,6 +98,18 @@ export class PluginRegistry { return { ...theme, id, pluginId, localId: theme.id }; } + private qualifyThemePair(pluginId: string, pair: ThemePairContribution): QualifiedThemePairContribution { + const id = this.qualify(pluginId, pair.id); + return { + ...pair, + id, + pluginId, + localId: pair.id, + light: this.qualifyReference(pluginId, pair.light), + dark: this.qualifyReference(pluginId, pair.dark), + }; + } + private qualify(pluginId: string, localId: string): QualifiedContributionId { this.validateLocalId(localId); const qualified: QualifiedContributionId = `${pluginId}:${localId}`; @@ -100,6 +118,11 @@ export class PluginRegistry { return qualified; } + private qualifyReference(pluginId: string, localId: string): QualifiedContributionId { + this.validateLocalId(localId); + return `${pluginId}:${localId}`; + } + private validatePluginId(pluginId: string): void { if (!idPattern.test(pluginId)) throw new Error(`Invalid plugin id: ${pluginId}`); } diff --git a/src/client/src/plugins/themes/index.ts b/src/client/src/plugins/themes/index.ts index 9e03cdb..075c85f 100644 --- a/src/client/src/plugins/themes/index.ts +++ b/src/client/src/plugins/themes/index.ts @@ -1,6 +1,6 @@ import type { PiWebPlugin, ThemeTokens } from "../types"; -const currentTokens = { +const classicTokens = { "--pi-bg": "#0d1117", "--pi-surface": "#161b22", "--pi-surface-hover": "#21262d", @@ -38,7 +38,7 @@ const currentTokens = { "--pi-terminal-selection": "#264f78", } satisfies ThemeTokens; -const docsDarkTokens = { +const piWebDarkTokens = { "--pi-bg": "#070912", "--pi-surface": "#101527", "--pi-surface-hover": "#151b31", @@ -76,7 +76,7 @@ const docsDarkTokens = { "--pi-terminal-selection": "#3d4a78", } satisfies ThemeTokens; -const docsLightTokens = { +const piWebLightTokens = { "--pi-bg": "#f7f1e6", "--pi-surface": "#fff9ee", "--pi-surface-hover": "#f0e6d6", @@ -121,28 +121,38 @@ export const themePackPlugin: PiWebPlugin = { contributions: { themes: [ { - id: "current", - name: "Pi Web Current", - description: "The original Pi Web dark palette.", + id: "pi-web-dark", + name: "Pi Web Dark", + description: "Dark Pi Web palette.", order: 10, colorScheme: "dark", - tokens: currentTokens, + tokens: piWebDarkTokens, }, { - id: "docs-dark", - name: "Docs Dark", - description: "Dark theme based on the Pi Web docs site.", + id: "pi-web-light", + name: "Pi Web Light", + description: "Light Pi Web palette.", order: 20, - colorScheme: "dark", - tokens: docsDarkTokens, + colorScheme: "light", + tokens: piWebLightTokens, }, { - id: "docs-light", - name: "Docs Light", - description: "Light theme based on the Pi Web docs site.", + id: "classic", + name: "Pi Web Classic", + description: "The original Pi Web dark palette.", order: 30, - colorScheme: "light", - tokens: docsLightTokens, + colorScheme: "dark", + tokens: classicTokens, + }, + ], + themePairs: [ + { + id: "pi-web", + name: "Pi Web", + description: "Follow the system light/dark preference with Pi Web themes.", + order: 10, + light: "pi-web-light", + dark: "pi-web-dark", }, ], }, diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index e1ad6f0..6ca95db 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -34,6 +34,7 @@ export interface PluginContributions { workspacePanels?: WorkspacePanelContribution[]; workspaceLabels?: WorkspaceLabelContribution[]; themes?: ThemeContribution[]; + themePairs?: ThemePairContribution[]; } export interface PluginRuntimeContext { @@ -193,12 +194,29 @@ export interface ThemeContribution { tokens: ThemeTokens; } +export interface ThemePairContribution { + id: LocalContributionId; + name: string; + description?: string; + order?: number; + light: LocalContributionId; + dark: LocalContributionId; +} + export interface QualifiedThemeContribution extends ThemeContribution { id: QualifiedContributionId; pluginId: PluginId; localId: LocalContributionId; } +export interface QualifiedThemePairContribution extends Omit { + id: QualifiedContributionId; + pluginId: PluginId; + localId: LocalContributionId; + light: QualifiedContributionId; + dark: QualifiedContributionId; +} + export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContribution { id: QualifiedContributionId; pluginId: PluginId; diff --git a/src/client/src/theme.test.ts b/src/client/src/theme.test.ts new file mode 100644 index 0000000..ce4f9e4 --- /dev/null +++ b/src/client/src/theme.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { CLASSIC_THEME_ID, DEFAULT_THEME_PREFERENCE, findThemePairForTheme, resolveThemePreference } from "./theme"; +import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, ThemeColorScheme, ThemeTokens } from "./plugins/types"; + +const tokens = { + "--pi-bg": "#000000", + "--pi-surface": "#000000", + "--pi-surface-hover": "#000000", + "--pi-terminal-bg": "#000000", + "--pi-terminal-text": "#000000", + "--pi-border": "#000000", + "--pi-border-muted": "#000000", + "--pi-text": "#000000", + "--pi-text-secondary": "#000000", + "--pi-text-bright": "#000000", + "--pi-muted": "#000000", + "--pi-dim": "#000000", + "--pi-accent": "#000000", + "--pi-accent-border": "#000000", + "--pi-selection-bg": "#000000", + "--pi-success": "#000000", + "--pi-success-border": "#000000", + "--pi-success-bg": "#000000", + "--pi-success-surface": "#000000", + "--pi-success-ring": "#000000", + "--pi-warning": "#000000", + "--pi-warning-border": "#000000", + "--pi-warning-surface": "#000000", + "--pi-danger": "#000000", + "--pi-purple": "#000000", + "--pi-purple-border": "#000000", + "--pi-purple-surface": "#000000", + "--pi-overlay": "#000000", + "--pi-shadow-soft": "#000000", + "--pi-shadow": "#000000", + "--pi-shadow-strong": "#000000", + "--pi-bg-overlay-soft": "#000000", + "--pi-bg-overlay": "#000000", + "--pi-success-bg-overlay": "#000000", + "--pi-terminal-selection": "#000000", +} satisfies ThemeTokens; + +const themes = [ + theme("pi-web-dark", "Pi Web Dark", "dark"), + theme("pi-web-light", "Pi Web Light", "light"), + theme("classic", "Pi Web Classic", "dark"), +]; + +const themePairs: QualifiedThemePairContribution[] = [ + { + id: "themes:pi-web", + pluginId: "themes", + localId: "pi-web", + name: "Pi Web", + light: "themes:pi-web-light", + dark: "themes:pi-web-dark", + }, +]; + +describe("resolveThemePreference", () => { + it("resolves the default auto preference to the dark member when the system is dark", () => { + expect(resolveThemePreference({ themes, themePairs, preference: DEFAULT_THEME_PREFERENCE, prefersLight: false }).activeTheme?.id) + .toBe("themes:pi-web-dark"); + }); + + it("resolves the default auto preference to the light member when the system is light", () => { + expect(resolveThemePreference({ themes, themePairs, preference: DEFAULT_THEME_PREFERENCE, prefersLight: true }).activeTheme?.id) + .toBe("themes:pi-web-light"); + }); + + it("keeps an unpaired theme selected when auto is enabled", () => { + const resolution = resolveThemePreference({ + themes, + themePairs, + preference: { themeId: CLASSIC_THEME_ID, auto: true }, + prefersLight: true, + }); + + expect(resolution.selectedTheme?.id).toBe("themes:classic"); + expect(resolution.activeTheme?.id).toBe("themes:classic"); + expect(resolution.selectedThemePair).toBeUndefined(); + }); + + it("falls back to Classic when the selected theme does not exist", () => { + const resolution = resolveThemePreference({ + themes, + themePairs, + preference: { themeId: "plugin:missing", auto: false }, + prefersLight: true, + }); + + expect(resolution.selectedTheme?.id).toBe("themes:classic"); + expect(resolution.activeTheme?.id).toBe("themes:classic"); + }); + + it("does not overwrite a missing selected theme preference in the resolution result", () => { + const missingThemeId: QualifiedContributionId = "plugin:missing"; + const resolution = resolveThemePreference({ + themes, + themePairs, + preference: { themeId: missingThemeId, auto: true }, + prefersLight: false, + }); + + expect(resolution.selectedTheme?.id).toBe("themes:classic"); + expect(missingThemeId).toBe("plugin:missing"); + }); + + it("can look up a pair from either member theme", () => { + expect(findThemePairForTheme(themePairs, "themes:pi-web-light")?.id).toBe("themes:pi-web"); + expect(findThemePairForTheme(themePairs, "themes:pi-web-dark")?.id).toBe("themes:pi-web"); + }); +}); + +function theme(localId: string, name: string, colorScheme: ThemeColorScheme): QualifiedThemeContribution { + return { + id: `themes:${localId}`, + pluginId: "themes", + localId, + name, + colorScheme, + tokens, + }; +} diff --git a/src/client/src/theme.ts b/src/client/src/theme.ts index f3a3fa9..9b40bf1 100644 --- a/src/client/src/theme.ts +++ b/src/client/src/theme.ts @@ -1,6 +1,28 @@ -import type { QualifiedContributionId, QualifiedThemeContribution, ThemeToken } from "./plugins/types"; +import type { QualifiedContributionId, QualifiedThemeContribution, QualifiedThemePairContribution, ThemeToken } from "./plugins/types"; -export const DEFAULT_THEME_ID: QualifiedContributionId = "themes:current"; +export interface ThemePreference { + themeId: QualifiedContributionId; + auto: boolean; +} + +export interface ResolveThemePreferenceOptions { + themes: readonly QualifiedThemeContribution[]; + themePairs: readonly QualifiedThemePairContribution[]; + preference: ThemePreference; + prefersLight: boolean; + fallbackThemeId?: QualifiedContributionId; +} + +export interface ThemePreferenceResolution { + selectedTheme: QualifiedThemeContribution | undefined; + activeTheme: QualifiedThemeContribution | undefined; + selectedThemePair: QualifiedThemePairContribution | undefined; + fallbackTheme: QualifiedThemeContribution | undefined; +} + +export const CLASSIC_THEME_ID: QualifiedContributionId = "themes:classic"; +export const DEFAULT_THEME_ID: QualifiedContributionId = "themes:pi-web-dark"; +export const DEFAULT_THEME_PREFERENCE: ThemePreference = { themeId: DEFAULT_THEME_ID, auto: true }; export const THEME_STORAGE_KEY = "pi-web-app-theme"; export const THEME_TOKENS: ThemeToken[] = [ @@ -43,16 +65,24 @@ export const THEME_TOKENS: ThemeToken[] = [ const qualifiedContributionIdPattern = /^[a-z][a-z0-9.-]*:[a-z][a-z0-9.-]*$/u; -export function readStoredThemeId(): QualifiedContributionId | undefined { +export function readStoredThemePreference(): ThemePreference | undefined { try { const value = window.localStorage.getItem(THEME_STORAGE_KEY); - return isQualifiedContributionId(value) ? value : undefined; + return value === null ? undefined : parseThemePreference(value); } catch { return undefined; } } -export function applyPiWebTheme(theme: QualifiedThemeContribution, options: { persist?: boolean } = {}): void { +export function writeStoredThemePreference(preference: ThemePreference): void { + try { + window.localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(preference)); + } catch { + // Ignore storage failures; the selected theme can still apply for this tab. + } +} + +export function applyPiWebTheme(theme: QualifiedThemeContribution): void { const root = document.documentElement; root.dataset["piWebTheme"] = theme.id; root.style.colorScheme = theme.colorScheme; @@ -61,14 +91,53 @@ export function applyPiWebTheme(theme: QualifiedThemeContribution, options: { pe if (typeof value === "string" && value !== "") root.style.setProperty(token, value); else root.style.removeProperty(token); } - if (options.persist === false) return; +} + +export function resolveThemePreference(options: ResolveThemePreferenceOptions): ThemePreferenceResolution { + const fallbackTheme = findFallbackTheme(options.themes, options.fallbackThemeId ?? CLASSIC_THEME_ID); + const selectedTheme = options.themes.find((candidate) => candidate.id === options.preference.themeId) ?? fallbackTheme; + if (selectedTheme === undefined) { + return { selectedTheme: undefined, activeTheme: undefined, selectedThemePair: undefined, fallbackTheme }; + } + const selectedThemePair = findThemePairForTheme(options.themePairs, selectedTheme.id); + if (!options.preference.auto || selectedThemePair === undefined) { + return { selectedTheme, activeTheme: selectedTheme, selectedThemePair, fallbackTheme }; + } + const activeThemeId = options.prefersLight ? selectedThemePair.light : selectedThemePair.dark; + const activeTheme = options.themes.find((candidate) => candidate.id === activeThemeId) ?? selectedTheme; + return { selectedTheme, activeTheme, selectedThemePair, fallbackTheme }; +} + +export function findFallbackTheme(themes: readonly QualifiedThemeContribution[], fallbackThemeId: QualifiedContributionId = CLASSIC_THEME_ID): QualifiedThemeContribution | undefined { + return themes.find((candidate) => candidate.id === fallbackThemeId) ?? themes[0]; +} + +export function findThemePairForTheme(themePairs: readonly QualifiedThemePairContribution[], themeId: QualifiedContributionId): QualifiedThemePairContribution | undefined { + return themePairs.find((pair) => pair.light === themeId || pair.dark === themeId); +} + +function parseThemePreference(value: string): ThemePreference | undefined { + const trimmed = value.trim(); + if (trimmed === "") return undefined; try { - window.localStorage.setItem(THEME_STORAGE_KEY, theme.id); + const parsed: unknown = JSON.parse(trimmed); + return isThemePreference(parsed) ? parsed : undefined; } catch { - // Ignore storage failures; the selected theme can still apply for this tab. + return undefined; } } -function isQualifiedContributionId(value: string | null): value is QualifiedContributionId { - return value !== null && qualifiedContributionIdPattern.test(value); +function isThemePreference(value: unknown): value is ThemePreference { + if (!isUnknownRecord(value)) return false; + const themeId = value["themeId"]; + const auto = value["auto"]; + return isQualifiedContributionId(themeId) && typeof auto === "boolean"; +} + +function isUnknownRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isQualifiedContributionId(value: unknown): value is QualifiedContributionId { + return typeof value === "string" && qualifiedContributionIdPattern.test(value); }