diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index 4d7300d1f050..58070db33550 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -20,8 +20,10 @@ import { parseThemeFile, removeCustomTheme, themeIdFromName, + updateThemeColorFamily, updateCustomTheme, type ThemeAppearance, + type ThemeColors, type ThemeColorRole, type ThemeDefinition, } from "../../themePalette"; @@ -42,62 +44,189 @@ import { type ThemeElementInspection, } from "./themeInspector"; -const THEME_EDITOR_PRIMARY_ROLES: ReadonlyArray = [ - "canvas", - "chrome", - "sidebar", - "surface", - "text", - "textMuted", - "placeholder", - "secondaryLabel", - "iconMuted", - "accent", - "messageSurface", - "messageAction", -]; - const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray = ["canvas", "accent"]; -const THEME_EDITOR_STATUS_ROLES: ReadonlyArray = [ - "error", - "errorForeground", - "errorSurface", - "warning", - "warningForeground", - "warningSurface", - "update", - "updateForeground", - "updateSurface", -]; - -const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter( - (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role), -); +type ThemeEditorColorFamily = Readonly<{ + id: string; + label: string; + role: ThemeColorRole; + roles: ReadonlyArray; +}>; const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{ id: string; title: string; - roles: ReadonlyArray; + families: ReadonlyArray; }> = [ { - id: "main", - title: "Main colors", - roles: THEME_EDITOR_PRIMARY_ROLES, + id: "foundation", + title: "Foundation", + families: [ + { + id: "background", + label: "Background", + role: "canvas", + roles: ["canvas", "chrome", "toolbar"], + }, + { id: "surface", label: "Surface", role: "surface", roles: ["surface"] }, + { + id: "raised-surface", + label: "Raised surface", + role: "surfaceRaised", + roles: ["surfaceRaised"], + }, + { + id: "overlay", + label: "Overlay", + role: "surfaceOverlay", + roles: ["surfaceOverlay"], + }, + { + id: "text", + label: "Text", + role: "text", + roles: ["text", "toolbarForeground", "toolbarControlForeground"], + }, + { + id: "muted-text", + label: "Muted text", + role: "mutedForeground", + roles: [ + "textMuted", + "mutedForeground", + "placeholder", + "secondaryLabel", + "iconMuted", + "sidebarMutedForeground", + ], + }, + { + id: "border", + label: "Border", + role: "border", + roles: ["border", "toolbarBorder", "sidebarBorder"], + }, + { id: "input", label: "Input", role: "input", roles: ["input"] }, + ], }, { - id: "status", - title: "Status colors", - roles: THEME_EDITOR_STATUS_ROLES, + id: "brand-content", + title: "Brand & content", + families: [ + { + id: "subtle-surface", + label: "Subtle surface", + role: "secondary", + roles: ["secondary", "secondaryForeground", "muted", "toolbarControl"], + }, + { + id: "highlight-surface", + label: "Highlight surface", + role: "accentSurface", + roles: ["accentSurface", "accentSurfaceForeground", "toolbarControlHover"], + }, + { + id: "accent", + label: "Accent", + role: "accent", + roles: [ + "accent", + "accentForeground", + "focus", + "update", + "updateForeground", + "updateSurface", + "terminalCursor", + ], + }, + { + id: "action", + label: "Action", + role: "messageAction", + roles: ["messageAction", "messageActionForeground", "messageActionHover"], + }, + { + id: "message-surface", + label: "Message surface", + role: "messageSurface", + roles: ["messageSurface", "messageForeground"], + }, + { + id: "code-surface", + label: "Code surface", + role: "codeBackground", + roles: ["codeBackground", "codeForeground"], + }, + ], }, { - id: "additional", - title: "Other colors", - roles: THEME_EDITOR_ADVANCED_ROLES, + id: "context", + title: "Context", + families: [ + { + id: "sidebar-background", + label: "Sidebar background", + role: "sidebar", + roles: ["sidebar", "sidebarForeground"], + }, + { + id: "sidebar-controls", + label: "Sidebar controls", + role: "sidebarControlSurface", + roles: ["sidebarControlSurface"], + }, + { + id: "sidebar-selection", + label: "Sidebar selection", + role: "sidebarRowSelected", + roles: ["sidebarRowHover", "sidebarRowActive", "sidebarRowSelected"], + }, + { + id: "terminal-background", + label: "Terminal background", + role: "terminalBackground", + roles: [ + "terminalBackground", + "terminalForeground", + "terminalSelection", + "terminalScrollbar", + "terminalScrollbarHover", + ], + }, + ], + }, + { + id: "status", + title: "Status", + families: [ + { + id: "error", + label: "Error", + role: "error", + roles: ["error", "errorForeground", "errorSurface"], + }, + { + id: "warning", + label: "Warning", + role: "warning", + roles: ["warning", "warningForeground", "warningSurface"], + }, + ], }, ]; -type ThemeEditorColors = Record; +const THEME_EDITOR_COLOR_FAMILIES = THEME_EDITOR_ROLE_GROUPS.flatMap((group) => group.families); +const THEME_EDITOR_COLOR_FAMILY_BY_ROLE = new Map( + THEME_EDITOR_COLOR_FAMILIES.flatMap((family) => + family.roles.map((role) => [role, family] as const), + ), +); + +function getThemeEditorColorFamily(role: ThemeColorRole): ThemeEditorColorFamily | null { + return THEME_EDITOR_COLOR_FAMILY_BY_ROLE.get(role) ?? null; +} + +type ThemeEditorColors = ThemeColors; type ThemeEditorColorsByAppearance = Record; // A draft with no source theme starts as the standard T3 Code look — the @@ -348,9 +477,11 @@ export function ThemeEditorPanel({ return { ...current, - [activeAppearance]: shouldManageColors - ? getManagedEditorColors(activeAppearance, nextColors) - : nextColors, + [activeAppearance]: isAdvanced + ? updateThemeColorFamily(activeAppearance, current[activeAppearance], role, value) + : shouldManageColors + ? getManagedEditorColors(activeAppearance, nextColors) + : nextColors, }; }); if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) { @@ -364,8 +495,9 @@ export function ThemeEditorPanel({ ); const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => { - setSelectedRole(role); - if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) { + const visibleRole = getThemeEditorColorFamily(role)?.role ?? role; + setSelectedRole(visibleRole); + if (!THEME_EDITOR_SIMPLE_ROLES.includes(visibleRole)) { setIsAdvanced(true); setRoleQuery(""); } @@ -373,7 +505,7 @@ export function ThemeEditorPanel({ requestAnimationFrame(() => { panelRef.current - ?.querySelector(`[data-theme-color-role="${role}"]`) + ?.querySelector(`[data-theme-color-role="${visibleRole}"]`) ?.scrollIntoView({ behavior: "smooth", block: "nearest" }); }); }, []); @@ -389,13 +521,15 @@ export function ThemeEditorPanel({ }, []); const selectedHighlightRoles = selectedRole - ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) - ? THEME_COLOR_ROLES.filter( - (role) => - colorsByAppearance[activeAppearance][role].trim().toLowerCase() === - colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), - ) - : [selectedRole] + ? isAdvanced + ? (getThemeEditorColorFamily(selectedRole)?.roles ?? [selectedRole]) + : THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole) + ? THEME_COLOR_ROLES.filter( + (role) => + colorsByAppearance[activeAppearance][role].trim().toLowerCase() === + colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(), + ) + : [selectedRole] : []; const selectedHighlightRolesKey = selectedHighlightRoles.join(","); @@ -490,7 +624,10 @@ export function ThemeEditorPanel({ }; const showInspection = (inspection: ThemeElementInspection) => { hoverInspection = inspection; - showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role)); + showThemeInspectorHover( + inspection, + getThemeEditorColorFamily(inspection.role)?.label ?? getThemeRoleLabel(inspection.role), + ); }; const handlePointerOver = (event: PointerEvent) => { const target = event.target; @@ -553,7 +690,11 @@ export function ThemeEditorPanel({ hoverFrame ??= requestAnimationFrame(() => { hoverFrame = null; if (hoverInspection) { - showThemeInspectorHover(hoverInspection, getThemeRoleLabel(hoverInspection.role)); + showThemeInspectorHover( + hoverInspection, + getThemeEditorColorFamily(hoverInspection.role)?.label ?? + getThemeRoleLabel(hoverInspection.role), + ); } }); }; @@ -854,19 +995,20 @@ export function ThemeEditorPanel({ ); const renderRoleFields = ( - roles: ReadonlyArray, + families: ReadonlyArray, gridClassName = "grid gap-2 sm:grid-cols-2", ) => (
- {roles.map((role) => ( + {families.map((family) => ( ))}
@@ -876,16 +1018,21 @@ export function ThemeEditorPanel({ const query = roleQuery.trim().toLowerCase(); const groups = THEME_EDITOR_ROLE_GROUPS.map((group) => ({ ...group, - roles: group.roles.filter( - (role) => !query || getThemeRoleLabel(role).toLowerCase().includes(query), + families: group.families.filter( + (family) => + !query || + [family.label, ...family.roles.map((role) => getThemeRoleLabel(role))] + .join(" ") + .toLowerCase() + .includes(query), ), - })).filter((group) => group.roles.length > 0); + })).filter((group) => group.families.length > 0); return isAdvanced ? (
{groups.map((group) => (

{group.title}

- {renderRoleFields(group.roles, "grid gap-1")} + {renderRoleFields(group.families, "grid gap-1")}
))} {groups.length === 0 ?

No matches.

: null} @@ -1018,7 +1165,7 @@ export function ThemeEditorPanel({ {isInspecting ? "Select an element · Esc to cancel" : selectedRole - ? `${getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}` + ? `${isAdvanced ? (getThemeEditorColorFamily(selectedRole)?.label ?? getThemeRoleLabel(selectedRole)) : getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}` : "Select a color below"}

)} diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 7bf4f0426bd3..ec75cbe5b758 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1468,6 +1468,198 @@ function themeActionColors( }; } +/** + * Update one Advanced-editor color family without normalizing the rest of an + * imported or hand-tuned palette. The editor exposes a representative role + * for each family; paired foregrounds and nearby states are derived only when + * that representative is changed. + */ +export function updateThemeColorFamily( + appearance: ThemeAppearance, + colors: ThemeColors, + role: ThemeColorRole, + value: string, +): ThemeColors { + const parsedSelected = parseThemeColor(value); + if (!parsedSelected) return { ...colors, [role]: value }; + const normalized = formatOklchThemeColor(parsedSelected.color, parsedSelected.alpha); + + const canvas = parseThemeRgbColor( + colors.canvas, + appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, + ); + const selected = themeOklchToRgb(parsedSelected.color); + const selectedOn = (background: ThemeRgbColor) => + mixThemeRgbColors(background, selected, parsedSelected.alpha); + const selectedOnCanvas = selectedOn(canvas); + const accent = parseThemeRgbColor(colors.accent, { r: 168, g: 67, b: 112 }); + const canvasIsDark = themeRelativeLuminance(canvas) < 0.179; + const terminalIsDark = themeRelativeLuminance(selectedOnCanvas) < 0.179; + const colorOf = (color: ThemeRgbColor) => themeRgbToThemeColor(color); + const foregroundOn = (background: ThemeRgbColor) => colorOf(readableThemeForeground(background)); + const selectedToneOn = (background: ThemeRgbColor) => + themeOklchToThemeColor( + solveOklchLightness( + parsedSelected.color, + background, + 4.6, + themeRelativeLuminance(background) < 0.179 ? "lighter" : "darker", + ), + ); + const statusColors = () => { + const surface = mixThemeRgbColors(canvas, selectedOnCanvas, canvasIsDark ? 0.16 : 0.08); + return { + foreground: selectedToneOn(surface), + surface: colorOf(surface), + }; + }; + + switch (role) { + case "canvas": + return { ...colors, canvas: normalized, chrome: normalized, toolbar: normalized }; + case "surface": + case "surfaceRaised": + case "surfaceOverlay": + case "input": + case "sidebarControlSurface": + return { ...colors, [role]: normalized }; + case "text": + return { + ...colors, + text: normalized, + toolbarForeground: normalized, + toolbarControlForeground: normalized, + }; + case "mutedForeground": + return { + ...colors, + textMuted: normalized, + mutedForeground: normalized, + placeholder: normalized, + secondaryLabel: normalized, + iconMuted: normalized, + sidebarMutedForeground: normalized, + }; + case "border": + return { + ...colors, + border: normalized, + toolbarBorder: normalized, + sidebarBorder: normalized, + }; + case "secondary": + return { + ...colors, + secondary: normalized, + secondaryForeground: foregroundOn(selectedOnCanvas), + muted: normalized, + toolbarControl: normalized, + }; + case "accentSurface": + return { + ...colors, + accentSurface: normalized, + accentSurfaceForeground: foregroundOn(selectedOnCanvas), + toolbarControlHover: normalized, + }; + case "accent": { + const updateSurface = mixThemeRgbColors(canvas, selectedOnCanvas, canvasIsDark ? 0.32 : 0.16); + return { + ...colors, + accent: normalized, + accentForeground: foregroundOn(selectedOnCanvas), + focus: normalized, + update: normalized, + updateForeground: selectedToneOn(updateSurface), + updateSurface: colorOf(updateSurface), + terminalCursor: normalized, + }; + } + case "messageAction": { + const actionForeground = readableThemeForeground(selectedOnCanvas); + const towardOpposite = + actionForeground === THEME_LIGHT_FOREGROUND || actionForeground === THEME_WHITE_FOREGROUND + ? THEME_BLACK_FOREGROUND + : THEME_WHITE_FOREGROUND; + const actionHover = mixThemeRgbColors(selected, towardOpposite, 0.12); + return { + ...colors, + messageAction: normalized, + messageActionForeground: colorOf(actionForeground), + messageActionHover: formatOklchThemeColor( + themeRgbToOklch(actionHover), + parsedSelected.alpha, + ), + }; + } + case "messageSurface": + return { + ...colors, + messageSurface: normalized, + messageForeground: foregroundOn(selectedOnCanvas), + }; + case "codeBackground": + return { + ...colors, + codeBackground: normalized, + codeForeground: foregroundOn(selectedOnCanvas), + }; + case "sidebar": + return { + ...colors, + sidebar: normalized, + sidebarForeground: foregroundOn(selectedOnCanvas), + }; + case "sidebarRowSelected": { + const sidebar = parseThemeRgbColor(colors.sidebar, canvas); + const selectedOnSidebar = selectedOn(sidebar); + return { + ...colors, + sidebarRowHover: colorOf(mixThemeRgbColors(sidebar, selectedOnSidebar, 0.5)), + sidebarRowActive: colorOf(mixThemeRgbColors(sidebar, selectedOnSidebar, 0.8)), + sidebarRowSelected: normalized, + }; + } + case "terminalBackground": { + const terminalForeground = readableThemeForeground(selectedOnCanvas); + return { + ...colors, + terminalBackground: normalized, + terminalForeground: colorOf(terminalForeground), + terminalSelection: colorOf( + mixThemeRgbColors(selectedOnCanvas, accent, terminalIsDark ? 0.35 : 0.18), + ), + terminalScrollbar: colorOf( + mixThemeRgbColors(selectedOnCanvas, terminalForeground, terminalIsDark ? 0.42 : 0.22), + ), + terminalScrollbarHover: colorOf( + mixThemeRgbColors(selectedOnCanvas, terminalForeground, terminalIsDark ? 0.55 : 0.32), + ), + }; + } + case "error": { + const status = statusColors(); + return { + ...colors, + error: normalized, + errorForeground: status.foreground, + errorSurface: status.surface, + }; + } + case "warning": { + const status = statusColors(); + return { + ...colors, + warning: normalized, + warningForeground: status.foreground, + warningSurface: status.surface, + }; + } + default: + return { ...colors, [role]: normalized }; + } +} + export const GROVE_THEME: ThemeDefinition = { id: GROVE_THEME_ID, label: GROVE_THEME_LABEL, diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 63264bed10b5..8e56a79a287d 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -43,8 +43,10 @@ Repeating either shortcut closes that search, and switching shortcuts replaces t `themeEditor.toggle` opens or closes the floating theme editor and defaults to `mod+alt+shift+t`. Select a color label to spotlight the elements that use it; select the label again to clear the spotlight. The swatch and hex field keep that color selected while you edit it. +Advanced mode groups related app tokens into a smaller set of color families. Changing a family +updates its paired text and interaction states while leaving every unrelated imported color intact. Use **Inspect** to pick an element in the app and reveal its color token. Inspect disarms after one -successful pick; its hover glow and badge preview the element and token that click will select. +successful pick; its hover glow and badge preview the element and color family that click will select. **Cancel** or `Escape` exits Inspect and clears its selection and spotlight. `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,