Archived
feat: add automatic Pi Web theme pairs
This commit is contained in:
@@ -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.
|
||||
@@ -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() {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user