diff --git a/packages/app/index.html b/packages/app/index.html index 2c3a0eabd4..4f003c05bc 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -1,5 +1,5 @@ - + @@ -13,14 +13,12 @@ + + -
diff --git a/packages/app/script/inject-theme-preload.ts b/packages/app/script/inject-theme-preload.ts new file mode 100644 index 0000000000..511ab7a3b5 --- /dev/null +++ b/packages/app/script/inject-theme-preload.ts @@ -0,0 +1,18 @@ +/** + * Injects the theme preload script into index.html. + * Run this as part of the build process. + */ + +import { generatePreloadScript } from "@opencode-ai/ui/theme" + +const htmlPath = new URL("../index.html", import.meta.url).pathname +const html = await Bun.file(htmlPath).text() + +const script = generatePreloadScript() +const injectedHtml = html.replace( + /`, +) + +await Bun.write(htmlPath, injectedHtml) +console.log("Injected theme preload script into index.html") diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index de8fcf7d12..bf5ba95662 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -8,6 +8,7 @@ import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { CodeComponentProvider } from "@opencode-ai/ui/context/code" import { Diff } from "@opencode-ai/ui/diff" import { Code } from "@opencode-ai/ui/code" +import { ThemeProvider } from "@opencode-ai/ui/theme" import { GlobalSyncProvider } from "@/context/global-sync" import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" @@ -45,48 +46,50 @@ export function App() { return ( - }> - - - - - - - - - ( - - {props.children} - - )} - > - - - } /> - ( - - - - - - - - )} - /> - - - - - - - - - - - + + }> + + + + + + + + + ( + + {props.children} + + )} + > + + + } /> + ( + + + + + + + + )} + /> + + + + + + + + + + + + ) } diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 1d6bab2a44..03251fe5f5 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,9 +1,9 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" +import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/terminal" -import { usePrefersDark } from "@solid-primitives/media" +import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY @@ -12,8 +12,28 @@ export interface TerminalProps extends ComponentProps<"div"> { onConnectError?: (error: unknown) => void } +type TerminalColors = { + background: string + foreground: string + cursor: string +} + +const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = { + light: { + background: "#fcfcfc", + foreground: "#211e1e", + cursor: "#211e1e", + }, + dark: { + background: "#191515", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + }, +} + export const Terminal = (props: TerminalProps) => { const sdk = useSDK() + const theme = useTheme() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) let ws: WebSocket @@ -22,7 +42,35 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void - const prefersDark = usePrefersDark() + + const getTerminalColors = (): TerminalColors => { + const mode = theme.mode() + const fallback = DEFAULT_TERMINAL_COLORS[mode] + const currentTheme = theme.themes()[theme.themeId()] + if (!currentTheme) return fallback + const variant = mode === "dark" ? currentTheme.dark : currentTheme.light + if (!variant?.seeds) return fallback + const resolved = resolveThemeVariant(variant, mode === "dark") + const text = resolved["text-base"] ?? fallback.foreground + const background = resolved["background-stronger"] ?? fallback.background + return { + background, + foreground: text, + cursor: text, + } + } + + const [terminalColors, setTerminalColors] = createSignal(getTerminalColors()) + + createEffect(() => { + const colors = getTerminalColors() + setTerminalColors(colors) + if (!term) return + const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption + if (!setOption) return + setOption("theme", colors) + }) + const focusTerminal = () => term?.focus() const copySelection = () => { if (!term || !term.hasSelection()) return false @@ -62,17 +110,7 @@ export const Terminal = (props: TerminalProps) => { fontSize: 14, fontFamily: "IBM Plex Mono, monospace", allowTransparency: true, - theme: prefersDark() - ? { - background: "#191515", - foreground: "#d4d4d4", - cursor: "#d4d4d4", - } - : { - background: "#fcfcfc", - foreground: "#211e1e", - cursor: "#211e1e", - }, + theme: terminalColors(), scrollback: 10_000, ghostty, }) @@ -192,6 +230,7 @@ export const Terminal = (props: TerminalProps) => { ref={container} data-component="terminal" data-prevent-autofocus + style={{ "background-color": terminalColors().background }} classList={{ ...(local.classList ?? {}), "size-full px-6 py-3 font-mono": true, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 0b4e040b74..08b3401871 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -47,6 +47,7 @@ import { useNotification } from "@/context/notification" import { Binary } from "@opencode-ai/util/binary" import { Header } from "@/components/header" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { DialogSelectProvider } from "@/components/dialog-select-provider" import { useCommand } from "@/context/command" import { ConstrainDragXAxis } from "@/utils/solid-dnd" @@ -89,6 +90,41 @@ export default function Layout(props: ParentProps) { const providers = useProviders() const dialog = useDialog() const command = useCommand() + const theme = useTheme() + const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] + const colorSchemeLabel: Record = { + system: "System", + light: "Light", + dark: "Dark", + } + + function cycleTheme(direction = 1) { + const ids = availableThemeEntries().map(([id]) => id) + if (ids.length === 0) return + const currentIndex = ids.indexOf(theme.themeId()) + const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length + const nextThemeId = ids[nextIndex] + theme.setTheme(nextThemeId) + const nextTheme = theme.themes()[nextThemeId] + showToast({ + title: "Theme switched", + description: nextTheme?.name ?? nextThemeId, + }) + } + + function cycleColorScheme(direction = 1) { + const current = theme.colorScheme() + const currentIndex = colorSchemeOrder.indexOf(current) + const nextIndex = + currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length + const next = colorSchemeOrder[nextIndex] + theme.setColorScheme(next) + showToast({ + title: "Color scheme", + description: colorSchemeLabel[next], + }) + } onMount(async () => { if (platform.checkUpdate && platform.update && platform.restart) { @@ -286,57 +322,94 @@ export default function Layout(props: ParentProps) { } } - command.register(() => [ - { - id: "sidebar.toggle", - title: "Toggle sidebar", - category: "View", - keybind: "mod+b", - onSelect: () => layout.sidebar.toggle(), - }, - ...(platform.openDirectoryPickerDialog - ? [ - { - id: "project.open", - title: "Open project", - category: "Project", - keybind: "mod+o", - onSelect: () => chooseProject(), - }, - ] - : []), - { - id: "provider.connect", - title: "Connect provider", - category: "Provider", - onSelect: () => connectProvider(), - }, - { - id: "session.previous", - title: "Previous session", - category: "Session", - keybind: "alt+arrowup", - onSelect: () => navigateSessionByOffset(-1), - }, - { - id: "session.next", - title: "Next session", - category: "Session", - keybind: "alt+arrowdown", - onSelect: () => navigateSessionByOffset(1), - }, - { - id: "session.archive", - title: "Archive session", - category: "Session", - keybind: "mod+shift+backspace", - disabled: !params.dir || !params.id, - onSelect: () => { - const session = currentSessions().find((s) => s.id === params.id) - if (session) archiveSession(session) + command.register(() => { + const commands = [ + { + id: "sidebar.toggle", + title: "Toggle sidebar", + category: "View", + keybind: "mod+b", + onSelect: () => layout.sidebar.toggle(), }, - }, - ]) + ...(platform.openDirectoryPickerDialog + ? [ + { + id: "project.open", + title: "Open project", + category: "Project", + keybind: "mod+o", + onSelect: () => chooseProject(), + }, + ] + : []), + { + id: "provider.connect", + title: "Connect provider", + category: "Provider", + onSelect: () => connectProvider(), + }, + { + id: "session.previous", + title: "Previous session", + category: "Session", + keybind: "alt+arrowup", + onSelect: () => navigateSessionByOffset(-1), + }, + { + id: "session.next", + title: "Next session", + category: "Session", + keybind: "alt+arrowdown", + onSelect: () => navigateSessionByOffset(1), + }, + { + id: "session.archive", + title: "Archive session", + category: "Session", + keybind: "mod+shift+backspace", + disabled: !params.dir || !params.id, + onSelect: () => { + const session = currentSessions().find((s) => s.id === params.id) + if (session) archiveSession(session) + }, + }, + { + id: "theme.cycle", + title: "Cycle theme", + category: "Theme", + keybind: "mod+shift+t", + onSelect: () => cycleTheme(1), + }, + ] + + for (const [id, definition] of availableThemeEntries()) { + commands.push({ + id: `theme.set.${id}`, + title: `Use theme: ${definition.name ?? id}`, + category: "Theme", + onSelect: () => theme.setTheme(id), + }) + } + + commands.push({ + id: "theme.scheme.cycle", + title: "Cycle color scheme", + category: "Theme", + keybind: "mod+shift+s", + onSelect: () => cycleColorScheme(1), + }) + + for (const scheme of colorSchemeOrder) { + commands.push({ + id: `theme.scheme.${scheme}`, + title: `Use color scheme: ${colorSchemeLabel[scheme]}`, + category: "Theme", + onSelect: () => theme.setColorScheme(scheme), + }) + } + + return commands + }) function connectProvider() { dialog.show(() => ) diff --git a/packages/app/vite.js b/packages/app/vite.js index 6b8fd61376..5ab3688365 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,6 +1,23 @@ import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" +import { generatePreloadScript } from "@opencode-ai/ui/theme" + +/** + * Vite plugin that injects the theme preload script into index.html. + * This ensures the theme is applied before the page renders, avoiding FOUC. + * @type {import("vite").Plugin} + */ +const themePreloadPlugin = { + name: "opencode-desktop:theme-preload", + transformIndexHtml(html) { + const script = generatePreloadScript() + return html.replace( + /`, + ) + }, +} /** * @type {import("vite").PluginOption} @@ -21,6 +38,7 @@ export default [ } }, }, + themePreloadPlugin, tailwindcss(), solidPlugin(), ] diff --git a/packages/desktop/index.html b/packages/desktop/index.html index faeb1a1fde..ea656068ca 100644 --- a/packages/desktop/index.html +++ b/packages/desktop/index.html @@ -1,5 +1,5 @@ - + @@ -13,14 +13,12 @@ + + -
diff --git a/packages/ui/package.json b/packages/ui/package.json index bb6adb0fb7..0aa059cd97 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -11,6 +11,9 @@ "./context/*": "./src/context/*.tsx", "./styles": "./src/styles/index.css", "./styles/tailwind": "./src/styles/tailwind/index.css", + "./theme": "./src/theme/index.ts", + "./theme/*": "./src/theme/*.ts", + "./theme/context": "./src/theme/context.tsx", "./icons/provider": "./src/components/provider-icons/types.ts", "./icons/file-type": "./src/components/file-icons/types.ts", "./fonts/*": "./src/assets/fonts/*", diff --git a/packages/ui/script/generate-preload.ts b/packages/ui/script/generate-preload.ts new file mode 100644 index 0000000000..180b30564e --- /dev/null +++ b/packages/ui/script/generate-preload.ts @@ -0,0 +1,16 @@ +/** + * Generates the theme preload script content. + * Run this to get the script that should be embedded in index.html. + */ + +import { generatePreloadScript, generatePreloadScriptFormatted } from "../src/theme/preload" + +const formatted = process.argv.includes("--formatted") + +if (formatted) { + console.log("") +} else { + console.log("") +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index a8a9e6a31e..92323876ca 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -137,6 +137,9 @@ [data-slot="message-part-tool-error-message"] { color: var(--text-on-critical-weak); + max-height: 240px; + overflow-y: auto; + word-break: break-word; } } diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 1748feab96..599116d28a 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -343,6 +343,8 @@ .error-card { color: var(--text-on-critical-base); + max-height: 240px; + overflow-y: auto; } [data-slot="session-turn-collapsible-content-inner"] { diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 6584a5e648..e8de4a06cb 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -68,987 +68,8 @@ color-scheme: light; --text-mix-blend-mode: multiply; - /* OC-1-light */ - --background-base: #f8f7f7; - --background-weak: var(--smoke-light-3); - --background-strong: var(--smoke-light-1); - --background-stronger: #fcfcfc; - --surface-base: var(--smoke-light-alpha-2); - --base: var(--smoke-light-alpha-2); - --surface-base-hover: #0500000f; - --surface-base-active: var(--smoke-light-alpha-3); - --surface-base-interactive-active: var(--cobalt-light-alpha-3); - --base2: var(--smoke-light-alpha-2); - --base3: var(--smoke-light-alpha-2); - --surface-inset-base: var(--smoke-light-alpha-2); - --surface-inset-base-hover: var(--smoke-light-alpha-3); - --surface-inset-strong: #1f000017; - --surface-inset-strong-hover: #1f000017; - --surface-raised-base: var(--smoke-light-alpha-1); - --surface-float-base: var(--smoke-dark-1); - --surface-float-base-hover: var(--smoke-dark-2); - --surface-raised-base-hover: var(--smoke-light-alpha-2); - --surface-raised-base-active: var(--smoke-light-alpha-3); - --surface-raised-strong: var(--smoke-light-1); - --surface-raised-strong-hover: var(--white); - --surface-raised-stronger: var(--white); - --surface-raised-stronger-hover: var(--white); - --surface-weak: var(--smoke-light-alpha-3); - --surface-weaker: var(--smoke-light-alpha-4); - --surface-strong: #ffffff; - --surface-raised-stronger-non-alpha: var(--white); - --surface-brand-base: var(--yuzu-light-9); - --surface-brand-hover: var(--yuzu-light-10); - --surface-interactive-base: var(--cobalt-light-3); - --surface-interactive-hover: var(--cobalt-light-4); - --surface-interactive-weak: var(--cobalt-light-2); - --surface-interactive-weak-hover: var(--cobalt-light-3); - --surface-success-base: var(--apple-light-3); - --surface-success-weak: var(--apple-light-2); - --surface-success-strong: var(--apple-light-9); - --surface-warning-base: var(--solaris-light-3); - --surface-warning-weak: var(--solaris-light-2); - --surface-warning-strong: var(--solaris-light-9); - --surface-critical-base: var(--ember-light-3); - --surface-critical-weak: var(--ember-light-2); - --surface-critical-strong: var(--ember-light-9); - --surface-info-base: var(--lilac-light-3); - --surface-info-weak: var(--lilac-light-2); - --surface-info-strong: var(--lilac-light-9); - --surface-diff-unchanged-base: #ffffff00; - --surface-diff-skip-base: var(--smoke-light-2); - --surface-diff-hidden-base: var(--blue-light-3); - --surface-diff-hidden-weak: var(--blue-light-2); - --surface-diff-hidden-weaker: var(--blue-light-1); - --surface-diff-hidden-strong: var(--blue-light-5); - --surface-diff-hidden-stronger: var(--blue-light-9); - --surface-diff-add-base: #dafbe0; - --surface-diff-add-weak: var(--mint-light-2); - --surface-diff-add-weaker: var(--mint-light-1); - --surface-diff-add-strong: var(--mint-light-5); - --surface-diff-add-stronger: var(--mint-light-9); - --surface-diff-delete-base: var(--ember-light-3); - --surface-diff-delete-weak: var(--ember-light-2); - --surface-diff-delete-weaker: var(--ember-light-1); - --surface-diff-delete-strong: var(--ember-light-6); - --surface-diff-delete-stronger: var(--ember-light-9); - --input-base: var(--smoke-light-1); - --input-hover: var(--smoke-light-2); - --input-active: var(--cobalt-light-1); - --input-selected: var(--cobalt-light-4); - --input-focus: var(--cobalt-light-1); - --input-disabled: var(--smoke-light-4); - --text-base: var(--smoke-light-11); - --text-weak: var(--smoke-light-9); - --text-weaker: var(--smoke-light-8); - --text-strong: var(--smoke-light-12); - --text-invert-base: var(--smoke-dark-alpha-11); - --text-invert-weak: var(--smoke-dark-alpha-9); - --text-invert-weaker: var(--smoke-dark-alpha-8); - --text-invert-strong: var(--smoke-dark-alpha-12); - --text-interactive-base: var(--cobalt-light-9); - --text-on-brand-base: var(--smoke-light-alpha-11); - --text-on-interactive-base: var(--smoke-light-1); - --text-on-interactive-weak: var(--smoke-dark-alpha-11); - --text-on-success-base: var(--apple-light-10); - --text-on-critical-base: var(--ember-light-10); - --text-on-critical-weak: var(--ember-light-8); - --text-on-critical-strong: var(--ember-light-12); - --text-on-warning-base: var(--smoke-dark-alpha-11); - --text-on-info-base: var(--smoke-dark-alpha-11); - --text-diff-add-base: var(--mint-light-11); - --text-diff-delete-base: var(--ember-light-10); - --text-diff-delete-strong: var(--ember-light-12); - --text-diff-add-strong: var(--mint-light-12); - --text-on-info-weak: var(--smoke-dark-alpha-9); - --text-on-info-strong: var(--smoke-dark-alpha-12); - --text-on-warning-weak: var(--smoke-dark-alpha-9); - --text-on-warning-strong: var(--smoke-dark-alpha-12); - --text-on-success-weak: var(--apple-light-6); - --text-on-success-strong: var(--apple-light-12); - --text-on-brand-weak: var(--smoke-light-alpha-9); - --text-on-brand-weaker: var(--smoke-light-alpha-8); - --text-on-brand-strong: var(--smoke-light-alpha-12); - --button-secondary-base: #fdfcfc; - --button-secondary-hover: #faf9f9; - --border-base: var(--smoke-light-alpha-7); - --border-hover: var(--smoke-light-alpha-8); - --border-active: var(--smoke-light-alpha-9); - --border-selected: var(--cobalt-light-alpha-9); - --border-disabled: var(--smoke-light-alpha-8); - --border-focus: var(--smoke-light-alpha-9); - --border-weak-base: var(--smoke-light-alpha-5); - --border-strong-base: var(--smoke-light-alpha-7); - --border-strong-hover: var(--smoke-light-alpha-8); - --border-strong-active: var(--smoke-light-alpha-7); - --border-strong-selected: var(--cobalt-light-alpha-6); - --border-strong-disabled: var(--smoke-light-alpha-6); - --border-strong-focus: var(--smoke-light-alpha-7); - --border-weak-hover: var(--smoke-light-alpha-6); - --border-weak-active: var(--smoke-light-alpha-7); - --border-weak-selected: var(--cobalt-light-alpha-5); - --border-weak-disabled: var(--smoke-light-alpha-6); - --border-weak-focus: var(--smoke-light-alpha-7); - --border-interactive-base: var(--cobalt-light-7); - --border-interactive-hover: var(--cobalt-light-8); - --border-interactive-active: var(--cobalt-light-9); - --border-interactive-selected: var(--cobalt-light-9); - --border-interactive-disabled: var(--smoke-light-8); - --border-interactive-focus: var(--cobalt-light-9); - --border-success-base: var(--apple-light-6); - --border-success-hover: var(--apple-light-7); - --border-success-selected: var(--apple-light-9); - --border-warning-base: var(--solaris-light-6); - --border-warning-hover: var(--solaris-light-7); - --border-warning-selected: var(--solaris-light-9); - --border-critical-base: var(--ember-light-6); - --border-critical-hover: var(--ember-light-7); - --border-critical-selected: var(--ember-light-9); - --border-info-base: var(--lilac-light-6); - --border-info-hover: var(--lilac-light-7); - --border-info-selected: var(--lilac-light-9); - --icon-base: var(--smoke-light-9); - --icon-hover: var(--smoke-light-11); - --icon-active: var(--smoke-light-12); - --icon-selected: var(--smoke-light-12); - --icon-disabled: var(--smoke-light-8); - --icon-focus: var(--smoke-light-12); - --icon-invert-base: #ffffff; - --icon-weak-base: var(--smoke-light-7); - --icon-weak-hover: var(--smoke-light-8); - --icon-weak-active: var(--smoke-light-9); - --icon-weak-selected: var(--smoke-light-10); - --icon-weak-disabled: var(--smoke-light-6); - --icon-weak-focus: var(--smoke-light-9); - --icon-strong-base: var(--smoke-light-12); - --icon-strong-hover: #151313; - --icon-strong-active: #020202; - --icon-strong-selected: #020202; - --icon-strong-disabled: var(--smoke-light-8); - --icon-strong-focus: #020202; - --icon-brand-base: var(--smoke-light-12); - --icon-interactive-base: var(--cobalt-light-9); - --icon-success-base: var(--apple-light-7); - --icon-success-hover: var(--apple-light-8); - --icon-success-active: var(--apple-light-11); - --icon-warning-base: var(--amber-light-7); - --icon-warning-hover: var(--amber-light-8); - --icon-warning-active: var(--amber-light-11); - --icon-critical-base: var(--ember-light-10); - --icon-critical-hover: var(--ember-light-11); - --icon-critical-active: var(--ember-light-12); - --icon-info-base: var(--lilac-light-7); - --icon-info-hover: var(--lilac-light-8); - --icon-info-active: var(--lilac-light-11); - --icon-on-brand-base: var(--smoke-light-alpha-11); - --icon-on-brand-hover: var(--smoke-light-alpha-12); - --icon-on-brand-selected: var(--smoke-light-alpha-12); - --icon-on-interactive-base: var(--smoke-light-1); - --icon-agent-plan-base: var(--purple-light-9); - --icon-agent-docs-base: var(--amber-light-9); - --icon-agent-ask-base: var(--cyan-light-9); - --icon-agent-build-base: var(--cobalt-light-9); - --icon-on-success-base: var(--apple-light-alpha-9); - --icon-on-success-hover: var(--apple-light-alpha-10); - --icon-on-success-selected: var(--apple-light-alpha-11); - --icon-on-warning-base: var(--amber-lightalpha-9); - --icon-on-warning-hover: var(--amber-lightalpha-10); - --icon-on-warning-selected: var(--amber-lightalpha-11); - --icon-on-critical-base: var(--ember-light-alpha-9); - --icon-on-critical-hover: var(--ember-light-alpha-10); - --icon-on-critical-selected: var(--ember-light-alpha-11); - --icon-on-info-base: var(--lilac-light-9); - --icon-on-info-hover: var(--lilac-light-alpha-10); - --icon-on-info-selected: var(--lilac-light-alpha-11); - --icon-diff-add-base: var(--mint-light-11); - --icon-diff-add-hover: var(--mint-light-12); - --icon-diff-add-active: var(--mint-light-12); - --icon-diff-delete-base: var(--ember-light-10); - --icon-diff-delete-hover: var(--ember-light-11); - --syntax-comment: var(--text-weak); - --syntax-regexp: var(--text-base); - --syntax-string: #006656; - --syntax-keyword: var(--text-weak); - --syntax-primitive: #fb4804; - --syntax-operator: var(--text-base); - --syntax-variable: var(--text-strong); - --syntax-property: #ed6dc8; - --syntax-type: #596600; - --syntax-constant: #007b80; - --syntax-punctuation: var(--text-base); - --syntax-object: var(--text-strong); - --syntax-success: var(--apple-light-10); - --syntax-warning: var(--amber-light-10); - --syntax-critical: var(--ember-light-10); - --syntax-info: #0092a8; - --syntax-diff-add: var(--mint-light-11); - --syntax-diff-delete: var(--ember-light-11); - --syntax-diff-unknown: #ff0000; - --markdown-heading: #d68c27; - --markdown-text: #1a1a1a; - --markdown-link: #3b7dd8; - --markdown-link-text: #318795; - --markdown-code: #3d9a57; - --markdown-block-quote: #b0851f; - --markdown-emph: #b0851f; - --markdown-strong: #d68c27; - --markdown-horizontal-rule: #8a8a8a; - --markdown-list-item: #3b7dd8; - --markdown-list-enumeration: #318795; - --markdown-image: #3b7dd8; - --markdown-image-text: #318795; - --markdown-code-block: #1a1a1a; - --border-color: #ffffff; - --border-weaker-base: var(--smoke-light-alpha-3); - --border-weaker-hover: var(--smoke-light-alpha-4); - --border-weaker-active: var(--smoke-light-alpha-6); - --border-weaker-selected: var(--cobalt-light-alpha-4); - --border-weaker-disabled: var(--smoke-light-alpha-2); - --border-weaker-focus: var(--smoke-light-alpha-6); - --button-ghost-hover: var(--smoke-light-alpha-2); - --button-ghost-hover2: var(--smoke-light-alpha-3); - --avatar-background-pink: #feeef8; - --avatar-background-mint: #e1fbf4; - --avatar-background-orange: #fff1e7; - --avatar-background-purple: #f9f1fe; - --avatar-background-cyan: #e7f9fb; - --avatar-background-lime: #eefadc; - --avatar-text-pink: #cd1d8d; - --avatar-text-mint: #147d6f; - --avatar-text-orange: #ed5f00; - --avatar-text-purple: #8445bc; - --avatar-text-cyan: #0894b3; - --avatar-text-lime: #5d770d; - @media (prefers-color-scheme: dark) { color-scheme: dark; --text-mix-blend-mode: plus-lighter; - - /* OC-1-dark */ - --background-base: var(--smoke-dark-1); - --background-weak: #1c1717; - --background-strong: #151313; - --background-stronger: #191515; - --surface-base: var(--smoke-dark-alpha-2); - --base: var(--smoke-dark-alpha-2); - --surface-base-hover: #e0b7b716; - --surface-base-active: var(--smoke-dark-alpha-3); - --surface-base-interactive-active: var(--cobalt-dark-alpha-2); - --base2: var(--smoke-dark-alpha-2); - --base3: var(--smoke-dark-alpha-2); - --surface-inset-base: #0e0b0b7f; - --surface-inset-base-hover: #0e0b0b7f; - --surface-inset-strong: #060505cc; - --surface-inset-strong-hover: #060505cc; - --surface-raised-base: var(--smoke-dark-alpha-3); - --surface-float-base: var(--smoke-dark-1); - --surface-float-base-hover: var(--smoke-dark-2); - --surface-raised-base-hover: var(--smoke-dark-alpha-4); - --surface-raised-base-active: var(--smoke-dark-alpha-5); - --surface-raised-strong: var(--smoke-dark-alpha-4); - --surface-raised-strong-hover: var(--smoke-dark-alpha-6); - --surface-raised-stronger: var(--smoke-dark-alpha-6); - --surface-raised-stronger-hover: var(--smoke-dark-alpha-7); - --surface-weak: var(--smoke-dark-alpha-4); - --surface-weaker: var(--smoke-dark-alpha-5); - --surface-strong: var(--smoke-dark-alpha-7); - --surface-raised-stronger-non-alpha: var(--smoke-dark-3); - --surface-brand-base: var(--yuzu-light-9); - --surface-brand-hover: var(--yuzu-light-10); - --surface-interactive-base: var(--cobalt-light-3); - --surface-interactive-hover: var(--cobalt-light-4); - --surface-interactive-weak: var(--cobalt-light-2); - --surface-interactive-weak-hover: var(--cobalt-light-3); - --surface-success-base: var(--apple-light-3); - --surface-success-weak: var(--apple-light-2); - --surface-success-strong: var(--apple-light-9); - --surface-warning-base: var(--solaris-light-3); - --surface-warning-weak: var(--solaris-light-2); - --surface-warning-strong: var(--solaris-light-9); - --surface-critical-base: var(--ember-dark-3); - --surface-critical-weak: var(--ember-dark-2); - --surface-critical-strong: var(--ember-dark-9); - --surface-info-base: var(--lilac-light-3); - --surface-info-weak: var(--lilac-light-2); - --surface-info-strong: var(--lilac-light-9); - --surface-diff-unchanged-base: var(--smoke-dark-1); - --surface-diff-skip-base: var(--smoke-dark-alpha-1); - --surface-diff-hidden-base: var(--blue-dark-2); - --surface-diff-hidden-weak: var(--blue-dark-1); - --surface-diff-hidden-weaker: var(--blue-dark-3); - --surface-diff-hidden-strong: var(--blue-dark-5); - --surface-diff-hidden-stronger: var(--blue-dark-11); - --surface-diff-add-base: var(--mint-dark-3); - --surface-diff-add-weak: var(--mint-dark-4); - --surface-diff-add-weaker: var(--mint-dark-3); - --surface-diff-add-strong: var(--mint-dark-5); - --surface-diff-add-stronger: var(--mint-dark-11); - --surface-diff-delete-base: var(--ember-dark-3); - --surface-diff-delete-weak: var(--ember-dark-4); - --surface-diff-delete-weaker: var(--ember-dark-3); - --surface-diff-delete-strong: var(--ember-dark-5); - --surface-diff-delete-stronger: var(--ember-dark-11); - --input-base: var(--smoke-dark-2); - --input-hover: var(--smoke-dark-2); - --input-active: var(--cobalt-dark-1); - --input-selected: var(--cobalt-dark-2); - --input-focus: var(--cobalt-dark-1); - --input-disabled: var(--smoke-dark-4); - --text-base: var(--smoke-dark-alpha-11); - --text-weak: var(--smoke-dark-alpha-9); - --text-weaker: var(--smoke-dark-alpha-8); - --text-strong: var(--smoke-dark-alpha-12); - --text-invert-base: var(--smoke-dark-alpha-11); - --text-invert-weak: var(--smoke-dark-alpha-9); - --text-invert-weaker: var(--smoke-dark-alpha-8); - --text-invert-strong: var(--smoke-dark-alpha-12); - --text-interactive-base: var(--cobalt-dark-11); - --text-on-brand-base: var(--smoke-dark-alpha-11); - --text-on-interactive-base: var(--smoke-dark-12); - --text-on-interactive-weak: var(--smoke-dark-alpha-11); - --text-on-success-base: var(--apple-dark-9); - --text-on-critical-base: var(--ember-dark-9); - --text-on-critical-weak: var(--ember-dark-8); - --text-on-critical-strong: var(--ember-dark-12); - --text-on-warning-base: var(--smoke-dark-alpha-11); - --text-on-info-base: var(--smoke-dark-alpha-11); - --text-diff-add-base: var(--mint-dark-11); - --text-diff-delete-base: var(--ember-dark-9); - --text-diff-delete-strong: var(--ember-dark-12); - --text-diff-add-strong: var(--mint-dark-8); - --text-on-info-weak: var(--smoke-dark-alpha-9); - --text-on-info-strong: var(--smoke-dark-alpha-12); - --text-on-warning-weak: var(--smoke-dark-alpha-9); - --text-on-warning-strong: var(--smoke-dark-alpha-12); - --text-on-success-weak: var(--apple-dark-8); - --text-on-success-strong: var(--apple-dark-12); - --text-on-brand-weak: var(--smoke-dark-alpha-9); - --text-on-brand-weaker: var(--smoke-dark-alpha-8); - --text-on-brand-strong: var(--smoke-dark-alpha-12); - --button-secondary-base: #231f1f; - --button-secondary-hover: #2a2727; - --border-base: var(--smoke-dark-alpha-7); - --border-hover: var(--smoke-dark-alpha-8); - --border-active: var(--smoke-dark-alpha-9); - --border-selected: var(--cobalt-dark-alpha-11); - --border-disabled: var(--smoke-dark-alpha-8); - --border-focus: var(--smoke-dark-alpha-9); - --border-weak-base: var(--smoke-dark-alpha-6); - --border-strong-base: var(--smoke-dark-alpha-8); - --border-strong-hover: var(--smoke-dark-alpha-7); - --border-strong-active: var(--smoke-dark-alpha-8); - --border-strong-selected: var(--cobalt-dark-alpha-6); - --border-strong-disabled: var(--smoke-dark-alpha-6); - --border-strong-focus: var(--smoke-dark-alpha-8); - --border-weak-hover: var(--smoke-dark-alpha-7); - --border-weak-active: var(--smoke-dark-alpha-8); - --border-weak-selected: var(--cobalt-dark-alpha-6); - --border-weak-disabled: var(--smoke-dark-alpha-6); - --border-weak-focus: var(--smoke-dark-alpha-8); - --border-interactive-base: var(--cobalt-light-7); - --border-interactive-hover: var(--cobalt-light-8); - --border-interactive-active: var(--cobalt-light-9); - --border-interactive-selected: var(--cobalt-light-9); - --border-interactive-disabled: var(--smoke-light-8); - --border-interactive-focus: var(--cobalt-light-9); - --border-success-base: var(--apple-light-6); - --border-success-hover: var(--apple-light-7); - --border-success-selected: var(--apple-light-9); - --border-warning-base: var(--solaris-light-6); - --border-warning-hover: var(--solaris-light-7); - --border-warning-selected: var(--solaris-light-9); - --border-critical-base: var(--ember-dark-5); - --border-critical-hover: var(--ember-dark-7); - --border-critical-selected: var(--ember-dark-9); - --border-info-base: var(--lilac-light-6); - --border-info-hover: var(--lilac-light-7); - --border-info-selected: var(--lilac-light-9); - --icon-base: var(--smoke-dark-9); - --icon-hover: var(--smoke-dark-10); - --icon-active: var(--smoke-dark-11); - --icon-selected: var(--smoke-dark-12); - --icon-disabled: var(--smoke-dark-7); - --icon-focus: var(--smoke-dark-12); - --icon-invert-base: var(--smoke-dark-1); - --icon-weak-base: var(--smoke-dark-6); - --icon-weak-hover: var(--smoke-light-7); - --icon-weak-active: var(--smoke-light-8); - --icon-weak-selected: var(--smoke-light-9); - --icon-weak-disabled: var(--smoke-light-4); - --icon-weak-focus: var(--smoke-light-9); - --icon-strong-base: var(--smoke-dark-12); - --icon-strong-hover: #f6f3f3; - --icon-strong-active: #fcfcfc; - --icon-strong-selected: #fdfcfc; - --icon-strong-disabled: var(--smoke-dark-8); - --icon-strong-focus: #fdfcfc; - --icon-brand-base: var(--white); - --icon-interactive-base: var(--cobalt-dark-9); - --icon-success-base: var(--apple-dark-7); - --icon-success-hover: var(--apple-dark-8); - --icon-success-active: var(--apple-dark-11); - --icon-warning-base: var(--amber-dark-7); - --icon-warning-hover: var(--amber-dark-8); - --icon-warning-active: var(--amber-dark-11); - --icon-critical-base: var(--ember-dark-9); - --icon-critical-hover: var(--ember-dark-11); - --icon-critical-active: var(--ember-dark-12); - --icon-info-base: var(--lilac-dark-7); - --icon-info-hover: var(--lilac-dark-8); - --icon-info-active: var(--lilac-dark-11); - --icon-on-brand-base: var(--smoke-light-alpha-11); - --icon-on-brand-hover: var(--smoke-light-alpha-12); - --icon-on-brand-selected: var(--smoke-light-alpha-12); - --icon-on-interactive-base: var(--smoke-dark-12); - --icon-agent-plan-base: var(--purple-dark-9); - --icon-agent-docs-base: var(--amber-dark-9); - --icon-agent-ask-base: var(--cyan-dark-9); - --icon-agent-build-base: var(--cobalt-dark-11); - --icon-on-success-base: var(--apple-dark-alpha-9); - --icon-on-success-hover: var(--apple-dark-alpha-10); - --icon-on-success-selected: var(--apple-dark-alpha-11); - --icon-on-warning-base: var(--amber-darkalpha-9); - --icon-on-warning-hover: var(--amber-darkalpha-10); - --icon-on-warning-selected: var(--amber-darkalpha-11); - --icon-on-critical-base: var(--ember-dark-alpha-9); - --icon-on-critical-hover: var(--ember-dark-alpha-10); - --icon-on-critical-selected: var(--ember-dark-alpha-11); - --icon-on-info-base: var(--lilac-dark-9); - --icon-on-info-hover: var(--lilac-dark-alpha-10); - --icon-on-info-selected: var(--lilac-dark-alpha-11); - --icon-diff-add-base: var(--mint-dark-11); - --icon-diff-add-hover: var(--mint-dark-10); - --icon-diff-add-active: var(--mint-dark-11); - --icon-diff-delete-base: var(--ember-dark-9); - --icon-diff-delete-hover: var(--ember-dark-10); - --syntax-comment: var(--text-weak); - --syntax-regexp: var(--text-base); - --syntax-string: #00ceb9; - --syntax-keyword: var(--text-weak); - --syntax-primitive: #ffba92; - --syntax-operator: var(--text-weak); - --syntax-variable: var(--text-strong); - --syntax-property: #ff9ae2; - --syntax-type: #ecf58c; - --syntax-constant: #93e9f6; - --syntax-punctuation: var(--text-weak); - --syntax-object: var(--text-strong); - --syntax-success: var(--apple-dark-10); - --syntax-warning: var(--amber-dark-10); - --syntax-critical: var(--ember-dark-10); - --syntax-info: #93e9f6; - --syntax-diff-add: var(--mint-dark-11); - --syntax-diff-delete: var(--ember-dark-11); - --syntax-diff-unknown: #ff0000; - --markdown-heading: #9d7cd8; - --markdown-text: #eeeeee; - --markdown-link: #fab283; - --markdown-link-text: #56b6c2; - --markdown-code: #7fd88f; - --markdown-block-quote: #e5c07b; - --markdown-emph: #e5c07b; - --markdown-strong: #f5a742; - --markdown-horizontal-rule: #808080; - --markdown-list-item: #fab283; - --markdown-list-enumeration: #56b6c2; - --markdown-image: #fab283; - --markdown-image-text: #56b6c2; - --markdown-code-block: #eeeeee; - --border-color: #ffffff; - --border-weaker-base: var(--smoke-dark-alpha-3); - --border-weaker-hover: var(--smoke-dark-alpha-4); - --border-weaker-active: var(--smoke-dark-alpha-6); - --border-weaker-selected: var(--cobalt-dark-alpha-3); - --border-weaker-disabled: var(--smoke-dark-alpha-2); - --border-weaker-focus: var(--smoke-dark-alpha-6); - --button-ghost-hover: var(--smoke-dark-alpha-2); - --button-ghost-hover2: var(--smoke-dark-alpha-3); - --avatar-background-pink: #501b3f; - --avatar-background-mint: #033a34; - --avatar-background-orange: #5f2a06; - --avatar-background-purple: #432155; - --avatar-background-cyan: #0f3058; - --avatar-background-lime: #2b3711; - --avatar-text-pink: #e34ba9; - --avatar-text-mint: #95f3d9; - --avatar-text-orange: #ff802b; - --avatar-text-purple: #9d5bd2; - --avatar-text-cyan: #369eff; - --avatar-text-lime: #c4f042; - } -} - -html[data-theme="oc-2-paper"] { - /* OC-2-paper */ - --background-base: #f7f8f8; - --background-weak: var(--ink-light-3); - --background-strong: var(--ink-light-1); - --background-stronger: #fcfcfc; - --surface-base: var(--ink-light-alpha-2); - --base: var(--ink-light-alpha-2); - --surface-base-hover: #0005050f; - --surface-base-active: var(--ink-light-alpha-3); - --surface-base-interactive-active: var(--cobalt-light-alpha-3); - --base2: var(--ink-light-alpha-2); - --base3: var(--ink-light-alpha-2); - --surface-inset-base: var(--ink-light-alpha-2); - --surface-inset-base-hover: var(--ink-light-alpha-3); - --surface-inset-strong: #001f1f17; - --surface-inset-strong-hover: #001f1f17; - --surface-raised-base: var(--ink-light-alpha-1); - --surface-float-base: var(--ink-dark-1); - --surface-float-base-hover: var(--ink-dark-2); - --surface-raised-base-hover: var(--ink-light-alpha-2); - --surface-raised-base-active: var(--ink-light-alpha-3); - --surface-raised-strong: var(--ink-light-1); - --surface-raised-strong-hover: var(--white); - --surface-raised-stronger: var(--white); - --surface-raised-stronger-hover: var(--white); - --surface-weak: var(--ink-light-alpha-3); - --surface-weaker: var(--ink-light-alpha-4); - --surface-strong: #ffffff; - --surface-raised-stronger-non-alpha: var(--white); - --surface-brand-base: var(--yuzu-light-9); - --surface-brand-hover: var(--yuzu-light-10); - --surface-interactive-base: var(--cobalt-light-3); - --surface-interactive-hover: var(--cobalt-light-4); - --surface-interactive-weak: var(--cobalt-light-2); - --surface-interactive-weak-hover: var(--cobalt-light-3); - --surface-success-base: var(--apple-light-3); - --surface-success-weak: var(--apple-light-2); - --surface-success-strong: var(--apple-light-9); - --surface-warning-base: var(--solaris-light-3); - --surface-warning-weak: var(--solaris-light-2); - --surface-warning-strong: var(--solaris-light-9); - --surface-critical-base: var(--ember-light-3); - --surface-critical-weak: var(--ember-light-2); - --surface-critical-strong: var(--ember-light-9); - --surface-info-base: var(--lilac-light-3); - --surface-info-weak: var(--lilac-light-2); - --surface-info-strong: var(--lilac-light-9); - --surface-diff-unchanged-base: #ffffff00; - --surface-diff-skip-base: var(--ink-light-2); - --surface-diff-hidden-base: var(--blue-light-3); - --surface-diff-hidden-weak: var(--blue-light-2); - --surface-diff-hidden-weaker: var(--blue-light-1); - --surface-diff-hidden-strong: var(--blue-light-5); - --surface-diff-hidden-stronger: var(--blue-light-9); - --surface-diff-add-base: var(--mint-light-3); - --surface-diff-add-weak: var(--mint-light-2); - --surface-diff-add-weaker: var(--mint-light-1); - --surface-diff-add-strong: var(--mint-light-5); - --surface-diff-add-stronger: var(--mint-light-9); - --surface-diff-delete-base: var(--ember-light-3); - --surface-diff-delete-weak: var(--ember-light-2); - --surface-diff-delete-weaker: var(--ember-light-1); - --surface-diff-delete-strong: var(--ember-light-6); - --surface-diff-delete-stronger: var(--ember-light-9); - --text-base: var(--ink-light-11); - --input-base: var(--ink-light-1); - --input-hover: var(--ink-light-2); - --input-active: var(--cobalt-light-1); - --input-selected: var(--cobalt-light-4); - --input-focus: var(--cobalt-light-1); - --input-disabled: var(--ink-light-4); - --text-weak: var(--ink-light-9); - --text-weaker: var(--ink-light-8); - --text-strong: var(--ink-light-12); - --text-interactive-base: var(--cobalt-light-9); - --text-on-brand-base: var(--ink-light-alpha-11); - --text-on-interactive-base: var(--ink-light-1); - --text-on-interactive-weak: var(--ink-light-alpha-11); - --text-on-success-base: var(--apple-light-10); - --text-on-critical-base: var(--ember-light-10); - --text-on-critical-weak: var(--ember-light-8); - --text-on-critical-strong: var(--ember-light-12); - --text-on-warning-base: var(--ink-dark-alpha-11); - --text-on-info-base: var(--ink-dark-alpha-11); - --text-diff-add-base: var(--mint-light-11); - --text-diff-delete-base: var(--ember-light-10); - --text-diff-delete-strong: var(--ember-light-12); - --text-diff-add-strong: var(--mint-light-12); - --text-on-info-weak: var(--ink-dark-alpha-9); - --text-on-info-strong: var(--ink-dark-alpha-12); - --text-on-warning-weak: var(--ink-dark-alpha-9); - --text-on-warning-strong: var(--ink-dark-alpha-12); - --text-on-success-weak: var(--apple-light-6); - --text-on-success-strong: var(--apple-light-12); - --text-on-brand-weak: var(--ink-light-alpha-9); - --text-on-brand-weaker: var(--ink-light-alpha-8); - --text-on-brand-strong: var(--ink-light-alpha-12); - --button-secondary-base: #fcfdfd; - --button-secondary-hover: #f9fafa; - --border-base: var(--ink-light-alpha-7); - --border-hover: var(--ink-light-alpha-8); - --border-active: var(--ink-light-alpha-9); - --border-selected: var(--cobalt-light-alpha-9); - --border-disabled: var(--ink-light-alpha-8); - --border-focus: var(--ink-light-alpha-9); - --border-weak-base: var(--ink-light-alpha-5); - --border-strong-base: var(--ink-light-alpha-7); - --border-strong-hover: var(--ink-light-alpha-8); - --border-strong-active: var(--ink-light-alpha-7); - --border-strong-selected: var(--cobalt-light-alpha-6); - --border-strong-disabled: var(--ink-light-alpha-6); - --border-strong-focus: var(--ink-light-alpha-7); - --border-weak-hover: var(--ink-light-alpha-6); - --border-weak-active: var(--ink-light-alpha-7); - --border-weak-selected: var(--cobalt-light-alpha-5); - --border-weak-disabled: var(--ink-light-alpha-6); - --border-weak-focus: var(--ink-light-alpha-7); - --border-interactive-base: var(--cobalt-light-7); - --border-interactive-hover: var(--cobalt-light-8); - --border-interactive-active: var(--cobalt-light-9); - --border-interactive-selected: var(--cobalt-light-9); - --border-interactive-disabled: var(--ink-light-8); - --border-interactive-focus: var(--cobalt-light-9); - --border-success-base: var(--apple-light-6); - --border-success-hover: var(--apple-light-7); - --border-success-selected: var(--apple-light-9); - --border-warning-base: var(--solaris-light-6); - --border-warning-hover: var(--solaris-light-7); - --border-warning-selected: var(--solaris-light-9); - --border-critical-base: var(--ember-light-6); - --border-critical-hover: var(--ember-light-7); - --border-critical-selected: var(--ember-light-9); - --border-info-base: var(--lilac-light-6); - --border-info-hover: var(--lilac-light-7); - --border-info-selected: var(--lilac-light-9); - --icon-base: var(--ink-light-9); - --icon-hover: var(--ink-light-11); - --icon-active: var(--ink-light-12); - --icon-selected: var(--ink-light-12); - --icon-disabled: var(--ink-light-8); - --icon-focus: var(--ink-light-12); - --icon-invert-base: #ffffff; - --icon-weak-base: var(--ink-light-7); - --icon-weak-hover: var(--ink-light-8); - --icon-weak-active: var(--ink-light-9); - --icon-weak-selected: var(--ink-light-10); - --icon-weak-disabled: var(--ink-light-6); - --icon-weak-focus: var(--ink-light-9); - --icon-strong-base: var(--ink-light-12); - --icon-strong-hover: #131515; - --icon-strong-active: #020202; - --icon-strong-selected: #020202; - --icon-strong-disabled: var(--ink-light-8); - --icon-strong-focus: #020202; - --icon-brand-base: var(--ink-light-12); - --icon-interactive-base: var(--cobalt-light-9); - --icon-success-base: var(--apple-light-7); - --icon-success-hover: var(--apple-light-8); - --icon-success-active: var(--apple-light-11); - --icon-warning-base: var(--amber-light-7); - --icon-warning-hover: var(--amber-light-8); - --icon-warning-active: var(--amber-light-11); - --icon-critical-base: var(--ember-light-10); - --icon-critical-hover: var(--ember-light-11); - --icon-critical-active: var(--ember-light-12); - --icon-info-base: var(--lilac-light-7); - --icon-info-hover: var(--lilac-light-8); - --icon-info-active: var(--lilac-light-11); - --icon-on-brand-base: var(--ink-light-alpha-11); - --icon-on-brand-hover: var(--ink-light-alpha-12); - --icon-on-brand-selected: var(--ink-light-alpha-12); - --icon-on-interactive-base: var(--ink-light-1); - --icon-agent-plan-base: var(--purple-light-9); - --icon-agent-docs-base: var(--amber-light-9); - --icon-agent-ask-base: var(--cyan-light-9); - --icon-agent-build-base: var(--cobalt-light-9); - --icon-on-success-base: var(--apple-light-alpha-9); - --icon-on-success-hover: var(--apple-light-alpha-10); - --icon-on-success-selected: var(--apple-light-alpha-11); - --icon-on-warning-base: var(--amber-lightalpha-9); - --icon-on-warning-hover: var(--amber-lightalpha-10); - --icon-on-warning-selected: var(--amber-lightalpha-11); - --icon-on-critical-base: var(--ember-light-alpha-9); - --icon-on-critical-hover: var(--ember-light-alpha-10); - --icon-on-critical-selected: var(--ember-light-alpha-11); - --icon-on-info-base: var(--lilac-light-9); - --icon-on-info-hover: var(--lilac-light-alpha-10); - --icon-on-info-selected: var(--lilac-light-alpha-11); - --icon-diff-add-base: var(--mint-light-11); - --icon-diff-add-hover: var(--mint-light-12); - --icon-diff-add-active: var(--mint-light-12); - --icon-diff-delete-base: var(--ember-light-10); - --icon-diff-delete-hover: var(--ember-light-11); - --syntax-comment: var(--text-weak); - --syntax-regexp: var(--text-base); - --syntax-string: #007663; - --syntax-keyword: var(--text-weak); - --syntax-primitive: #fb7f51; - --syntax-operator: var(--text-weak); - --syntax-variable: var(--text-strong); - --syntax-property: #ec6cc8; - --syntax-type: #738400; - --syntax-constant: #00b2b9; - --syntax-punctuation: var(--text-weak); - --syntax-object: var(--text-strong); - --syntax-success: var(--apple-light-10); - --syntax-warning: var(--amber-light-10); - --syntax-critical: var(--ember-light-9); - --syntax-info: #0091a7; - --syntax-diff-add: var(--mint-light-11); - --syntax-diff-delete: var(--ember-light-11); - --syntax-diff-unknown: #ff0000; - --markdown-heading: #d68c27; - --markdown-text: #1a1a1a; - --markdown-link: #3b7dd8; - --markdown-link-text: #318795; - --markdown-code: #3d9a57; - --markdown-block-quote: #b0851f; - --markdown-emph: #b0851f; - --markdown-strong: #d68c27; - --markdown-horizontal-rule: #8a8a8a; - --markdown-list-item: #3b7dd8; - --markdown-list-enumeration: #318795; - --markdown-image: #3b7dd8; - --markdown-image-text: #318795; - --markdown-code-block: #1a1a1a; - --border-color: #ffffff; - --border-weaker-base: var(--ink-light-alpha-3); - --border-weaker-hover: var(--ink-light-alpha-4); - --border-weaker-active: var(--ink-light-alpha-6); - --border-weaker-selected: var(--cobalt-light-alpha-4); - --border-weaker-disabled: var(--ink-light-alpha-2); - --border-weaker-focus: var(--ink-light-alpha-6); - --button-ghost-hover: var(--ink-light-alpha-2); - --button-ghost-hover2: var(--ink-light-alpha-3); - - @media (prefers-color-scheme: dark) { - --background-base: var(--ink-dark-1); - --background-weak: #171c1c; - --background-strong: #131515; - --background-stronger: #151919; - --surface-base: var(--ink-dark-alpha-2); - --base: var(--ink-dark-alpha-2); - --surface-base-hover: #b8e0e00f; - --surface-base-active: var(--ink-dark-alpha-3); - --surface-base-interactive-active: var(--cobalt-light-alpha-3); - --base2: var(--ink-dark-alpha-2); - --base3: var(--ink-dark-alpha-2); - --surface-inset-base: #0b0e0e7f; - --surface-inset-base-hover: #0b0e0e7f; - --surface-inset-strong: #050606cc; - --surface-inset-strong-hover: #050606cc; - --surface-raised-base: var(--ink-light-alpha-1); - --surface-float-base: var(--ink-dark-1); - --surface-float-base-hover: var(--ink-dark-2); - --surface-raised-base-hover: var(--ink-light-alpha-2); - --surface-raised-base-active: var(--ink-light-alpha-3); - --surface-raised-strong: var(--ink-light-1); - --surface-raised-strong-hover: var(--white); - --surface-raised-stronger: var(--white); - --surface-raised-stronger-hover: var(--white); - --surface-weak: var(--ink-dark-alpha-4); - --surface-weaker: var(--ink-dark-alpha-5); - --surface-strong: var(--ink-dark-alpha-7); - --surface-raised-stronger-non-alpha: var(--white); - --surface-brand-base: var(--yuzu-light-9); - --surface-brand-hover: var(--yuzu-light-10); - --surface-interactive-base: var(--cobalt-light-3); - --surface-interactive-hover: var(--cobalt-light-4); - --surface-interactive-weak: var(--cobalt-light-2); - --surface-interactive-weak-hover: var(--cobalt-light-3); - --surface-success-base: var(--apple-light-3); - --surface-success-weak: var(--apple-light-2); - --surface-success-strong: var(--apple-light-9); - --surface-warning-base: var(--solaris-light-3); - --surface-warning-weak: var(--solaris-light-2); - --surface-warning-strong: var(--solaris-light-9); - --surface-critical-base: var(--ember-light-3); - --surface-critical-weak: var(--ember-light-2); - --surface-critical-strong: var(--ember-light-9); - --surface-info-base: var(--lilac-light-3); - --surface-info-weak: var(--lilac-light-2); - --surface-info-strong: var(--lilac-light-9); - --surface-diff-unchanged-base: #ffffff00; - --surface-diff-skip-base: var(--ink-light-2); - --surface-diff-hidden-base: var(--blue-light-3); - --surface-diff-hidden-weak: var(--blue-light-2); - --surface-diff-hidden-weaker: var(--blue-light-1); - --surface-diff-hidden-strong: var(--blue-light-5); - --surface-diff-hidden-stronger: var(--blue-light-9); - --surface-diff-add-base: var(--mint-light-3); - --surface-diff-add-weak: var(--mint-light-2); - --surface-diff-add-weaker: var(--mint-light-1); - --surface-diff-add-strong: var(--mint-light-5); - --surface-diff-add-stronger: var(--mint-light-9); - --surface-diff-delete-base: var(--ember-light-3); - --surface-diff-delete-weak: var(--ember-light-2); - --surface-diff-delete-weaker: var(--ember-light-1); - --surface-diff-delete-strong: var(--ember-light-6); - --surface-diff-delete-stronger: var(--ember-light-9); - --text-base: var(--ink-light-11); - --input-base: var(--ink-light-1); - --input-hover: var(--ink-light-2); - --input-active: var(--cobalt-light-1); - --input-selected: var(--cobalt-light-4); - --input-focus: var(--cobalt-light-1); - --input-disabled: var(--ink-light-4); - --text-weak: var(--ink-light-9); - --text-weaker: var(--ink-light-8); - --text-strong: var(--ink-light-12); - --text-interactive-base: var(--cobalt-light-9); - --text-on-brand-base: var(--ink-light-alpha-11); - --text-on-interactive-base: var(--ink-light-1); - --text-on-interactive-weak: var(--ink-light-alpha-11); - --text-on-success-base: var(--apple-light-10); - --text-on-critical-base: var(--ember-light-10); - --text-on-critical-weak: var(--ember-light-8); - --text-on-critical-strong: var(--ember-light-12); - --text-on-warning-base: var(--ink-dark-alpha-11); - --text-on-info-base: var(--ink-dark-alpha-11); - --text-diff-add-base: var(--mint-light-11); - --text-diff-delete-base: var(--ember-light-10); - --text-diff-delete-strong: var(--ember-light-12); - --text-diff-add-strong: var(--mint-light-12); - --text-on-info-weak: var(--ink-dark-alpha-9); - --text-on-info-strong: var(--ink-dark-alpha-12); - --text-on-warning-weak: var(--ink-dark-alpha-9); - --text-on-warning-strong: var(--ink-dark-alpha-12); - --text-on-success-weak: var(--apple-light-6); - --text-on-success-strong: var(--apple-light-12); - --text-on-brand-weak: var(--ink-light-alpha-9); - --text-on-brand-weaker: var(--ink-light-alpha-8); - --text-on-brand-strong: var(--ink-light-alpha-12); - --button-secondary-base: #fcfdfd; - --button-secondary-hover: #f9fafa; - --border-base: var(--ink-light-alpha-7); - --border-hover: var(--ink-light-alpha-8); - --border-active: var(--ink-light-alpha-9); - --border-selected: var(--cobalt-light-alpha-9); - --border-disabled: var(--ink-light-alpha-8); - --border-focus: var(--ink-light-alpha-9); - --border-weak-base: var(--ink-light-alpha-5); - --border-strong-base: var(--ink-light-alpha-7); - --border-strong-hover: var(--ink-light-alpha-8); - --border-strong-active: var(--ink-light-alpha-7); - --border-strong-selected: var(--cobalt-light-alpha-6); - --border-strong-disabled: var(--ink-light-alpha-6); - --border-strong-focus: var(--ink-light-alpha-7); - --border-weak-hover: var(--ink-light-alpha-6); - --border-weak-active: var(--ink-light-alpha-7); - --border-weak-selected: var(--cobalt-light-alpha-5); - --border-weak-disabled: var(--ink-light-alpha-6); - --border-weak-focus: var(--ink-light-alpha-7); - --border-interactive-base: var(--cobalt-light-7); - --border-interactive-hover: var(--cobalt-light-8); - --border-interactive-active: var(--cobalt-light-9); - --border-interactive-selected: var(--cobalt-light-9); - --border-interactive-disabled: var(--ink-light-8); - --border-interactive-focus: var(--cobalt-light-9); - --border-success-base: var(--apple-light-6); - --border-success-hover: var(--apple-light-7); - --border-success-selected: var(--apple-light-9); - --border-warning-base: var(--solaris-light-6); - --border-warning-hover: var(--solaris-light-7); - --border-warning-selected: var(--solaris-light-9); - --border-critical-base: var(--ember-light-6); - --border-critical-hover: var(--ember-light-7); - --border-critical-selected: var(--ember-light-9); - --border-info-base: var(--lilac-light-6); - --border-info-hover: var(--lilac-light-7); - --border-info-selected: var(--lilac-light-9); - --icon-base: var(--ink-light-9); - --icon-hover: var(--ink-light-11); - --icon-active: var(--ink-light-12); - --icon-selected: var(--ink-light-12); - --icon-disabled: var(--ink-light-8); - --icon-focus: var(--ink-light-12); - --icon-invert-base: #ffffff; - --icon-weak-base: var(--ink-light-7); - --icon-weak-hover: var(--ink-light-8); - --icon-weak-active: var(--ink-light-9); - --icon-weak-selected: var(--ink-light-10); - --icon-weak-disabled: var(--ink-light-6); - --icon-weak-focus: var(--ink-light-9); - --icon-strong-base: var(--ink-light-12); - --icon-strong-hover: #131515; - --icon-strong-active: #020202; - --icon-strong-selected: #020202; - --icon-strong-disabled: var(--ink-light-8); - --icon-strong-focus: #020202; - --icon-brand-base: var(--ink-light-12); - --icon-interactive-base: var(--cobalt-light-9); - --icon-success-base: var(--apple-light-7); - --icon-success-hover: var(--apple-light-8); - --icon-success-active: var(--apple-light-11); - --icon-warning-base: var(--amber-light-7); - --icon-warning-hover: var(--amber-light-8); - --icon-warning-active: var(--amber-light-11); - --icon-critical-base: var(--ember-light-10); - --icon-critical-hover: var(--ember-light-11); - --icon-critical-active: var(--ember-light-12); - --icon-info-base: var(--lilac-light-7); - --icon-info-hover: var(--lilac-light-8); - --icon-info-active: var(--lilac-light-11); - --icon-on-brand-base: var(--ink-light-alpha-11); - --icon-on-brand-hover: var(--ink-light-alpha-12); - --icon-on-brand-selected: var(--ink-light-alpha-12); - --icon-on-interactive-base: var(--ink-light-1); - --icon-agent-plan-base: var(--purple-light-9); - --icon-agent-docs-base: var(--amber-light-9); - --icon-agent-ask-base: var(--cyan-light-9); - --icon-agent-build-base: var(--cobalt-light-9); - --icon-on-success-base: var(--apple-light-alpha-9); - --icon-on-success-hover: var(--apple-light-alpha-10); - --icon-on-success-selected: var(--apple-light-alpha-11); - --icon-on-warning-base: var(--amber-lightalpha-9); - --icon-on-warning-hover: var(--amber-lightalpha-10); - --icon-on-warning-selected: var(--amber-lightalpha-11); - --icon-on-critical-base: var(--ember-light-alpha-9); - --icon-on-critical-hover: var(--ember-light-alpha-10); - --icon-on-critical-selected: var(--ember-light-alpha-11); - --icon-on-info-base: var(--lilac-light-9); - --icon-on-info-hover: var(--lilac-light-alpha-10); - --icon-on-info-selected: var(--lilac-light-alpha-11); - --icon-diff-add-base: var(--mint-light-11); - --icon-diff-add-hover: var(--mint-light-12); - --icon-diff-add-active: var(--mint-light-12); - --icon-diff-delete-base: var(--ember-light-10); - --icon-diff-delete-hover: var(--ember-light-11); - --syntax-comment: var(--text-weak); - --syntax-regexp: var(--text-base); - --syntax-string: #007663; - --syntax-keyword: var(--text-weak); - --syntax-primitive: #fb7f51; - --syntax-operator: var(--text-weak); - --syntax-variable: var(--text-strong); - --syntax-property: #ec6cc8; - --syntax-type: #738400; - --syntax-constant: #00b2b9; - --syntax-punctuation: var(--text-weak); - --syntax-object: var(--text-strong); - --syntax-success: var(--apple-light-10); - --syntax-warning: var(--amber-light-10); - --syntax-critical: var(--ember-light-9); - --syntax-info: #0091a7; - --syntax-diff-add: var(--mint-light-11); - --syntax-diff-delete: var(--ember-light-11); - --syntax-diff-unknown: #ff0000; - --markdown-heading: #d68c27; - --markdown-text: #1a1a1a; - --markdown-link: #3b7dd8; - --markdown-link-text: #318795; - --markdown-code: #3d9a57; - --markdown-block-quote: #b0851f; - --markdown-emph: #b0851f; - --markdown-strong: #d68c27; - --markdown-horizontal-rule: #8a8a8a; - --markdown-list-item: #3b7dd8; - --markdown-list-enumeration: #318795; - --markdown-image: #3b7dd8; - --markdown-image-text: #318795; - --markdown-code-block: #1a1a1a; - --border-color: #ffffff; - --border-weaker-base: var(--ink-light-alpha-3); - --border-weaker-hover: var(--ink-light-alpha-4); - --border-weaker-active: var(--ink-light-alpha-6); - --border-weaker-selected: var(--cobalt-light-alpha-4); - --border-weaker-disabled: var(--ink-light-alpha-2); - --border-weaker-focus: var(--ink-light-alpha-6); - --button-ghost-hover: var(--ink-light-alpha-2); - --button-ghost-hover2: var(--ink-light-alpha-3); } } diff --git a/packages/ui/src/theme/color.ts b/packages/ui/src/theme/color.ts new file mode 100644 index 0000000000..3a0526ca6c --- /dev/null +++ b/packages/ui/src/theme/color.ts @@ -0,0 +1,267 @@ +/** + * Color utilities for theme generation using OKLCH color space. + * OKLCH provides perceptually uniform color manipulation. + */ + +import type { HexColor, OklchColor } from "./types" + +/** + * Convert hex color to RGB values (0-1 range) + */ +export function hexToRgb(hex: HexColor): { r: number; g: number; b: number } { + const h = hex.replace("#", "") + const full = + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h + + const num = parseInt(full, 16) + return { + r: ((num >> 16) & 255) / 255, + g: ((num >> 8) & 255) / 255, + b: (num & 255) / 255, + } +} + +/** + * Convert RGB (0-1 range) to hex color + */ +export function rgbToHex(r: number, g: number, b: number): HexColor { + const toHex = (v: number) => { + const clamped = Math.max(0, Math.min(1, v)) + const int = Math.round(clamped * 255) + return int.toString(16).padStart(2, "0") + } + return `#${toHex(r)}${toHex(g)}${toHex(b)}` +} + +/** + * Convert linear RGB to sRGB + */ +function linearToSrgb(c: number): number { + if (c <= 0.0031308) return c * 12.92 + return 1.055 * Math.pow(c, 1 / 2.4) - 0.055 +} + +/** + * Convert sRGB to linear RGB + */ +function srgbToLinear(c: number): number { + if (c <= 0.04045) return c / 12.92 + return Math.pow((c + 0.055) / 1.055, 2.4) +} + +/** + * Convert RGB to OKLCH + */ +export function rgbToOklch(r: number, g: number, b: number): OklchColor { + // Convert to linear RGB + const lr = srgbToLinear(r) + const lg = srgbToLinear(g) + const lb = srgbToLinear(b) + + // RGB to OKLab matrix multiplication + const l_ = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb + const m_ = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb + const s_ = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb + + const l = Math.cbrt(l_) + const m = Math.cbrt(m_) + const s = Math.cbrt(s_) + + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const bOk = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + + const C = Math.sqrt(a * a + bOk * bOk) + let H = Math.atan2(bOk, a) * (180 / Math.PI) + if (H < 0) H += 360 + + return { l: L, c: C, h: H } +} + +/** + * Convert OKLCH to RGB + */ +export function oklchToRgb(oklch: OklchColor): { r: number; g: number; b: number } { + const { l: L, c: C, h: H } = oklch + + const a = C * Math.cos((H * Math.PI) / 180) + const b = C * Math.sin((H * Math.PI) / 180) + + const l = L + 0.3963377774 * a + 0.2158037573 * b + const m = L - 0.1055613458 * a - 0.0638541728 * b + const s = L - 0.0894841775 * a - 1.291485548 * b + + const l3 = l * l * l + const m3 = m * m * m + const s3 = s * s * s + + const lr = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3 + const lg = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3 + const lb = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3 + + return { + r: linearToSrgb(lr), + g: linearToSrgb(lg), + b: linearToSrgb(lb), + } +} + +/** + * Convert hex to OKLCH + */ +export function hexToOklch(hex: HexColor): OklchColor { + const { r, g, b } = hexToRgb(hex) + return rgbToOklch(r, g, b) +} + +/** + * Convert OKLCH to hex + */ +export function oklchToHex(oklch: OklchColor): HexColor { + const { r, g, b } = oklchToRgb(oklch) + return rgbToHex(r, g, b) +} + +/** + * Generate a 12-step color scale from a seed color. + * Steps 1-4: Very light (backgrounds) + * Steps 5-7: Mid tones (borders, subtle UI) + * Steps 8-9: Saturated (primary buttons, text) + * Steps 10-12: Dark (text, strong accents) + * + * @param seed - The seed color (typically step 9 - the main accent) + * @param isDark - Whether generating for dark mode + */ +export function generateScale(seed: HexColor, isDark: boolean): HexColor[] { + const base = hexToOklch(seed) + const scale: HexColor[] = [] + + // Lightness values for each step (0-1) + // These are tuned to match Radix-style color scales + const lightSteps = isDark + ? [0.15, 0.18, 0.22, 0.26, 0.32, 0.38, 0.46, 0.56, base.l, base.l - 0.05, 0.75, 0.93] + : [0.99, 0.97, 0.94, 0.9, 0.85, 0.79, 0.72, 0.64, base.l, base.l + 0.05, 0.45, 0.25] + + // Chroma multipliers - less saturation at extremes + const chromaMultipliers = isDark + ? [0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1, 1, 0.9, 0.6] + : [0.1, 0.15, 0.25, 0.35, 0.45, 0.55, 0.7, 0.85, 1, 1, 0.95, 0.85] + + for (let i = 0; i < 12; i++) { + scale.push( + oklchToHex({ + l: lightSteps[i], + c: base.c * chromaMultipliers[i], + h: base.h, + }), + ) + } + + return scale +} + +/** + * Generate a neutral gray scale from a seed color. + * The seed color's hue is used to tint the grays slightly. + * + * @param seed - A neutral-ish color to derive the gray scale from + * @param isDark - Whether generating for dark mode + */ +export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[] { + const base = hexToOklch(seed) + const scale: HexColor[] = [] + + // Very low chroma for neutrals - just a hint of the hue + const neutralChroma = Math.min(base.c, 0.02) + + const lightSteps = isDark + ? [0.13, 0.16, 0.2, 0.24, 0.28, 0.33, 0.4, 0.52, 0.58, 0.66, 0.82, 0.96] + : [0.995, 0.98, 0.96, 0.94, 0.91, 0.88, 0.84, 0.78, 0.62, 0.56, 0.46, 0.2] + + for (let i = 0; i < 12; i++) { + scale.push( + oklchToHex({ + l: lightSteps[i], + c: neutralChroma, + h: base.h, + }), + ) + } + + return scale +} + +/** + * Generate alpha variants of a color scale. + * Returns hex colors with alpha pre-multiplied against white (light) or black (dark). + */ +export function generateAlphaScale(scale: HexColor[], isDark: boolean): HexColor[] { + // Alpha values for each step + const alphas = isDark + ? [0.02, 0.04, 0.08, 0.12, 0.16, 0.2, 0.26, 0.36, 0.44, 0.52, 0.76, 0.96] + : [0.01, 0.03, 0.06, 0.09, 0.12, 0.15, 0.2, 0.28, 0.48, 0.56, 0.64, 0.88] + + return scale.map((hex, i) => { + const { r, g, b } = hexToRgb(hex) + const a = alphas[i] + + // Pre-multiply against white (light) or black (dark) + const bg = isDark ? 0 : 1 + const blendedR = r * a + bg * (1 - a) + const blendedG = g * a + bg * (1 - a) + const blendedB = b * a + bg * (1 - a) + + // Return as hex with alpha encoded in the color itself + // For true alpha, we'd need rgba(), but this approximates it + return rgbToHex(blendedR, blendedG, blendedB) + }) +} + +/** + * Mix two colors together + */ +export function mixColors(color1: HexColor, color2: HexColor, amount: number): HexColor { + const c1 = hexToOklch(color1) + const c2 = hexToOklch(color2) + + return oklchToHex({ + l: c1.l + (c2.l - c1.l) * amount, + c: c1.c + (c2.c - c1.c) * amount, + h: c1.h + (c2.h - c1.h) * amount, + }) +} + +/** + * Lighten a color by a given amount (0-1) + */ +export function lighten(color: HexColor, amount: number): HexColor { + const oklch = hexToOklch(color) + return oklchToHex({ + ...oklch, + l: Math.min(1, oklch.l + amount), + }) +} + +/** + * Darken a color by a given amount (0-1) + */ +export function darken(color: HexColor, amount: number): HexColor { + const oklch = hexToOklch(color) + return oklchToHex({ + ...oklch, + l: Math.max(0, oklch.l - amount), + }) +} + +/** + * Adjust the alpha/opacity of a hex color (returns rgba string) + */ +export function withAlpha(color: HexColor, alpha: number): string { + const { r, g, b } = hexToRgb(color) + return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${alpha})` +} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx new file mode 100644 index 0000000000..d8ca6d5077 --- /dev/null +++ b/packages/ui/src/theme/context.tsx @@ -0,0 +1,280 @@ +/** + * Theme context for SolidJS applications. + * Provides reactive theme management with localStorage persistence and caching. + * + * Works in conjunction with the preload script to provide zero-FOUC theming: + * 1. Preload script applies cached CSS immediately from localStorage + * 2. ThemeProvider takes over, resolves theme, and updates cache + */ + +import { + createContext, + useContext, + createSignal, + onMount, + onCleanup, + createEffect, + type JSX, + type Accessor, +} from "solid-js" +import type { DesktopTheme } from "./types" +import { resolveThemeVariant, themeToCss } from "./resolve" +import { STORAGE_KEYS, getThemeCacheKey } from "./preload" +import { DEFAULT_THEMES } from "./default-themes" + +export type ColorScheme = "light" | "dark" | "system" + +interface ThemeContextValue { + /** Currently active theme ID */ + themeId: Accessor + /** Current color scheme preference */ + colorScheme: Accessor + /** Resolved current mode (light or dark) */ + mode: Accessor<"light" | "dark"> + /** All available themes */ + themes: Accessor> + /** Set the active theme by ID */ + setTheme: (id: string) => void + /** Set color scheme preference */ + setColorScheme: (scheme: ColorScheme) => void + /** Register a custom theme */ + registerTheme: (theme: DesktopTheme) => void +} + +const ThemeContext = createContext() + +/** + * Static tokens that don't change between themes + */ +const STATIC_TOKENS = ` + --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.16; + --letter-spacing-tightest: -0.32; + --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), 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), 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), 0 0 0 1px var(--border-selected), 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), 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), 0 0 0 3px var(--border-selected); +` + +const THEME_STYLE_ID = "oc-theme" + +function ensureThemeStyleElement(): 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 +} + +/** + * Resolve a mode from system preference + */ +function getSystemMode(): "light" | "dark" { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" +} + +/** + * Apply theme CSS to the document + */ +function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "dark"): void { + const isDark = mode === "dark" + const variant = isDark ? theme.dark : theme.light + const tokens = resolveThemeVariant(variant, isDark) + const css = themeToCss(tokens) + + // Cache to localStorage for preload script + const cacheKey = getThemeCacheKey(themeId, mode) + try { + localStorage.setItem(cacheKey, css) + } catch { + // localStorage might be full or disabled + } + + // Build full CSS + const fullCss = `:root { + ${STATIC_TOKENS} + color-scheme: ${mode}; + --text-mix-blend-mode: ${isDark ? "plus-lighter" : "multiply"}; + ${css} +}` + + // Remove preload style if it exists + const preloadStyle = document.getElementById("oc-theme-preload") + if (preloadStyle) { + preloadStyle.remove() + } + + const themeStyleElement = ensureThemeStyleElement() + themeStyleElement.textContent = fullCss + + // Update data attributes + document.documentElement.dataset.theme = themeId + document.documentElement.dataset.colorScheme = mode +} + +/** + * Cache both light and dark variants of a theme + */ +function cacheThemeVariants(theme: DesktopTheme, themeId: string): void { + for (const mode of ["light", "dark"] as const) { + const isDark = mode === "dark" + const variant = isDark ? theme.dark : theme.light + const tokens = resolveThemeVariant(variant, isDark) + const css = themeToCss(tokens) + const cacheKey = getThemeCacheKey(themeId, mode) + try { + localStorage.setItem(cacheKey, css) + } catch { + // localStorage might be full or disabled + } + } +} + +export function ThemeProvider(props: { children: JSX.Element; defaultTheme?: string }) { + const [themes, setThemes] = createSignal>(DEFAULT_THEMES) + const [themeId, setThemeIdSignal] = createSignal(props.defaultTheme ?? "oc-1") + const [colorScheme, setColorSchemeSignal] = createSignal("system") + const [mode, setMode] = createSignal<"light" | "dark">(getSystemMode()) + + // Listen for system color scheme changes + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handler = () => { + if (colorScheme() === "system") { + setMode(getSystemMode()) + } + } + mediaQuery.addEventListener("change", handler) + onCleanup(() => mediaQuery.removeEventListener("change", handler)) + + // Load saved preferences + const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) + const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null + + if (savedTheme && themes()[savedTheme]) { + setThemeIdSignal(savedTheme) + } + + if (savedScheme) { + setColorSchemeSignal(savedScheme) + if (savedScheme !== "system") { + setMode(savedScheme) + } + } + + // Cache current theme variants for future preloads + const currentTheme = themes()[themeId()] + if (currentTheme) { + cacheThemeVariants(currentTheme, themeId()) + } + }) + + // Apply theme when themeId or mode changes + createEffect(() => { + const id = themeId() + const m = mode() + const theme = themes()[id] + if (theme) { + applyThemeCss(theme, id, m) + } + }) + + const setTheme = (id: string) => { + const theme = themes()[id] + if (!theme) { + console.warn(`Theme "${id}" not found`) + return + } + + setThemeIdSignal(id) + localStorage.setItem(STORAGE_KEYS.THEME_ID, id) + + // Cache both variants for future preloads + cacheThemeVariants(theme, id) + } + + const setColorSchemePref = (scheme: ColorScheme) => { + setColorSchemeSignal(scheme) + localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + + if (scheme === "system") { + setMode(getSystemMode()) + } else { + setMode(scheme) + } + } + + const registerTheme = (theme: DesktopTheme) => { + setThemes((prev) => ({ + ...prev, + [theme.id]: theme, + })) + } + + return ( + + {props.children} + + ) +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext) + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider") + } + return ctx +} diff --git a/packages/ui/src/theme/default-themes.ts b/packages/ui/src/theme/default-themes.ts new file mode 100644 index 0000000000..749d5e97cc --- /dev/null +++ b/packages/ui/src/theme/default-themes.ts @@ -0,0 +1,35 @@ +import type { DesktopTheme } from "./types" +import oc1ThemeJson from "./themes/oc-1.json" +import tokyoThemeJson from "./themes/tokyonight.json" +import draculaThemeJson from "./themes/dracula.json" +import monokaiThemeJson from "./themes/monokai.json" +import solarizedThemeJson from "./themes/solarized.json" +import nordThemeJson from "./themes/nord.json" +import catppuccinThemeJson from "./themes/catppuccin.json" +import ayuThemeJson from "./themes/ayu.json" +import oneDarkProThemeJson from "./themes/onedarkpro.json" +import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json" + +export const oc1Theme = oc1ThemeJson as DesktopTheme +export const tokyonightTheme = tokyoThemeJson as DesktopTheme +export const draculaTheme = draculaThemeJson as DesktopTheme +export const monokaiTheme = monokaiThemeJson as DesktopTheme +export const solarizedTheme = solarizedThemeJson as DesktopTheme +export const nordTheme = nordThemeJson as DesktopTheme +export const catppuccinTheme = catppuccinThemeJson as DesktopTheme +export const ayuTheme = ayuThemeJson as DesktopTheme +export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme +export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme + +export const DEFAULT_THEMES: Record = { + "oc-1": oc1Theme, + tokyonight: tokyonightTheme, + dracula: draculaTheme, + monokai: monokaiTheme, + solarized: solarizedTheme, + nord: nordTheme, + catppuccin: catppuccinTheme, + ayu: ayuTheme, + onedarkpro: oneDarkProTheme, + shadesofpurple: shadesOfPurpleTheme, +} diff --git a/packages/ui/src/theme/desktop-theme.schema.json b/packages/ui/src/theme/desktop-theme.schema.json new file mode 100644 index 0000000000..b60a8f37ca --- /dev/null +++ b/packages/ui/src/theme/desktop-theme.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://opencode.ai/desktop-theme.json", + "title": "OpenCode Desktop Theme", + "description": "A theme definition for the OpenCode desktop application", + "type": "object", + "required": ["name", "id", "light", "dark"], + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference" + }, + "name": { + "type": "string", + "description": "Human-readable theme name" + }, + "id": { + "type": "string", + "description": "Unique theme identifier (slug)", + "pattern": "^[a-z0-9-]+$" + }, + "light": { + "$ref": "#/definitions/ThemeVariant", + "description": "Light mode color variant" + }, + "dark": { + "$ref": "#/definitions/ThemeVariant", + "description": "Dark mode color variant" + } + }, + "definitions": { + "HexColor": { + "type": "string", + "pattern": "^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", + "description": "A hex color value like #fff, #ffff, #ffffff, or #ffffffff" + }, + "ColorValue": { + "type": "string", + "pattern": "^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|var\(--[a-z0-9-]+\))$", + "description": "Either a hex color value (#rgb/#rgba/#rrggbb/#rrggbbaa) or a CSS variable reference" + }, + "ThemeSeedColors": { + "type": "object", + "description": "The minimum set of colors needed to generate a theme", + "required": ["neutral", "primary", "success", "warning", "error", "info", "interactive", "diffAdd", "diffDelete"], + "properties": { + "neutral": { + "$ref": "#/definitions/HexColor", + "description": "Base neutral color for generating the gray scale" + }, + "primary": { + "$ref": "#/definitions/HexColor", + "description": "Primary brand/accent color" + }, + "success": { + "$ref": "#/definitions/HexColor", + "description": "Success state color (typically green)" + }, + "warning": { + "$ref": "#/definitions/HexColor", + "description": "Warning state color (typically yellow/orange)" + }, + "error": { + "$ref": "#/definitions/HexColor", + "description": "Error/critical state color (typically red)" + }, + "info": { + "$ref": "#/definitions/HexColor", + "description": "Informational state color (typically purple/blue)" + }, + "interactive": { + "$ref": "#/definitions/HexColor", + "description": "Interactive element color (links, buttons)" + }, + "diffAdd": { + "$ref": "#/definitions/HexColor", + "description": "Color for diff additions" + }, + "diffDelete": { + "$ref": "#/definitions/HexColor", + "description": "Color for diff deletions" + } + } + }, + "ThemeVariant": { + "type": "object", + "description": "A theme variant (light or dark) with seed colors and optional overrides", + "required": ["seeds"], + "properties": { + "seeds": { + "$ref": "#/definitions/ThemeSeedColors", + "description": "Seed colors used to generate the full palette" + }, + "overrides": { + "type": "object", + "description": "Optional direct overrides for any CSS variable (without -- prefix)", + "additionalProperties": { + "$ref": "#/definitions/ColorValue" + } + } + } + } + } +} diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts new file mode 100644 index 0000000000..8f3da4ca13 --- /dev/null +++ b/packages/ui/src/theme/index.ts @@ -0,0 +1,71 @@ +/** + * Desktop Theme System + * + * Provides JSON-based theming for the desktop app. Unlike TUI themes, + * desktop themes use more design tokens and generate full color scales + * from seed colors. + * + * Usage: + * ```ts + * import { applyTheme } from "@opencode/ui/theme" + * import myTheme from "./themes/my-theme.json" + * + * applyTheme(myTheme) + * ``` + */ + +// Types +export type { + DesktopTheme, + ThemeSeedColors, + ThemeVariant, + HexColor, + OklchColor, + ResolvedTheme, + ColorValue, + CssVarRef, +} from "./types" + +// Color utilities +export { + hexToRgb, + rgbToHex, + hexToOklch, + oklchToHex, + rgbToOklch, + oklchToRgb, + generateScale, + generateNeutralScale, + generateAlphaScale, + mixColors, + lighten, + darken, + withAlpha, +} from "./color" + +// Theme resolution +export { resolveThemeVariant, resolveTheme, themeToCss } from "./resolve" + +// Theme loader +export { applyTheme, loadThemeFromUrl, getActiveTheme, removeTheme, setColorScheme } from "./loader" + +// Theme context (SolidJS) +export { ThemeProvider, useTheme, type ColorScheme } from "./context" + +// Preload script utilities +export { generatePreloadScript, generatePreloadScriptFormatted, STORAGE_KEYS, getThemeCacheKey } from "./preload" + +// Default themes +export { + DEFAULT_THEMES, + oc1Theme, + tokyonightTheme, + draculaTheme, + monokaiTheme, + solarizedTheme, + nordTheme, + catppuccinTheme, + ayuTheme, + oneDarkProTheme, + shadesOfPurpleTheme, +} from "./default-themes" diff --git a/packages/ui/src/theme/loader.ts b/packages/ui/src/theme/loader.ts new file mode 100644 index 0000000000..b25c833dd3 --- /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