From b51d56c36cab61d9ff466bfde9b1757bd4e44c07 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Mon, 18 May 2026 21:56:35 +0200 Subject: [PATCH] feat: add Pi Web theme picker --- .changeset/themable-current-colors.md | 5 + src/client/index.html | 40 ++- src/client/src/appState.ts | 2 + src/client/src/components/AuthDialog.ts | 20 +- src/client/src/components/CodeViewer.ts | 10 +- src/client/src/components/PiWebApp.ts | 43 +++ src/client/src/components/ProjectDialog.ts | 30 +-- src/client/src/components/TerminalPanel.ts | 60 +++-- src/client/src/components/shared.ts | 290 +++++++++++---------- src/client/src/plugins/core/actions.ts | 7 + src/client/src/plugins/registry.test.ts | 79 +++++- src/client/src/plugins/registry.ts | 13 +- src/client/src/plugins/themes/index.ts | 150 +++++++++++ src/client/src/plugins/types.ts | 58 +++++ src/client/src/theme.ts | 74 ++++++ 15 files changed, 689 insertions(+), 192 deletions(-) create mode 100644 .changeset/themable-current-colors.md create mode 100644 src/client/src/plugins/themes/index.ts create mode 100644 src/client/src/theme.ts diff --git a/.changeset/themable-current-colors.md b/.changeset/themable-current-colors.md new file mode 100644 index 0000000..436e6ff --- /dev/null +++ b/.changeset/themable-current-colors.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add theme tokens, a theme picker, and built-in current/docs-inspired themes for the Pi Web UI. diff --git a/src/client/index.html b/src/client/index.html index 572ac6e..12bc470 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -5,7 +5,45 @@ Pi Web diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index 5ef6f89..c5fcfd2 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -21,6 +21,7 @@ export interface AppState { commandDialog: Extract | undefined; modelDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined; thinkingDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined; + themeDialog: { title: string; options: CommandOption[]; selectedValue?: string } | undefined; authDialog: AuthDialogState | undefined; actionPaletteOpen: boolean; projectDialogOpen: boolean; @@ -67,6 +68,7 @@ export function initialAppState(): AppState { commandDialog: undefined, modelDialog: undefined, thinkingDialog: undefined, + themeDialog: undefined, authDialog: undefined, actionPaletteOpen: false, projectDialogOpen: false, diff --git a/src/client/src/components/AuthDialog.ts b/src/client/src/components/AuthDialog.ts index 5c5b5c1..28a5698 100644 --- a/src/client/src/components/AuthDialog.ts +++ b/src/client/src/components/AuthDialog.ts @@ -146,19 +146,19 @@ export class AuthDialog extends LitElement { static override styles = [commandPickerStyles, css` .form { display: grid; gap: 12px; padding: 14px; overflow: auto; } - .form p { margin: 0; color: #c9d1d9; overflow-wrap: anywhere; } - .form a { color: #58a6ff; overflow-wrap: anywhere; } - .form code { border: 1px solid #30363d; border-radius: 4px; background: #161b22; padding: 1px 4px; } - label { color: #8b949e; } + .form p { margin: 0; color: var(--pi-text-secondary); overflow-wrap: anywhere; } + .form a { color: var(--pi-accent); overflow-wrap: anywhere; } + .form code { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-surface); padding: 1px 4px; } + label { color: var(--pi-muted); } .actions { display: flex; justify-content: flex-end; gap: 8px; } - .actions button, .inline-options button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; } - .actions button.primary { border-color: #238636; background: #0f2a16; color: #3fb950; } + .actions button, .inline-options button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; } + .actions button.primary { border-color: var(--pi-success-border); background: var(--pi-success-surface); color: var(--pi-success); } .actions button:disabled { opacity: .6; cursor: wait; } - .warning { color: #d29922; } - .error-text { color: #ff7b72; } - .progress { margin: 0; padding-left: 18px; color: #8b949e; } + .warning { color: var(--pi-warning); } + .error-text { color: var(--pi-danger); } + .progress { margin: 0; padding-left: 18px; color: var(--pi-muted); } .inline-options { display: grid; gap: 8px; } - em { color: #3fb950; font-style: normal; font-size: 12px; } + em { color: var(--pi-success); font-style: normal; font-size: 12px; } `]; } diff --git a/src/client/src/components/CodeViewer.ts b/src/client/src/components/CodeViewer.ts index 57ebcee..7523f15 100644 --- a/src/client/src/components/CodeViewer.ts +++ b/src/client/src/components/CodeViewer.ts @@ -70,8 +70,8 @@ export class CodeViewer extends LitElement { const viewerTheme = EditorView.theme({ "&": { height: "100%", - color: "#e6edf3", - backgroundColor: "#0d1117", + color: "var(--pi-text)", + backgroundColor: "var(--pi-bg)", fontSize: "12px", }, ".cm-scroller": { @@ -79,9 +79,9 @@ const viewerTheme = EditorView.theme({ lineHeight: "1.45", }, ".cm-gutters": { - backgroundColor: "#0d1117", - color: "#6e7681", - borderRight: "1px solid #21262d", + backgroundColor: "var(--pi-bg)", + color: "var(--pi-dim)", + borderRight: "1px solid var(--pi-border-muted)", }, ".cm-activeLineGutter": { backgroundColor: "transparent", diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index a06f47c..35ee626 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -12,7 +12,9 @@ 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 { corePlugin } from "../plugins/core"; +import { themePackPlugin } from "../plugins/themes"; import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry } from "../plugins/registry"; import { queryNamespace, readNamespacedString } from "../namespacedQueryArgs"; @@ -77,6 +79,8 @@ export class PiWebApp extends LitElement { private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined; private terminalAutoStartWorkspaceId: string | undefined; private readonly plugins = createPluginRegistry(); + private preferredThemeId: QualifiedContributionId = readStoredThemeId() ?? DEFAULT_THEME_ID; + @state() private activeThemeId: QualifiedContributionId = DEFAULT_THEME_ID; @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; @state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined; private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); @@ -101,6 +105,7 @@ export class PiWebApp extends LitElement { document.addEventListener("visibilitychange", this.onVisibilityChange); window.addEventListener("keydown", this.onKeyDown); this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); + this.applyPreferredTheme(false); this.connectRealtime(); void this.loadExternalPlugins(); void this.loadProjectsAndRestoreRoute(); @@ -387,6 +392,7 @@ export class PiWebApp extends LitElement { private async loadExternalPlugins(): Promise { try { for (const registration of await loadExternalPlugins()) this.plugins.register(registration); + this.applyPreferredTheme(false); this.requestUpdate(); } catch (error) { console.warn("Failed to load external Pi Web plugins", error); @@ -401,6 +407,7 @@ export class PiWebApp extends LitElement { addProject: () => { this.setState({ projectDialogOpen: true }); }, configureAuth: () => this.auth.openLogin(), logoutAuth: () => this.auth.openLogout(), + openThemePicker: () => { this.openThemeDialog(); }, selectMainView: (view) => { this.selectMainView(view); }, selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); }, refreshFiles: () => this.files.refreshFiles(), @@ -441,6 +448,40 @@ export class PiWebApp extends LitElement { await this.sessions.setModel(value.slice(0, slash), value.slice(slash + 1)); } + private openThemeDialog() { + const themes = this.plugins.getThemes(); + 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}`, + })), + }, + }); + } + + private pickTheme(value: string) { + const theme = this.plugins.getThemes().find((candidate) => candidate.id === value); + this.setState({ themeDialog: undefined }); + if (theme === undefined) return; + this.preferredThemeId = theme.id; + this.activeThemeId = theme.id; + applyPiWebTheme(theme); + } + + 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]; + if (theme === undefined) return; + this.activeThemeId = theme.id; + applyPiWebTheme(theme, { persist }); + } + private async openThinkingDialog() { const levels = await this.sessions.listThinkingLevels(); const current = this.state.status?.thinkingLevel ?? "off"; @@ -491,6 +532,7 @@ export class PiWebApp extends LitElement { ${this.renderWorkspacePanel()} ${state.actionPaletteOpen ? html` { this.setState({ actionPaletteOpen: false }); this.runAction(actionId); }} .onCancel=${() => { this.setState({ actionPaletteOpen: false }); }}>` : null} ${state.projectDialogOpen ? html` this.projects.addProject(path, create)} .onCancel=${() => { this.setState({ projectDialogOpen: false }); }}>` : null} + ${state.themeDialog !== undefined ? html` { this.pickTheme(value); }} .onCancel=${() => { this.setState({ themeDialog: undefined }); }}>` : null} `; } @@ -501,6 +543,7 @@ export class PiWebApp extends LitElement { function createPluginRegistry(): PluginRegistry { const registry = new PluginRegistry(); registry.register({ id: "core", plugin: corePlugin }); + registry.register({ id: "themes", plugin: themePackPlugin }); return registry; } diff --git a/src/client/src/components/ProjectDialog.ts b/src/client/src/components/ProjectDialog.ts index 8a034ae..b89a82d 100644 --- a/src/client/src/components/ProjectDialog.ts +++ b/src/client/src/components/ProjectDialog.ts @@ -123,22 +123,22 @@ export class ProjectDialog extends LitElement { } static override styles = css` - :host { position: fixed; inset: 0; z-index: 30; color: #e6edf3; font: 14px system-ui, sans-serif; } - .backdrop { display: grid; place-items: start center; width: 100%; height: 100%; padding-top: min(12vh, 90px); box-sizing: border-box; background: #0008; } - section { width: min(720px, calc(100vw - 40px)); max-height: min(700px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid #30363d; border-radius: 12px; background: #0d1117; box-shadow: 0 20px 60px #000b; overflow: hidden; } - header, footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid #30363d; } - footer { border-top: 1px solid #30363d; border-bottom: 0; justify-content: end; } + :host { position: fixed; inset: 0; z-index: 30; color: var(--pi-text); font: 14px system-ui, sans-serif; } + .backdrop { display: grid; place-items: start center; width: 100%; height: 100%; padding-top: min(12vh, 90px); box-sizing: border-box; background: var(--pi-overlay); } + section { width: min(720px, calc(100vw - 40px)); max-height: min(700px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid var(--pi-border); border-radius: 12px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; } + header, footer { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); } + footer { border-top: 1px solid var(--pi-border); border-bottom: 0; justify-content: end; } .body { display: grid; gap: 12px; padding: 12px; min-height: 0; } - label { display: grid; gap: 6px; color: #8b949e; } - input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid #30363d; border-radius: 8px; background: #0d1117; color: #e6edf3; padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - .check { display: flex; grid-template-columns: auto 1fr; align-items: center; color: #e6edf3; } - .suggestions { min-height: 90px; max-height: 320px; overflow: auto; border: 1px solid #30363d; border-radius: 8px; background: #161b22; } - .suggestions button { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; border-bottom: 1px solid #30363d; border-radius: 0; background: transparent; color: #e6edf3; padding: 8px 10px; text-align: left; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - .suggestions button.selected, .suggestions button:hover { background: #0d2847; } - .hint { padding: 12px; color: #8b949e; } - button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } - header button { border: 0; background: transparent; color: #8b949e; font-size: 22px; padding: 0 8px; } - .primary { border-color: #238636; background: #238636; } + label { display: grid; gap: 6px; color: var(--pi-muted); } + input[type="text"], input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); padding: 9px; font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .check { display: flex; grid-template-columns: auto 1fr; align-items: center; color: var(--pi-text); } + .suggestions { min-height: 90px; max-height: 320px; overflow: auto; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); } + .suggestions button { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; border-bottom: 1px solid var(--pi-border); border-radius: 0; background: transparent; color: var(--pi-text); padding: 8px 10px; text-align: left; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .suggestions button.selected, .suggestions button:hover { background: var(--pi-selection-bg); } + .hint { padding: 12px; color: var(--pi-muted); } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + header button { border: 0; background: transparent; color: var(--pi-muted); font-size: 22px; padding: 0 8px; } + .primary { border-color: var(--pi-success-border); background: var(--pi-success-border); } button:disabled { opacity: .5; cursor: not-allowed; } `; } diff --git a/src/client/src/components/TerminalPanel.ts b/src/client/src/components/TerminalPanel.ts index f4ea184..0e2cd91 100644 --- a/src/client/src/components/TerminalPanel.ts +++ b/src/client/src/components/TerminalPanel.ts @@ -1,16 +1,15 @@ import { css, html, LitElement } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; -import { Terminal, type ITerminalOptions } from "@xterm/xterm"; +import { Terminal, type ITerminalOptions, type ITheme } from "@xterm/xterm"; import { FitAddon, type ITerminalDimensions } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import { terminalSocket, terminalsApi, type TerminalInfo, type Workspace } from "../api"; -const TERMINAL_OPTIONS: ITerminalOptions = { +const TERMINAL_OPTIONS_BASE: ITerminalOptions = { cursorBlink: true, convertEol: true, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontSize: 13, - theme: { background: "#05070a", foreground: "#e6edf3", cursor: "#58a6ff", selectionBackground: "#264f78" }, }; const DEFAULT_TERMINAL_SIZE: TerminalSize = { cols: 100, rows: 30 }; @@ -31,10 +30,17 @@ export class TerminalPanel extends LitElement { private socket: WebSocket | undefined; private resizeObserver: ResizeObserver | undefined; private intersectionObserver: IntersectionObserver | undefined; + private themeObserver: MutationObserver | undefined; private suppressTerminalInput = false; private observedCwd: string | undefined; private loadedCwd: string | undefined; + override connectedCallback(): void { + super.connectedCallback(); + this.themeObserver = new MutationObserver(() => { this.applyTerminalTheme(); }); + this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] }); + } + override firstUpdated(): void { this.intersectionObserver = new IntersectionObserver((entries) => { this.visible = entries[0]?.isIntersecting === true; @@ -45,6 +51,8 @@ export class TerminalPanel extends LitElement { override disconnectedCallback(): void { this.intersectionObserver?.disconnect(); this.intersectionObserver = undefined; + this.themeObserver?.disconnect(); + this.themeObserver = undefined; this.disposeTerminalView(); super.disconnectedCallback(); } @@ -126,7 +134,7 @@ export class TerminalPanel extends LitElement { private ensureTerminalView(): void { const workspace = this.workspace; if (!this.visible || this.terminal !== undefined || this.selectedId === undefined || this.terminalHost === undefined || workspace === undefined) return; - const terminal = new Terminal(TERMINAL_OPTIONS); + const terminal = new Terminal(terminalOptions(this)); const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); terminal.open(this.terminalHost); @@ -205,7 +213,7 @@ export class TerminalPanel extends LitElement { if (currentSize !== undefined) return currentSize; if (this.terminal !== undefined || this.terminalHost === undefined) return undefined; - const measuringTerminal = new Terminal(TERMINAL_OPTIONS); + const measuringTerminal = new Terminal(terminalOptions(this)); const measuringFitAddon = new FitAddon(); measuringTerminal.loadAddon(measuringFitAddon); measuringTerminal.open(this.terminalHost); @@ -214,6 +222,10 @@ export class TerminalPanel extends LitElement { return size; } + private applyTerminalTheme(): void { + if (this.terminal !== undefined) this.terminal.options.theme = terminalTheme(this); + } + private send(message: { type: "input"; data: string } | { type: "resize"; cols: number; rows: number }): void { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); } @@ -249,21 +261,21 @@ export class TerminalPanel extends LitElement { static override styles = css` :host { flex: 1 1 auto; min-height: 0; display: flex; } - .terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: #05070a; } - .terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid #21262d; background: #0d1117; overflow: auto; } - button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; } - button.selected { border-color: #58a6ff; background: #0d2847; } - button.new { flex: 0 0 auto; color: #8b949e; } + .terminal-shell { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; background: var(--pi-terminal-bg); } + .terminal-tabs { flex: 0 0 auto; display: flex; gap: 6px; align-items: center; padding: 6px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); overflow: auto; } + button { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 180px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; } + button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + button.new { flex: 0 0 auto; color: var(--pi-muted); } button span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - button small { color: #8b949e; font-size: 14px; line-height: 1; } - button small:hover { color: #ff7b72; } + button small { color: var(--pi-muted); font-size: 14px; line-height: 1; } + button small:hover { color: var(--pi-danger); } button:disabled { opacity: .5; cursor: not-allowed; } .terminal-host { flex: 1 1 auto; min-height: 0; padding: 6px; box-sizing: border-box; overflow: hidden; } .terminal-host .xterm { height: 100%; cursor: text; position: relative; user-select: none; } .terminal-host .xterm.focus, .terminal-host .xterm:focus { outline: none; } .terminal-host .xterm-helpers { position: absolute; top: 0; z-index: 5; } .terminal-host .xterm-helper-textarea { position: absolute !important; left: -9999em !important; top: 0 !important; width: 0 !important; height: 0 !important; min-width: 0 !important; min-height: 0 !important; padding: 0 !important; border: 0 !important; margin: 0 !important; opacity: 0 !important; z-index: -5 !important; white-space: nowrap !important; overflow: hidden !important; resize: none !important; outline: 0 !important; appearance: none !important; } - .terminal-host .xterm-viewport { position: absolute; inset: 0; overflow-y: scroll; cursor: default; background-color: #05070a; } + .terminal-host .xterm-viewport { position: absolute; inset: 0; overflow-y: scroll; cursor: default; background-color: var(--pi-terminal-bg); } .terminal-host .xterm-screen { position: relative; } .terminal-host .xterm-screen canvas { position: absolute; left: 0; top: 0; } .terminal-host .xterm-char-measure-element { display: inline-block; visibility: hidden; position: absolute; top: 0; left: -9999em; line-height: normal; } @@ -272,8 +284,8 @@ export class TerminalPanel extends LitElement { .terminal-host .xterm-accessibility-tree { font-family: monospace; user-select: text; white-space: pre; } .terminal-host .xterm-accessibility-tree > div { transform-origin: left; width: fit-content; } .terminal-host .live-region { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; } - .error { flex: 0 0 auto; margin: 0; padding: 8px; color: #ff7b72; border-bottom: 1px solid #30363d; background: #161b22; } - .muted { margin: 10px; color: #8b949e; } + .error { flex: 0 0 auto; margin: 0; padding: 8px; color: var(--pi-danger); border-bottom: 1px solid var(--pi-border); background: var(--pi-surface); } + .muted { margin: 10px; color: var(--pi-muted); } .xterm { height: 100%; } `; } @@ -312,6 +324,24 @@ async function socketDataToString(data: unknown): Promise { return String(data); } +function terminalOptions(element: HTMLElement): ITerminalOptions { + return { ...TERMINAL_OPTIONS_BASE, theme: terminalTheme(element) }; +} + +function terminalTheme(element: HTMLElement): ITheme { + return { + background: themeColor(element, "--pi-terminal-bg", "#05070a"), + foreground: themeColor(element, "--pi-terminal-text", "#e6edf3"), + cursor: themeColor(element, "--pi-accent", "#58a6ff"), + selectionBackground: themeColor(element, "--pi-terminal-selection", "#264f78"), + }; +} + +function themeColor(element: HTMLElement, name: string, fallback: string): string { + const value = getComputedStyle(element).getPropertyValue(name).trim(); + return value === "" ? fallback : value; +} + function terminalSizeFromDimensions(dimensions: ITerminalDimensions | undefined): TerminalSize | undefined { if (dimensions === undefined || !isValidTerminalSize(dimensions.cols, dimensions.rows)) return undefined; return { cols: Math.floor(dimensions.cols), rows: Math.floor(dimensions.rows) }; diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 9fb4b36..038a2cc 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -30,18 +30,18 @@ export interface CompletionItem { } export const appStyles = css` - :host { display: block; height: 100dvh; box-sizing: border-box; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: #e6edf3; background: #0d1117; font: 14px system-ui, sans-serif; } + :host { display: block; height: 100dvh; box-sizing: border-box; padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); color: var(--pi-text); background: var(--pi-bg); font: 14px system-ui, sans-serif; } .shell { display: grid; grid-template-columns: 340px minmax(420px, 1fr) minmax(360px, 42vw); height: 100%; min-height: 0; } - aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid #30363d; overflow: hidden; } - header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid #30363d; } - project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid #21262d; } + aside { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--pi-border); overflow: hidden; } + header { flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; border-bottom: 1px solid var(--pi-border); } + project-list, workspace-list { flex: 0 0 auto; max-height: 26%; overflow: auto; border-bottom: 1px solid var(--pi-border-muted); } session-list { flex: 1 1 auto; min-height: 0; overflow: auto; } main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } - .mobile-tabs { display: none; flex: 0 0 auto; gap: 6px; padding: 8px; border-bottom: 1px solid #30363d; overflow-x: auto; } + .mobile-tabs { display: none; flex: 0 0 auto; gap: 6px; padding: 8px; border-bottom: 1px solid var(--pi-border); overflow-x: auto; } .mobile-navigation-tab, .mobile-navigation-panel { display: none; } - .mobile-tabs button.selected { border-color: #58a6ff; background: #0d2847; } - .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } - workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid #30363d; overflow: hidden; } + .mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } + workspace-panel { min-width: 0; min-height: 0; border-left: 1px solid var(--pi-border); overflow: hidden; } @media (max-width: 1180px) { .shell { grid-template-columns: 340px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); } aside { grid-row: 1 / 3; } @@ -72,42 +72,42 @@ export const appStyles = css` status-bar { flex: 0 0 auto; } chat-view { flex: 1 1 auto; min-height: 0; overflow: hidden; } prompt-editor, chat-composer { flex: 0 0 auto; } - button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } - .empty { margin: auto; color: #8b949e; } - .error { padding: 10px 16px; border-bottom: 1px solid #30363d; color: #ff7b72; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } + .empty { margin: auto; color: var(--pi-muted); } + .error { padding: 10px 16px; border-bottom: 1px solid var(--pi-border); color: var(--pi-danger); } `; export const workspacePanelStyles = css` - :host { display: flex; flex-direction: column; min-height: 0; color: #e6edf3; background: #0d1117; font: 13px system-ui, sans-serif; } - header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #30363d; } + :host { display: flex; flex-direction: column; min-height: 0; color: var(--pi-text); background: var(--pi-bg); font: 13px system-ui, sans-serif; } + header { flex: 0 0 auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border); } .tabs { display: flex; gap: 6px; } - button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid #30363d; border-radius: 7px; background: #161b22; color: #e6edf3; padding: 5px 7px; cursor: pointer; } - button.selected { border-color: #58a6ff; background: #0d2847; } - .tab-badge { display: inline-block; min-width: 14px; border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } + button { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--pi-border); border-radius: 7px; background: var(--pi-surface); color: var(--pi-text); padding: 5px 7px; cursor: pointer; } + button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + .tab-badge { display: inline-block; min-width: 14px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } .panel-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } - small, .muted { color: #8b949e; } + small, .muted { color: var(--pi-muted); } header small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @media (max-width: 1180px) { .tabs { display: none; } } .workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; } .workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; } - .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: #8b949e; } - .workspace-label-link { color: #58a6ff; text-decoration: none; } + .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); } + .workspace-label-link { color: var(--pi-accent); text-decoration: none; } .workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; } - .toolbar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid #21262d; } + .toolbar { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); } .toolbar button { margin-left: auto; } - .stale { border: 1px solid #6e5200; border-radius: 999px; color: #d29922; padding: 1px 6px; font-size: 12px; } + .stale { border: 1px solid var(--pi-warning-border); border-radius: 999px; color: var(--pi-warning); padding: 1px 6px; font-size: 12px; } .split { flex: 1 1 auto; min-height: 0; display: grid; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } - .list { min-height: 0; overflow: auto; border-bottom: 1px solid #30363d; padding: 6px; } + .list { min-height: 0; overflow: auto; border-bottom: 1px solid var(--pi-border); padding: 6px; } .row { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 4px; width: 100%; border: 0; border-radius: 5px; background: transparent; text-align: left; padding: 4px 6px 4px calc(6px + var(--depth, 0) * 14px); } - .row:hover, .row.selected { background: #0d2847; } + .row:hover, .row.selected { background: var(--pi-selection-bg); } .row span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .summary { margin: 4px 6px 8px; color: #8b949e; } + .summary { margin: 4px 6px 8px; color: var(--pi-muted); } .viewer { min-height: 0; overflow: hidden; display: flex; flex-direction: column; } .diffs { flex: 1 1 auto; min-height: 0; overflow: auto; display: grid; grid-template-rows: minmax(120px, 1fr) minmax(120px, 1fr); } .diffs.single { grid-template-rows: minmax(0, 1fr); } - .diff-section { min-height: 0; display: flex; flex-direction: column; border-bottom: 1px solid #30363d; } + .diff-section { min-height: 0; display: flex; flex-direction: column; border-bottom: 1px solid var(--pi-border); } .diff-section:last-child { border-bottom: 0; } - .viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid #21262d; background: #0d1117; } + .viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); } .viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } code-viewer { flex: 1 1 auto; min-height: 0; } pre { margin: 0; padding: 10px; overflow: auto; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; } @@ -115,95 +115,97 @@ export const workspacePanelStyles = css` `; export const listStyles = css` - :host { display: block; color: #e6edf3; font: 14px system-ui, sans-serif; } + :host { display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; } :host([collapsed]) { flex: 0 0 auto; min-height: auto; overflow: hidden; } section { padding: 10px; } - h2 { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: #8b949e; font-size: 12px; text-transform: uppercase; } - button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } + h2 { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin: 0 0 8px; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } section > button { display: block; width: 100%; text-align: left; margin: 6px 0; } .subheading { margin-top: 14px; } .section-toggle { display: flex; flex: 1 1 auto; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; width: 100%; border: 0; background: transparent; color: inherit; padding: 0; font: inherit; text-transform: inherit; } .section-toggle span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .section-toggle small { display: inline; color: inherit; font-size: inherit; } .action-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; margin: 6px 0; cursor: pointer; } - .action-row:focus-visible { outline: 2px solid #58a6ff; outline-offset: 2px; border-radius: 8px; } - .action-row.selected .action-main, .action-row.selected .action-menu-toggle { border-color: #58a6ff; background: #0d2847; } - .action-row.archived .action-main { color: #8b949e; } - .action-main { box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid #30363d; border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; } - .action-row:not(.selected):hover .action-main { background: #21262d; } + .action-row:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 2px; border-radius: 8px; } + .action-row.selected .action-main, .action-row.selected .action-menu-toggle { border-color: var(--pi-accent); background: var(--pi-selection-bg); } + .action-row.archived .action-main { color: var(--pi-muted); } + .action-main { box-sizing: border-box; min-width: 0; width: 100%; border: 1px solid var(--pi-border); border-top-right-radius: 0; border-bottom-right-radius: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px 7px calc(9px + var(--depth, 0) * 16px); text-align: left; } + .action-row:not(.selected):hover .action-main { background: var(--pi-surface-hover); } .workspace-row .action-main { border-radius: 8px; } - .tree-marker { color: #6e7681; margin-right: 5px; } - .badge { display: inline-block; margin-left: 5px; border: 1px solid #30363d; border-radius: 999px; color: #8b949e; padding: 0 5px; font-size: 11px; font-weight: 400; } + .tree-marker { color: var(--pi-dim); margin-right: 5px; } + .badge { display: inline-block; margin-left: 5px; border: 1px solid var(--pi-border); border-radius: 999px; color: var(--pi-muted); padding: 0 5px; font-size: 11px; font-weight: 400; } .action-menu { position: relative; align-self: stretch; } - .action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: #8b949e; border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; } - .action-menu-toggle:hover { color: #e6edf3; background: #21262d; } - .action-menu-panel { position: fixed; z-index: 50; min-width: 120px; padding: 4px; border: 1px solid #30363d; border-radius: 8px; background: #161b22; box-shadow: 0 8px 24px #0008; } - .action-menu-panel button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: #e6edf3; } - .action-menu-panel button:hover { background: #0d2847; } - button.selected { border-color: #58a6ff; background: #0d2847; } + .action-menu-toggle { display: grid; place-items: center; height: 100%; min-width: 32px; padding: 0; color: var(--pi-muted); border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; } + .action-menu-toggle:hover { color: var(--pi-text); background: var(--pi-surface-hover); } + .action-menu-panel { position: fixed; z-index: 50; min-width: 120px; padding: 4px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 8px 24px var(--pi-shadow); } + .action-menu-panel button { display: block; width: 100%; text-align: left; border: 0; background: transparent; color: var(--pi-text); } + .action-menu-panel button:hover { background: var(--pi-selection-bg); } + button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } button:disabled { opacity: .5; cursor: not-allowed; } - small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; } .workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; } - .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: #8b949e; } - .workspace-label-link { color: #58a6ff; text-decoration: none; } + .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); } + .workspace-label-link { color: var(--pi-accent); text-decoration: none; } .workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; } `; export const chatStyles = css` - :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: #e6edf3; font: 14px system-ui, sans-serif; } + :host { display: flex; flex-direction: column; min-height: 0; overflow: hidden; color: var(--pi-text); font: 14px system-ui, sans-serif; } .chat-wrap { position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden; } .chat { height: 100%; min-height: 0; overflow: auto; overflow-anchor: none; padding: 16px 16px 64px; box-sizing: border-box; } .scroll-marker { display: block; height: 0; overflow: hidden; pointer-events: none; } - .history-indicator { position: absolute; top: 10px; right: 18px; z-index: 2; display: grid; gap: 2px; max-width: min(320px, calc(100% - 36px)); border: 1px solid #30363d; border-radius: 8px; background: #0d1117dd; color: #8b949e; padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px #0006; } - .activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid #30363d; border-radius: 999px; background: #0d1117e6; color: #8b949e; padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px #0008; backdrop-filter: blur(6px); } - .activity-dock.active { border-color: #238636; color: #3fb950; background: #0f1b12ee; } + .history-indicator { position: absolute; top: 10px; right: 18px; z-index: 2; display: grid; gap: 2px; max-width: min(320px, calc(100% - 36px)); border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg-overlay-soft); color: var(--pi-muted); padding: 6px 8px; font-size: 12px; text-align: right; pointer-events: none; box-shadow: 0 8px 24px var(--pi-shadow-soft); } + .activity-dock { position: absolute; left: 16px; right: 16px; bottom: 12px; z-index: 3; display: flex; align-items: center; gap: 8px; min-width: 0; box-sizing: border-box; border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-bg-overlay); color: var(--pi-muted); padding: 8px 12px; font-size: 13px; pointer-events: none; box-shadow: 0 8px 28px var(--pi-shadow); backdrop-filter: blur(6px); } + .activity-dock.active { border-color: var(--pi-success-border); color: var(--pi-success); background: var(--pi-success-bg-overlay); } .activity-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; } .activity-dock.active .dot { animation: pulse 1s ease-in-out infinite; opacity: 1; } - .msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; overflow: hidden; } - .msg.user { border-color: #2f81f7; background: #0d2847; } - .msg.tool { border-color: #6e5200; background: #1f1a10; color: #d29922; } - .msg.system { color: #ff7b72; } - .msg.bash { border-color: #3fb950; background: #0f1b12; } - .msg.skill { border-color: #a371f7; background: #21132f; } - .msg.event-group { padding: 0; border-color: #30363d; background: #0d1117; color: #8b949e; } - .msg.event-group > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: #8b949e; } + .msg { max-width: 100%; min-width: 0; box-sizing: border-box; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); overflow: hidden; } + .msg.user { border-color: var(--pi-accent-border); background: var(--pi-selection-bg); } + .msg.tool { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); color: var(--pi-warning); } + .msg.system { color: var(--pi-danger); } + .msg.bash { border-color: var(--pi-success); background: var(--pi-success-bg); } + .msg.skill { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); } + .msg.event-group { padding: 0; border-color: var(--pi-border); background: var(--pi-bg); color: var(--pi-muted); } + .msg.event-group.live { border-color: var(--pi-success-border); background: var(--pi-success-bg); } + .msg.event-group > summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; color: var(--pi-muted); } + .msg.event-group.live > summary { color: var(--pi-success); } .msg.event-group > summary .label { margin: 0; } .group-body { padding: 0 12px 12px; } - .group-msg { max-width: 100%; min-width: 0; box-sizing: border-box; padding: 10px 0; border-top: 1px solid #21262d; color: #e6edf3; overflow: hidden; } - .group-msg.tool { color: #d29922; } - .group-msg.system { color: #ff7b72; } - .group-msg.bash { color: #3fb950; } - .history-boundary { display: grid; gap: 3px; justify-items: center; margin: 0 0 14px; color: #8b949e; font-size: 12px; text-align: center; } - .history-load-button { border: 1px solid #30363d; border-radius: 999px; background: #161b22; color: #c9d1d9; padding: 5px 12px; font: 12px system-ui, sans-serif; cursor: pointer; } - .history-load-button:hover, .history-load-button:focus { border-color: #58a6ff; color: #f0f6fc; } + .group-msg { max-width: 100%; min-width: 0; box-sizing: border-box; padding: 10px 0; border-top: 1px solid var(--pi-border-muted); color: var(--pi-text); overflow: hidden; } + .group-msg.tool { color: var(--pi-warning); } + .group-msg.system { color: var(--pi-danger); } + .group-msg.bash { color: var(--pi-success); } + .history-boundary { display: grid; gap: 3px; justify-items: center; margin: 0 0 14px; color: var(--pi-muted); font-size: 12px; text-align: center; } + .history-load-button { border: 1px solid var(--pi-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-text-secondary); padding: 5px 12px; font: 12px system-ui, sans-serif; cursor: pointer; } + .history-load-button:hover, .history-load-button:focus { border-color: var(--pi-accent); color: var(--pi-text-bright); } .history-load-button:disabled { cursor: default; opacity: .55; } - .queued-messages { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid #6e5200; border-radius: 10px; background: #1f1a10; color: #e6edf3; overflow: hidden; } + .queued-messages { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-warning-border); border-radius: 10px; background: var(--pi-warning-surface); color: var(--pi-text); overflow: hidden; } .queued-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } - .queued-header strong { color: #d29922; } - .queued-header small { color: #8b949e; } - .queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid #30363d; } + .queued-header strong { color: var(--pi-warning); } + .queued-header small { color: var(--pi-muted); } + .queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); } .queued-message:first-of-type { padding-top: 0; border-top: 0; } - .queued-kind { color: #8b949e; font-size: 12px; text-transform: uppercase; } - .session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid #30363d; border-radius: 10px; background: #161b22; color: #e6edf3; overflow: hidden; } - .session-activity.compacting { border-color: #a371f7; background: #21132f; } - .session-activity.receiving { border-color: #238636; background: #0f1b12; } - .session-activity strong { color: #d2a8ff; } - .session-activity.receiving strong { color: #3fb950; } - .session-activity span, .session-activity small { color: #8b949e; } - .history-boundary small { color: #6e7681; } + .queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } + .session-activity { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 4px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-border); border-radius: 10px; background: var(--pi-surface); color: var(--pi-text); overflow: hidden; } + .session-activity.compacting { border-color: var(--pi-purple-border); background: var(--pi-purple-surface); } + .session-activity.receiving { border-color: var(--pi-success-border); background: var(--pi-success-bg); } + .session-activity strong { color: var(--pi-purple); } + .session-activity.receiving strong { color: var(--pi-success); } + .session-activity span, .session-activity small { color: var(--pi-muted); } + .history-boundary small { color: var(--pi-dim); } .msg-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 8px; } .msg-header-trailing { min-width: 0; display: inline-flex; align-items: baseline; justify-content: flex-end; gap: 8px; } .msg-actions { display: inline-flex; gap: 6px; opacity: 0; transition: opacity .12s ease; } - .msg-action { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #8b949e; padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } - .msg-action:hover, .msg-action:focus { color: #e6edf3; border-color: #58a6ff; } + .msg-action { display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } + .msg-action:hover, .msg-action:focus { color: var(--pi-text); border-color: var(--pi-accent); } .msg:hover > .msg-header .msg-actions, .msg:focus-within > .msg-header .msg-actions, .group-msg:hover > .msg-header .msg-actions, .group-msg:focus-within > .msg-header .msg-actions { opacity: 1; } - .label { display: block; color: #8b949e; font-size: 12px; text-transform: uppercase; } + .label { display: block; color: var(--pi-muted); font-size: 12px; text-transform: uppercase; } .msg-header .label { margin: 0; } - .msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: #6e7681; padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease, max-width .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; } + .msg-meta { min-width: 0; opacity: .28; border: 0; background: transparent; color: var(--pi-dim); padding: 0; font: 11px system-ui, sans-serif; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: opacity .12s ease, max-width .12s ease; cursor: pointer; user-select: text; -webkit-user-select: text; } .msg:hover > .msg-header .msg-meta, .msg:focus-within > .msg-header .msg-meta, .group-msg:hover > .msg-header .msg-meta, .group-msg:focus-within > .msg-header .msg-meta, .msg-meta:focus, .msg-meta.expanded { opacity: 1; } - .msg-meta:focus { outline: 1px solid #30363d; outline-offset: 3px; border-radius: 4px; } + .msg-meta:focus { outline: 1px solid var(--pi-border); outline-offset: 3px; border-radius: 4px; } @media (hover: none) { .msg-actions { opacity: 1; } .msg-meta { opacity: .75; max-width: 26px; } @@ -214,16 +216,16 @@ export const chatStyles = css` formatted-text.part { display: block; } .part { max-width: 100%; min-width: 0; box-sizing: border-box; overflow: visible; } .part + .part { margin-top: 10px; } - .tool-line { color: #d29922; } - .summary { color: #8b949e; margin-left: 6px; } - .part:is(details) { border-top: 1px solid #30363d; padding-top: 8px; } + .tool-line { color: var(--pi-warning); } + .summary { color: var(--pi-muted); margin-left: 6px; } + .part:is(details) { border-top: 1px solid var(--pi-border); padding-top: 8px; } .part > formatted-text { display: block; max-width: 100%; min-width: 0; overflow: visible; } - .skill-invocation, .skill-read { border: 1px solid #30363d; border-radius: 8px; background: #161b22; padding: 8px 10px; } - .skill-invocation > summary, .skill-read > strong { color: #d2a8ff; } - .skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: #8b949e; } - summary { cursor: pointer; color: #8b949e; } + .skill-invocation, .skill-read { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); padding: 8px 10px; } + .skill-invocation > summary, .skill-read > strong { color: var(--pi-purple); } + .skill-invocation > small, .skill-read > small { display: block; margin: 6px 0 0; color: var(--pi-muted); } + summary { cursor: pointer; color: var(--pi-muted); } pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; } - .shell-output { color: #e6edf3; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; } + .shell-output { color: var(--pi-text); font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; } @keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } } `; @@ -234,15 +236,15 @@ export const formattedTextStyles = css` :is(p, ul, ol, pre, blockquote, table, .code-block-wrapper):last-child { margin-bottom: 0; } ul, ol { padding-left: 22px; } li + li { margin-top: 3px; } - code { border: 1px solid #30363d; border-radius: 4px; background: #0d1117; padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + code { border: 1px solid var(--pi-border); border-radius: 4px; background: var(--pi-bg); padding: 1px 4px; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .code-block-wrapper { position: relative; } .code-block-wrapper pre { margin: 0; padding-right: 40px; } - pre { border: 1px solid #30363d; border-radius: 8px; background: #0d1117; padding: 10px; overflow-x: auto; overflow-y: hidden; } + pre { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); padding: 10px; overflow-x: auto; overflow-y: hidden; } pre code { border: 0; padding: 0; background: transparent; } - .code-copy-button { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #8b949e; padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } - .code-copy-button:hover, .code-copy-button:focus { color: #e6edf3; border-color: #58a6ff; } - blockquote { border-left: 3px solid #30363d; padding-left: 10px; color: #8b949e; } - a { color: #58a6ff; } + .code-copy-button { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-grid; place-items: center; width: 24px; height: 24px; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 0; font: 14px system-ui, sans-serif; line-height: 1; cursor: pointer; } + .code-copy-button:hover, .code-copy-button:focus { color: var(--pi-text); border-color: var(--pi-accent); } + blockquote { border-left: 3px solid var(--pi-border); padding-left: 10px; color: var(--pi-muted); } + a { color: var(--pi-accent); } h1, h2, h3, h4 { margin: 14px 0 8px; line-height: 1.2; } h1:first-child, h2:first-child, h3:first-child, h4:first-child { margin-top: 0; } h1 { font-size: 20px; } @@ -250,94 +252,94 @@ export const formattedTextStyles = css` h3 { font-size: 15px; } h4 { font-size: 14px; } table { border-collapse: collapse; display: block; overflow-x: auto; overflow-y: hidden; } - th, td { border: 1px solid #30363d; padding: 4px 8px; } - th { background: #161b22; } + th, td { border: 1px solid var(--pi-border); padding: 4px 8px; } + th { background: var(--pi-surface); } `; export const statusBarStyles = css` - :host { display: block; color: #8b949e; font: 12px system-ui, sans-serif; } - .bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid #30363d; background: #0d1117; white-space: nowrap; overflow: hidden; } + :host { display: block; color: var(--pi-muted); font: 12px system-ui, sans-serif; } + .bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; } span { overflow: hidden; text-overflow: ellipsis; } .workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; } .workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; } - .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: #8b949e; } - .workspace-label-link { color: #58a6ff; text-decoration: none; } + .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); } + .workspace-label-link { color: var(--pi-accent); text-decoration: none; } .workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; } .bar > span:first-child { flex: 1 1 auto; min-width: 80px; } - .activity { display: inline-flex; align-items: center; gap: 6px; color: #8b949e; } - .activity.active { color: #3fb950; } + .activity { display: inline-flex; align-items: center; gap: 6px; color: var(--pi-muted); } + .activity.active { color: var(--pi-success); } .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; } .activity.active .dot { animation: pulse 1s ease-in-out infinite; opacity: 1; } - .muted { color: #6e7681; } + .muted { color: var(--pi-dim); } @keyframes pulse { 0%, 100% { transform: scale(.75); opacity: .55; } 50% { transform: scale(1.2); opacity: 1; } } `; export const autocompleteStyles = css` :host { display: block; } - .menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 6px); z-index: 10; max-height: 260px; overflow: auto; border: 1px solid #30363d; border-radius: 8px; background: #161b22; box-shadow: 0 10px 30px #0008; } - button { display: grid; grid-template-columns: minmax(120px, 1fr) auto; gap: 4px 10px; width: 100%; border: 0; border-bottom: 1px solid #30363d; border-radius: 0; background: transparent; color: #e6edf3; padding: 8px 10px; text-align: left; cursor: pointer; } + .menu { position: absolute; left: 0; right: 0; bottom: calc(100% + 6px); z-index: 10; max-height: 260px; overflow: auto; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); box-shadow: 0 10px 30px var(--pi-shadow); } + button { display: grid; grid-template-columns: minmax(120px, 1fr) auto; gap: 4px 10px; width: 100%; border: 0; border-bottom: 1px solid var(--pi-border); border-radius: 0; background: transparent; color: var(--pi-text); padding: 8px 10px; text-align: left; cursor: pointer; } button:last-child { border-bottom: 0; } - button.selected, button:hover { background: #0d2847; } - span { color: #8b949e; font-size: 12px; } - small { grid-column: 1 / -1; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + button.selected, button:hover { background: var(--pi-selection-bg); } + span { color: var(--pi-muted); font-size: 12px; } + small { grid-column: 1 / -1; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `; export const commandPickerStyles = css` - :host { position: fixed; inset: 0; z-index: 10; color: #e6edf3; font: 14px system-ui, sans-serif; } - .backdrop { display: grid; place-items: center; width: 100%; height: 100%; background: #0008; } - section { width: min(720px, calc(100vw - 40px)); max-height: min(640px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid #30363d; border-radius: 12px; background: #0d1117; box-shadow: 0 20px 60px #000b; overflow: hidden; } - header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid #30363d; } + :host { position: fixed; inset: 0; z-index: 10; color: var(--pi-text); font: 14px system-ui, sans-serif; } + .backdrop { display: grid; place-items: center; width: 100%; height: 100%; background: var(--pi-overlay); } + section { width: min(720px, calc(100vw - 40px)); max-height: min(640px, calc(100vh - 40px)); display: flex; flex-direction: column; border: 1px solid var(--pi-border); border-radius: 12px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; } + header { display: flex; align-items: center; justify-content: space-between; padding: 12px; border-bottom: 1px solid var(--pi-border); } .options { min-height: 0; overflow: auto; outline: none; } - button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; } - header button { font-size: 20px; color: #8b949e; } - input { margin: 10px 12px; border: 1px solid #30363d; border-radius: 8px; background: #0d1117; color: #e6edf3; font: 14px system-ui, sans-serif; padding: 8px 10px; outline: none; } - input:focus { border-color: #58a6ff; } - .options button { display: block; width: 100%; padding: 10px 12px; border-bottom: 1px solid #21262d; text-align: left; } - .options button.selected, .options button:hover { background: #0d2847; } - small { display: block; margin-top: 4px; color: #8b949e; } - .empty { padding: 24px; color: #8b949e; text-align: center; } + button { border: 0; background: transparent; color: var(--pi-text); cursor: pointer; } + header button { font-size: 20px; color: var(--pi-muted); } + input { margin: 10px 12px; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-bg); color: var(--pi-text); font: 14px system-ui, sans-serif; padding: 8px 10px; outline: none; } + input:focus { border-color: var(--pi-accent); } + .options button { display: block; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; } + .options button.selected, .options button:hover { background: var(--pi-selection-bg); } + small { display: block; margin-top: 4px; color: var(--pi-muted); } + .empty { padding: 24px; color: var(--pi-muted); text-align: center; } `; export const actionPaletteStyles = css` - :host { position: fixed; inset: 0; z-index: 20; color: #e6edf3; font: 14px system-ui, sans-serif; } - .backdrop { --palette-top: min(12dvh, 90px); --palette-bottom: max(20px, env(safe-area-inset-bottom)); display: grid; align-items: start; justify-items: center; width: 100%; height: 100dvh; background: #0008; padding: var(--palette-top) 20px var(--palette-bottom); box-sizing: border-box; overflow: hidden; } - section { width: min(720px, 100%); max-height: min(640px, calc(100dvh - var(--palette-top) - var(--palette-bottom))); display: flex; flex-direction: column; border: 1px solid #30363d; border-radius: 12px; background: #0d1117; box-shadow: 0 20px 60px #000b; overflow: hidden; } - header { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-bottom: 1px solid #30363d; } - input { min-width: 0; border: 0; outline: none; background: transparent; color: #e6edf3; font: 16px system-ui, sans-serif; padding: 8px; } - input::placeholder { color: #6e7681; } - button { border: 0; background: transparent; color: #e6edf3; cursor: pointer; } - header button { color: #8b949e; font-size: 22px; padding: 2px 8px; } + :host { position: fixed; inset: 0; z-index: 20; color: var(--pi-text); font: 14px system-ui, sans-serif; } + .backdrop { --palette-top: min(12dvh, 90px); --palette-bottom: max(20px, env(safe-area-inset-bottom)); display: grid; align-items: start; justify-items: center; width: 100%; height: 100dvh; background: var(--pi-overlay); padding: var(--palette-top) 20px var(--palette-bottom); box-sizing: border-box; overflow: hidden; } + section { width: min(720px, 100%); max-height: min(640px, calc(100dvh - var(--palette-top) - var(--palette-bottom))); display: flex; flex-direction: column; border: 1px solid var(--pi-border); border-radius: 12px; background: var(--pi-bg); box-shadow: 0 20px 60px var(--pi-shadow-strong); overflow: hidden; } + header { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-bottom: 1px solid var(--pi-border); } + input { min-width: 0; border: 0; outline: none; background: transparent; color: var(--pi-text); font: 16px system-ui, sans-serif; padding: 8px; } + input::placeholder { color: var(--pi-dim); } + button { border: 0; background: transparent; color: var(--pi-text); cursor: pointer; } + header button { color: var(--pi-muted); font-size: 22px; padding: 2px 8px; } .options { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; } - .options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid #21262d; text-align: left; } - .options button.selected, .options button:hover { background: #0d2847; } + .options button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 12px; width: 100%; padding: 10px 12px; border-bottom: 1px solid var(--pi-border-muted); text-align: left; } + .options button.selected, .options button:hover { background: var(--pi-selection-bg); } .main { min-width: 0; } strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - small { display: block; color: #8b949e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + small { display: block; color: var(--pi-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .group { grid-column: 1 / -1; font-size: 12px; } - kbd { align-self: center; border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #8b949e; padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } - .empty { padding: 24px; color: #8b949e; text-align: center; } + kbd { align-self: center; border: 1px solid var(--pi-border); border-radius: 6px; background: var(--pi-surface); color: var(--pi-muted); padding: 2px 6px; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: nowrap; } + .empty { padding: 24px; color: var(--pi-muted); text-align: center; } `; export const promptEditorStyles = css` - :host { position: relative; z-index: 5; display: block; color: #e6edf3; font: 14px system-ui, sans-serif; } - footer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; padding: 12px; border-top: 1px solid #30363d; } - footer.shell-mode { border-top-color: #3fb950; background: #0f1b12; } + :host { position: relative; z-index: 5; display: block; color: var(--pi-text); font: 14px system-ui, sans-serif; } + footer { display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; padding: 12px; border-top: 1px solid var(--pi-border); } + footer.shell-mode { border-top-color: var(--pi-success); background: var(--pi-success-bg); } .editor-wrap { position: relative; min-width: 0; } .actions { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: nowrap; white-space: nowrap; } - .compact-status { display: flex; min-width: 0; align-items: center; gap: 6px; color: #8b949e; font-size: 12px; flex: 1 1 0; } + .compact-status { display: flex; min-width: 0; align-items: center; gap: 6px; color: var(--pi-muted); font-size: 12px; flex: 1 1 0; } .compact-status > button { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .select-model { max-width: min(42vw, 320px); } .select-thinking { max-width: 110px; } - textarea, .markdown-editor .cm-editor { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow: hidden; border-radius: 8px; border: 1px solid #30363d; background: #0d1117; color: #e6edf3; font: 16px/1.4 system-ui, sans-serif; } + textarea, .markdown-editor .cm-editor { box-sizing: border-box; width: 100%; min-height: 54px; max-height: 220px; resize: none; overflow: hidden; border-radius: 8px; border: 1px solid var(--pi-border); background: var(--pi-bg); color: var(--pi-text); font: 16px/1.4 system-ui, sans-serif; } textarea { overflow-y: auto; padding: 8px; } .markdown-editor .cm-scroller { max-height: 220px; overflow-y: auto; font-family: system-ui, sans-serif; line-height: 1.4; } - .markdown-editor .cm-content { min-height: 38px; padding: 8px; caret-color: #e6edf3; } + .markdown-editor .cm-content { min-height: 38px; padding: 8px; caret-color: var(--pi-text); } .markdown-editor .cm-line { padding: 0; } - .markdown-editor .cm-placeholder { color: #6e7681; } + .markdown-editor .cm-placeholder { color: var(--pi-dim); } .markdown-editor .cm-focused { outline: none; } - .shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: #3fb950; box-shadow: 0 0 0 1px #3fb95055; } - .mode-hint { position: absolute; right: 8px; bottom: 8px; max-width: calc(100% - 16px); border: 1px solid #238636; border-radius: 999px; background: #0f2a16; color: #3fb950; padding: 2px 8px; font-size: 12px; pointer-events: none; } - button { border: 1px solid #30363d; border-radius: 8px; background: #161b22; color: #e6edf3; padding: 7px 9px; cursor: pointer; } + .shell-mode textarea, .shell-mode .markdown-editor .cm-editor { border-color: var(--pi-success); box-shadow: 0 0 0 1px var(--pi-success-ring); } + .mode-hint { position: absolute; right: 8px; bottom: 8px; max-width: calc(100% - 16px); border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 2px 8px; font-size: 12px; pointer-events: none; } + button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } button:disabled, textarea:disabled, .markdown-editor-disabled .cm-editor { opacity: .5; cursor: not-allowed; } @media (max-width: 640px) { footer { gap: 8px; padding: 8px; } diff --git a/src/client/src/plugins/core/actions.ts b/src/client/src/plugins/core/actions.ts index 9af93ca..c7fb7e6 100644 --- a/src/client/src/plugins/core/actions.ts +++ b/src/client/src/plugins/core/actions.ts @@ -39,6 +39,13 @@ export function createCoreActions(): PluginAction[] { group: "General", run: (context) => context.logoutAuth(), }, + { + id: "theme.select", + title: "Select Theme", + description: "Choose the Pi Web color theme", + group: "Preferences", + run: (context) => { context.openThemePicker(); }, + }, { id: "view.chat", title: "Go to Chat", diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 57ceff6..2ed1544 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -3,7 +3,8 @@ import type { Workspace } from "../api"; import { initialAppState, type AppState } from "../appState"; import { corePlugin } from "./core"; import { PluginRegistry } from "./registry"; -import type { PluginRuntimeContext } from "./types"; +import { themePackPlugin } from "./themes"; +import type { PluginRuntimeContext, ThemeTokens } from "./types"; function createContext(statePatch: Partial = {}) { const calls: string[] = []; @@ -14,6 +15,7 @@ function createContext(statePatch: Partial = {}) { addProject: vi.fn(() => { calls.push("addProject"); }), configureAuth: vi.fn(() => { calls.push("configureAuth"); }), logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }), + openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }), selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }), selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }), refreshFiles: vi.fn(() => { calls.push("refreshFiles"); }), @@ -81,6 +83,41 @@ describe("PluginRegistry", () => { expect(calls).toEqual(["refreshGit"]); }); + it("collects built-in Pi Web themes from an in-app plugin", () => { + const registry = new 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" }, + ]); + }); + + it("collects theme contributions in contribution order", () => { + const registry = new PluginRegistry(); + registry.register({ + id: "example", + plugin: { + apiVersion: 1, + name: "Example", + activate: () => ({ + contributions: { + themes: [ + { id: "last", name: "Last", order: 20, colorScheme: "dark", tokens: testThemeTokens() }, + { id: "first", name: "First", order: 10, colorScheme: "light", tokens: testThemeTokens() }, + ], + }, + }), + }, + }); + + expect(registry.getThemes().map((theme) => ({ id: theme.id, pluginId: theme.pluginId, localId: theme.localId, name: theme.name }))).toEqual([ + { id: "example:first", pluginId: "example", localId: "first", name: "First" }, + { id: "example:last", pluginId: "example", localId: "last", name: "Last" }, + ]); + }); + it("collects workspace label items in contribution order", () => { const registry = new PluginRegistry(); const workspace = testWorkspace(); @@ -111,3 +148,43 @@ describe("PluginRegistry", () => { function testWorkspace(): Workspace { return { id: "w1", projectId: "p1", path: "/tmp/project", label: "main", isMain: true, isGitRepo: true, isGitWorktree: false }; } + +function testThemeTokens(): ThemeTokens { + return { + "--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", + }; +} diff --git a/src/client/src/plugins/registry.ts b/src/client/src/plugins/registry.ts index 6736454..26d821d 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, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types"; +import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedThemeContribution, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, ThemeContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types"; const idPattern = /^[a-z][a-z0-9.-]*$/u; const localIdPattern = /^[a-z][a-z0-9.-]*$/u; @@ -16,6 +16,7 @@ export class PluginRegistry { private readonly actions: RegisteredPluginAction[] = []; private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = []; private readonly workspaceLabels: QualifiedWorkspaceLabelContribution[] = []; + private readonly themes: QualifiedThemeContribution[] = []; private readonly pluginIds = new Set(); private readonly contributionIds = new Set(); @@ -32,6 +33,7 @@ export class PluginRegistry { for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action)); 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)); } getActions(context: PluginRuntimeContext): QualifiedPluginAction[] { @@ -56,6 +58,10 @@ export class PluginRegistry { return [...this.workspacePanels].sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.title.localeCompare(right.title)); } + getThemes(): QualifiedThemeContribution[] { + return [...this.themes].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] @@ -81,6 +87,11 @@ export class PluginRegistry { return { ...contribution, id, pluginId, localId: contribution.id }; } + private qualifyTheme(pluginId: string, theme: ThemeContribution): QualifiedThemeContribution { + const id = this.qualify(pluginId, theme.id); + return { ...theme, id, pluginId, localId: theme.id }; + } + private qualify(pluginId: string, localId: string): QualifiedContributionId { this.validateLocalId(localId); const qualified: QualifiedContributionId = `${pluginId}:${localId}`; diff --git a/src/client/src/plugins/themes/index.ts b/src/client/src/plugins/themes/index.ts new file mode 100644 index 0000000..9e03cdb --- /dev/null +++ b/src/client/src/plugins/themes/index.ts @@ -0,0 +1,150 @@ +import type { PiWebPlugin, ThemeTokens } from "../types"; + +const currentTokens = { + "--pi-bg": "#0d1117", + "--pi-surface": "#161b22", + "--pi-surface-hover": "#21262d", + "--pi-terminal-bg": "#05070a", + "--pi-terminal-text": "#e6edf3", + "--pi-border": "#30363d", + "--pi-border-muted": "#21262d", + "--pi-text": "#e6edf3", + "--pi-text-secondary": "#c9d1d9", + "--pi-text-bright": "#f0f6fc", + "--pi-muted": "#8b949e", + "--pi-dim": "#6e7681", + "--pi-accent": "#58a6ff", + "--pi-accent-border": "#2f81f7", + "--pi-selection-bg": "#0d2847", + "--pi-success": "#3fb950", + "--pi-success-border": "#238636", + "--pi-success-bg": "#0f1b12", + "--pi-success-surface": "#0f2a16", + "--pi-success-ring": "#3fb95055", + "--pi-warning": "#d29922", + "--pi-warning-border": "#6e5200", + "--pi-warning-surface": "#1f1a10", + "--pi-danger": "#ff7b72", + "--pi-purple": "#d2a8ff", + "--pi-purple-border": "#a371f7", + "--pi-purple-surface": "#21132f", + "--pi-overlay": "#0008", + "--pi-shadow-soft": "#0006", + "--pi-shadow": "#0008", + "--pi-shadow-strong": "#000b", + "--pi-bg-overlay-soft": "#0d1117dd", + "--pi-bg-overlay": "#0d1117e6", + "--pi-success-bg-overlay": "#0f1b12ee", + "--pi-terminal-selection": "#264f78", +} satisfies ThemeTokens; + +const docsDarkTokens = { + "--pi-bg": "#070912", + "--pi-surface": "#101527", + "--pi-surface-hover": "#151b31", + "--pi-terminal-bg": "#050710", + "--pi-terminal-text": "#f7f4ff", + "--pi-border": "#26304f", + "--pi-border-muted": "#26304f", + "--pi-text": "#f7f4ff", + "--pi-text-secondary": "#aaa4bd", + "--pi-text-bright": "#ffffff", + "--pi-muted": "#aaa4bd", + "--pi-dim": "#817a99", + "--pi-accent": "#7c3cff", + "--pi-accent-border": "#3d4a78", + "--pi-selection-bg": "#151b31", + "--pi-success": "#00f0d8", + "--pi-success-border": "#00f0d8", + "--pi-success-bg": "#071e22", + "--pi-success-surface": "#092d31", + "--pi-success-ring": "#00f0d855", + "--pi-warning": "#ffb000", + "--pi-warning-border": "#ffb000", + "--pi-warning-surface": "#16140d", + "--pi-danger": "#ff4f7b", + "--pi-purple": "#b7a2ff", + "--pi-purple-border": "#7c3cff", + "--pi-purple-surface": "#181026", + "--pi-overlay": "#0008", + "--pi-shadow-soft": "#0006", + "--pi-shadow": "#0008", + "--pi-shadow-strong": "#000b", + "--pi-bg-overlay-soft": "#070912dd", + "--pi-bg-overlay": "#070912e6", + "--pi-success-bg-overlay": "#071e22ee", + "--pi-terminal-selection": "#3d4a78", +} satisfies ThemeTokens; + +const docsLightTokens = { + "--pi-bg": "#f7f1e6", + "--pi-surface": "#fff9ee", + "--pi-surface-hover": "#f0e6d6", + "--pi-terminal-bg": "#15131b", + "--pi-terminal-text": "#fff9ee", + "--pi-border": "#c9bca8", + "--pi-border-muted": "#d8cdbc", + "--pi-text": "#15131b", + "--pi-text-secondary": "#15131b", + "--pi-text-bright": "#15131b", + "--pi-muted": "#5f586a", + "--pi-dim": "#766f7e", + "--pi-accent": "#6430d8", + "--pi-accent-border": "#6430d8", + "--pi-selection-bg": "#eadff8", + "--pi-success": "#008c82", + "--pi-success-border": "#008c82", + "--pi-success-bg": "#e1f4ef", + "--pi-success-surface": "#d7f0ec", + "--pi-success-ring": "#008c8255", + "--pi-warning": "#b05f00", + "--pi-warning-border": "#b05f00", + "--pi-warning-surface": "#fff2d2", + "--pi-danger": "#b51d49", + "--pi-purple": "#6430d8", + "--pi-purple-border": "#6430d8", + "--pi-purple-surface": "#eadff8", + "--pi-overlay": "#15131b66", + "--pi-shadow-soft": "#15131b22", + "--pi-shadow": "#15131b33", + "--pi-shadow-strong": "#15131b44", + "--pi-bg-overlay-soft": "#f7f1e6dd", + "--pi-bg-overlay": "#f7f1e6e6", + "--pi-success-bg-overlay": "#e1f4efee", + "--pi-terminal-selection": "#8d7b64", +} satisfies ThemeTokens; + +export const themePackPlugin: PiWebPlugin = { + apiVersion: 1, + name: "Pi Web Themes", + activate: () => ({ + contributions: { + themes: [ + { + id: "current", + name: "Pi Web Current", + description: "The original Pi Web dark palette.", + order: 10, + colorScheme: "dark", + tokens: currentTokens, + }, + { + id: "docs-dark", + name: "Docs Dark", + description: "Dark theme based on the Pi Web docs site.", + order: 20, + colorScheme: "dark", + tokens: docsDarkTokens, + }, + { + id: "docs-light", + name: "Docs Light", + description: "Light theme based on the Pi Web docs site.", + order: 30, + colorScheme: "light", + tokens: docsLightTokens, + }, + ], + }, + }), +}; diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index a583fd8..b0dc1b4 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -33,6 +33,7 @@ export interface PluginContributions { actions?: PluginAction[]; workspacePanels?: WorkspacePanelContribution[]; workspaceLabels?: WorkspaceLabelContribution[]; + themes?: ThemeContribution[]; } export interface PluginRuntimeContext { @@ -42,6 +43,7 @@ export interface PluginRuntimeContext { addProject: () => void | Promise; configureAuth: () => void | Promise; logoutAuth: () => void | Promise; + openThemePicker: () => void; selectMainView: (view: AppState["mainView"]) => void; selectWorkspaceTool: (tool: QualifiedContributionId) => void; refreshFiles: () => void | Promise; @@ -140,6 +142,62 @@ export interface WorkspaceLabelContribution { items: (context: WorkspaceLabelContext) => WorkspaceLabelItem[]; } +export type ThemeColorScheme = "dark" | "light"; + +export type ThemeToken = + | "--pi-bg" + | "--pi-surface" + | "--pi-surface-hover" + | "--pi-terminal-bg" + | "--pi-terminal-text" + | "--pi-border" + | "--pi-border-muted" + | "--pi-text" + | "--pi-text-secondary" + | "--pi-text-bright" + | "--pi-muted" + | "--pi-dim" + | "--pi-accent" + | "--pi-accent-border" + | "--pi-selection-bg" + | "--pi-success" + | "--pi-success-border" + | "--pi-success-bg" + | "--pi-success-surface" + | "--pi-success-ring" + | "--pi-warning" + | "--pi-warning-border" + | "--pi-warning-surface" + | "--pi-danger" + | "--pi-purple" + | "--pi-purple-border" + | "--pi-purple-surface" + | "--pi-overlay" + | "--pi-shadow-soft" + | "--pi-shadow" + | "--pi-shadow-strong" + | "--pi-bg-overlay-soft" + | "--pi-bg-overlay" + | "--pi-success-bg-overlay" + | "--pi-terminal-selection"; + +export type ThemeTokens = Record; + +export interface ThemeContribution { + id: LocalContributionId; + name: string; + description?: string; + order?: number; + colorScheme: ThemeColorScheme; + tokens: ThemeTokens; +} + +export interface QualifiedThemeContribution extends ThemeContribution { + id: QualifiedContributionId; + pluginId: PluginId; + localId: LocalContributionId; +} + export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContribution { id: QualifiedContributionId; pluginId: PluginId; diff --git a/src/client/src/theme.ts b/src/client/src/theme.ts new file mode 100644 index 0000000..f3a3fa9 --- /dev/null +++ b/src/client/src/theme.ts @@ -0,0 +1,74 @@ +import type { QualifiedContributionId, QualifiedThemeContribution, ThemeToken } from "./plugins/types"; + +export const DEFAULT_THEME_ID: QualifiedContributionId = "themes:current"; +export const THEME_STORAGE_KEY = "pi-web-app-theme"; + +export const THEME_TOKENS: ThemeToken[] = [ + "--pi-bg", + "--pi-surface", + "--pi-surface-hover", + "--pi-terminal-bg", + "--pi-terminal-text", + "--pi-border", + "--pi-border-muted", + "--pi-text", + "--pi-text-secondary", + "--pi-text-bright", + "--pi-muted", + "--pi-dim", + "--pi-accent", + "--pi-accent-border", + "--pi-selection-bg", + "--pi-success", + "--pi-success-border", + "--pi-success-bg", + "--pi-success-surface", + "--pi-success-ring", + "--pi-warning", + "--pi-warning-border", + "--pi-warning-surface", + "--pi-danger", + "--pi-purple", + "--pi-purple-border", + "--pi-purple-surface", + "--pi-overlay", + "--pi-shadow-soft", + "--pi-shadow", + "--pi-shadow-strong", + "--pi-bg-overlay-soft", + "--pi-bg-overlay", + "--pi-success-bg-overlay", + "--pi-terminal-selection", +]; + +const qualifiedContributionIdPattern = /^[a-z][a-z0-9.-]*:[a-z][a-z0-9.-]*$/u; + +export function readStoredThemeId(): QualifiedContributionId | undefined { + try { + const value = window.localStorage.getItem(THEME_STORAGE_KEY); + return isQualifiedContributionId(value) ? value : undefined; + } catch { + return undefined; + } +} + +export function applyPiWebTheme(theme: QualifiedThemeContribution, options: { persist?: boolean } = {}): void { + const root = document.documentElement; + root.dataset["piWebTheme"] = theme.id; + root.style.colorScheme = theme.colorScheme; + for (const token of THEME_TOKENS) { + const value = theme.tokens[token]; + if (typeof value === "string" && value !== "") root.style.setProperty(token, value); + else root.style.removeProperty(token); + } + if (options.persist === false) return; + try { + window.localStorage.setItem(THEME_STORAGE_KEY, theme.id); + } catch { + // Ignore storage failures; the selected theme can still apply for this tab. + } +} + +function isQualifiedContributionId(value: string | null): value is QualifiedContributionId { + return value !== null && qualifiedContributionIdPattern.test(value); +}