feat: add automatic Pi Web theme pairs

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 22:29:09 +02:00
parent 619840a398
commit 1f06b25dee
9 changed files with 385 additions and 51 deletions
+5
View File
@@ -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.
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep loading other external plugins when one plugin fails during registration.
+91 -20
View File
@@ -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<string>();
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<void> {
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() {
+12 -3
View File
@@ -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", () => {
+24 -1
View File
@@ -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<string>();
private readonly contributionIds = new Set<QualifiedContributionId>();
@@ -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}`);
}
+27 -17
View File
@@ -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",
},
],
},
+18
View File
@@ -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<ThemePairContribution, "id" | "light" | "dark"> {
id: QualifiedContributionId;
pluginId: PluginId;
localId: LocalContributionId;
light: QualifiedContributionId;
dark: QualifiedContributionId;
}
export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContribution {
id: QualifiedContributionId;
pluginId: PluginId;
+124
View File
@@ -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,
};
}
+79 -10
View File
@@ -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<string, unknown> {
return typeof value === "object" && value !== null;
}
function isQualifiedContributionId(value: unknown): value is QualifiedContributionId {
return typeof value === "string" && qualifiedContributionIdPattern.test(value);
}