summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/theme/loader.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/ui/src/theme/loader.ts')
-rw-r--r--packages/ui/src/theme/loader.ts213
1 files changed, 213 insertions, 0 deletions
diff --git a/packages/ui/src/theme/loader.ts b/packages/ui/src/theme/loader.ts
new file mode 100644
index 000000000..b25c833dd
--- /dev/null
+++ b/packages/ui/src/theme/loader.ts
@@ -0,0 +1,213 @@
+/**
+ * Theme loader - loads theme JSON files and applies them to the DOM.
+ */
+
+import type { DesktopTheme, ResolvedTheme } from "./types"
+import { resolveThemeVariant, themeToCss } from "./resolve"
+
+/** Currently active theme */
+let activeTheme: DesktopTheme | null = null
+
+const THEME_STYLE_ID = "opencode-theme"
+
+function ensureLoaderStyleElement(): HTMLStyleElement {
+ const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null
+ if (existing) {
+ return existing
+ }
+ const element = document.createElement("style")
+ element.id = THEME_STYLE_ID
+ document.head.appendChild(element)
+ return element
+}
+
+/**
+ * Load and apply a theme to the document.
+ * Creates or updates a <style> element with the theme's CSS custom properties.
+ *
+ * @param theme - The desktop theme to apply
+ * @param themeId - Optional theme ID for the data-theme attribute
+ */
+export function applyTheme(theme: DesktopTheme, themeId?: string): void {
+ activeTheme = theme
+
+ // Resolve both variants
+ const lightTokens = resolveThemeVariant(theme.light, false)
+ const darkTokens = resolveThemeVariant(theme.dark, true)
+
+ const targetThemeId = themeId ?? theme.id
+
+ // Build the CSS
+ const css = buildThemeCss(lightTokens, darkTokens, targetThemeId)
+
+ const themeStyleElement = ensureLoaderStyleElement()
+ themeStyleElement.textContent = css
+
+ document.documentElement.setAttribute("data-theme", targetThemeId)
+}
+
+/**
+ * Build CSS string from resolved theme tokens
+ */
+function buildThemeCss(light: ResolvedTheme, dark: ResolvedTheme, themeId: string): string {
+ const isDefaultTheme = themeId === "oc-1"
+
+ // Static tokens that don't change between themes
+ const staticTokens = `
+ --font-family-sans: "Inter", "Inter Fallback";
+ --font-family-sans--font-feature-settings: "ss03" 1;
+ --font-family-mono: "IBM Plex Mono", "IBM Plex Mono Fallback";
+ --font-family-mono--font-feature-settings: "ss01" 1;
+
+ --font-size-small: 13px;
+ --font-size-base: 14px;
+ --font-size-large: 16px;
+ --font-size-x-large: 20px;
+ --font-weight-regular: 400;
+ --font-weight-medium: 500;
+ --line-height-large: 150%;
+ --line-height-x-large: 180%;
+ --line-height-2x-large: 200%;
+ --letter-spacing-normal: 0;
+ --letter-spacing-tight: -0.1599999964237213;
+ --letter-spacing-tightest: -0.3199999928474426;
+ --paragraph-spacing-base: 0;
+
+ --spacing: 0.25rem;
+
+ --breakpoint-sm: 40rem;
+ --breakpoint-md: 48rem;
+ --breakpoint-lg: 64rem;
+ --breakpoint-xl: 80rem;
+ --breakpoint-2xl: 96rem;
+
+ --container-3xs: 16rem;
+ --container-2xs: 18rem;
+ --container-xs: 20rem;
+ --container-sm: 24rem;
+ --container-md: 28rem;
+ --container-lg: 32rem;
+ --container-xl: 36rem;
+ --container-2xl: 42rem;
+ --container-3xl: 48rem;
+ --container-4xl: 56rem;
+ --container-5xl: 64rem;
+ --container-6xl: 72rem;
+ --container-7xl: 80rem;
+
+ --radius-xs: 0.125rem;
+ --radius-sm: 0.25rem;
+ --radius-md: 0.375rem;
+ --radius-lg: 0.5rem;
+ --radius-xl: 0.625rem;
+
+ --shadow-xs:
+ 0 1px 2px -1px rgba(19, 16, 16, 0.04), 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08);
+ --shadow-md:
+ 0 6px 8px -4px rgba(19, 16, 16, 0.12), 0 4px 3px -2px rgba(19, 16, 16, 0.12), 0 1px 2px -1px rgba(19, 16, 16, 0.12);
+ --shadow-xs-border:
+ 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.04),
+ 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08);
+ --shadow-xs-border-base:
+ 0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)), 0 1px 2px -1px rgba(19, 16, 16, 0.04),
+ 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08);
+ --shadow-xs-border-select:
+ 0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)),
+ 0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
+ 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12);
+ --shadow-xs-border-focus:
+ 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
+ 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0),
+ 0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99));`
+
+ const lightCss = themeToCss(light)
+ const darkCss = themeToCss(dark)
+
+ // For the default theme, we use :root directly
+ // For named themes, we use the [data-theme] selector
+ if (isDefaultTheme) {
+ return `
+:root {
+ ${staticTokens}
+
+ color-scheme: light;
+ --text-mix-blend-mode: multiply;
+
+ ${lightCss}
+
+ @media (prefers-color-scheme: dark) {
+ color-scheme: dark;
+ --text-mix-blend-mode: plus-lighter;
+
+ ${darkCss}
+ }
+}
+`
+ }
+
+ return `
+html[data-theme="${themeId}"] {
+ ${staticTokens}
+
+ color-scheme: light;
+ --text-mix-blend-mode: multiply;
+
+ ${lightCss}
+
+ @media (prefers-color-scheme: dark) {
+ color-scheme: dark;
+ --text-mix-blend-mode: plus-lighter;
+
+ ${darkCss}
+ }
+}
+`
+}
+
+/**
+ * Load a theme from a JSON file URL
+ */
+export async function loadThemeFromUrl(url: string): Promise<DesktopTheme> {
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error(`Failed to load theme from ${url}: ${response.statusText}`)
+ }
+ return response.json()
+}
+
+/**
+ * Get the currently active theme
+ */
+export function getActiveTheme(): DesktopTheme | null {
+ const activeId = document.documentElement.getAttribute("data-theme")
+ if (!activeId) {
+ return null
+ }
+ if (activeTheme?.id === activeId) {
+ return activeTheme
+ }
+ return null
+}
+
+/**
+ * Remove the current theme and clean up
+ */
+export function removeTheme(): void {
+ activeTheme = null
+ const existingElement = document.getElementById(THEME_STYLE_ID)
+ if (existingElement) {
+ existingElement.remove()
+ }
+ document.documentElement.removeAttribute("data-theme")
+}
+
+/**
+ * Force a specific color scheme (light/dark) regardless of system preference
+ */
+export function setColorScheme(scheme: "light" | "dark" | "auto"): void {
+ if (scheme === "auto") {
+ document.documentElement.style.removeProperty("color-scheme")
+ } else {
+ document.documentElement.style.setProperty("color-scheme", scheme)
+ }
+}