authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-15 20:59:13+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 14:59:13-04:00
log2f486ab80c748b4d8e3d3b17e49b5a327cb93335
tree457cc46729ba0c4793a4472e2634569625cbf839
parente58cbb9e75e97448f88d0fe2eccc8a45cd980394
signature Signed by PGP key B5690EEEBB952194

refactor(web): simplify advanced theme controls (#7107)


3 files changed, 409 insertions(+), 68 deletions(-)

apps/web/src/components/settings/ThemeEditorPanel.tsx+214-67
......@@ -20,8 +20,10 @@ import {
2020 parseThemeFile,
2121 removeCustomTheme,
2222 themeIdFromName,
23 updateThemeColorFamily,
2324 updateCustomTheme,
2425 type ThemeAppearance,
26 type ThemeColors,
2527 type ThemeColorRole,
2628 type ThemeDefinition,
2729} from "../../themePalette";
......@@ -42,62 +44,189 @@ import {
4244 type ThemeElementInspection,
4345} from "./themeInspector";
4446
45const THEME_EDITOR_PRIMARY_ROLES: ReadonlyArray<ThemeColorRole> = [
46 "canvas",
47 "chrome",
48 "sidebar",
49 "surface",
50 "text",
51 "textMuted",
52 "placeholder",
53 "secondaryLabel",
54 "iconMuted",
55 "accent",
56 "messageSurface",
57 "messageAction",
58];
59
6047const THEME_EDITOR_SIMPLE_ROLES: ReadonlyArray<ThemeColorRole> = ["canvas", "accent"];
6148
62const THEME_EDITOR_STATUS_ROLES: ReadonlyArray<ThemeColorRole> = [
63 "error",
64 "errorForeground",
65 "errorSurface",
66 "warning",
67 "warningForeground",
68 "warningSurface",
69 "update",
70 "updateForeground",
71 "updateSurface",
72];
73
74const THEME_EDITOR_ADVANCED_ROLES = THEME_COLOR_ROLES.filter(
75 (role) => !THEME_EDITOR_PRIMARY_ROLES.includes(role) && !THEME_EDITOR_STATUS_ROLES.includes(role),
76);
49type ThemeEditorColorFamily = Readonly<{
50 id: string;
51 label: string;
52 role: ThemeColorRole;
53 roles: ReadonlyArray<ThemeColorRole>;
54}>;
7755
7856const THEME_EDITOR_ROLE_GROUPS: ReadonlyArray<{
7957 id: string;
8058 title: string;
81 roles: ReadonlyArray<ThemeColorRole>;
59 families: ReadonlyArray<ThemeEditorColorFamily>;
8260}> = [
8361 {
84 id: "main",
85 title: "Main colors",
86 roles: THEME_EDITOR_PRIMARY_ROLES,
62 id: "foundation",
63 title: "Foundation",
64 families: [
65 {
66 id: "background",
67 label: "Background",
68 role: "canvas",
69 roles: ["canvas", "chrome", "toolbar"],
70 },
71 { id: "surface", label: "Surface", role: "surface", roles: ["surface"] },
72 {
73 id: "raised-surface",
74 label: "Raised surface",
75 role: "surfaceRaised",
76 roles: ["surfaceRaised"],
77 },
78 {
79 id: "overlay",
80 label: "Overlay",
81 role: "surfaceOverlay",
82 roles: ["surfaceOverlay"],
83 },
84 {
85 id: "text",
86 label: "Text",
87 role: "text",
88 roles: ["text", "toolbarForeground", "toolbarControlForeground"],
89 },
90 {
91 id: "muted-text",
92 label: "Muted text",
93 role: "mutedForeground",
94 roles: [
95 "textMuted",
96 "mutedForeground",
97 "placeholder",
98 "secondaryLabel",
99 "iconMuted",
100 "sidebarMutedForeground",
101 ],
102 },
103 {
104 id: "border",
105 label: "Border",
106 role: "border",
107 roles: ["border", "toolbarBorder", "sidebarBorder"],
108 },
109 { id: "input", label: "Input", role: "input", roles: ["input"] },
110 ],
87111 },
88112 {
89 id: "status",
90 title: "Status colors",
91 roles: THEME_EDITOR_STATUS_ROLES,
113 id: "brand-content",
114 title: "Brand & content",
115 families: [
116 {
117 id: "subtle-surface",
118 label: "Subtle surface",
119 role: "secondary",
120 roles: ["secondary", "secondaryForeground", "muted", "toolbarControl"],
121 },
122 {
123 id: "highlight-surface",
124 label: "Highlight surface",
125 role: "accentSurface",
126 roles: ["accentSurface", "accentSurfaceForeground", "toolbarControlHover"],
127 },
128 {
129 id: "accent",
130 label: "Accent",
131 role: "accent",
132 roles: [
133 "accent",
134 "accentForeground",
135 "focus",
136 "update",
137 "updateForeground",
138 "updateSurface",
139 "terminalCursor",
140 ],
141 },
142 {
143 id: "action",
144 label: "Action",
145 role: "messageAction",
146 roles: ["messageAction", "messageActionForeground", "messageActionHover"],
147 },
148 {
149 id: "message-surface",
150 label: "Message surface",
151 role: "messageSurface",
152 roles: ["messageSurface", "messageForeground"],
153 },
154 {
155 id: "code-surface",
156 label: "Code surface",
157 role: "codeBackground",
158 roles: ["codeBackground", "codeForeground"],
159 },
160 ],
92161 },
93162 {
94 id: "additional",
95 title: "Other colors",
96 roles: THEME_EDITOR_ADVANCED_ROLES,
163 id: "context",
164 title: "Context",
165 families: [
166 {
167 id: "sidebar-background",
168 label: "Sidebar background",
169 role: "sidebar",
170 roles: ["sidebar", "sidebarForeground"],
171 },
172 {
173 id: "sidebar-controls",
174 label: "Sidebar controls",
175 role: "sidebarControlSurface",
176 roles: ["sidebarControlSurface"],
177 },
178 {
179 id: "sidebar-selection",
180 label: "Sidebar selection",
181 role: "sidebarRowSelected",
182 roles: ["sidebarRowHover", "sidebarRowActive", "sidebarRowSelected"],
183 },
184 {
185 id: "terminal-background",
186 label: "Terminal background",
187 role: "terminalBackground",
188 roles: [
189 "terminalBackground",
190 "terminalForeground",
191 "terminalSelection",
192 "terminalScrollbar",
193 "terminalScrollbarHover",
194 ],
195 },
196 ],
197 },
198 {
199 id: "status",
200 title: "Status",
201 families: [
202 {
203 id: "error",
204 label: "Error",
205 role: "error",
206 roles: ["error", "errorForeground", "errorSurface"],
207 },
208 {
209 id: "warning",
210 label: "Warning",
211 role: "warning",
212 roles: ["warning", "warningForeground", "warningSurface"],
213 },
214 ],
97215 },
98216];
99217
100type ThemeEditorColors = Record<ThemeColorRole, string>;
218const THEME_EDITOR_COLOR_FAMILIES = THEME_EDITOR_ROLE_GROUPS.flatMap((group) => group.families);
219const THEME_EDITOR_COLOR_FAMILY_BY_ROLE = new Map(
220 THEME_EDITOR_COLOR_FAMILIES.flatMap((family) =>
221 family.roles.map((role) => [role, family] as const),
222 ),
223);
224
225function getThemeEditorColorFamily(role: ThemeColorRole): ThemeEditorColorFamily | null {
226 return THEME_EDITOR_COLOR_FAMILY_BY_ROLE.get(role) ?? null;
227}
228
229type ThemeEditorColors = ThemeColors;
101230type ThemeEditorColorsByAppearance = Record<ThemeAppearance, ThemeEditorColors>;
102231
103232// A draft with no source theme starts as the standard T3 Code look — the
......@@ -348,9 +477,11 @@ export function ThemeEditorPanel({
348477
349478 return {
350479 ...current,
351 [activeAppearance]: shouldManageColors
352 ? getManagedEditorColors(activeAppearance, nextColors)
353 : nextColors,
480 [activeAppearance]: isAdvanced
481 ? updateThemeColorFamily(activeAppearance, current[activeAppearance], role, value)
482 : shouldManageColors
483 ? getManagedEditorColors(activeAppearance, nextColors)
484 : nextColors,
354485 };
355486 });
356487 if (!isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(role) && isThemeEditorColor(value)) {
......@@ -364,8 +495,9 @@ export function ThemeEditorPanel({
364495 );
365496
366497 const selectThemeRole = useCallback((role: ThemeColorRole, reveal = false) => {
367 setSelectedRole(role);
368 if (!THEME_EDITOR_SIMPLE_ROLES.includes(role)) {
498 const visibleRole = getThemeEditorColorFamily(role)?.role ?? role;
499 setSelectedRole(visibleRole);
500 if (!THEME_EDITOR_SIMPLE_ROLES.includes(visibleRole)) {
369501 setIsAdvanced(true);
370502 setRoleQuery("");
371503 }
......@@ -373,7 +505,7 @@ export function ThemeEditorPanel({
373505
374506 requestAnimationFrame(() => {
375507 panelRef.current
376 ?.querySelector(`[data-theme-color-role="${role}"]`)
508 ?.querySelector(`[data-theme-color-role="${visibleRole}"]`)
377509 ?.scrollIntoView({ behavior: "smooth", block: "nearest" });
378510 });
379511 }, []);
......@@ -389,13 +521,15 @@ export function ThemeEditorPanel({
389521 }, []);
390522
391523 const selectedHighlightRoles = selectedRole
392 ? !isAdvanced && THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)
393 ? THEME_COLOR_ROLES.filter(
394 (role) =>
395 colorsByAppearance[activeAppearance][role].trim().toLowerCase() ===
396 colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(),
397 )
398 : [selectedRole]
524 ? isAdvanced
525 ? (getThemeEditorColorFamily(selectedRole)?.roles ?? [selectedRole])
526 : THEME_EDITOR_SIMPLE_ROLES.includes(selectedRole)
527 ? THEME_COLOR_ROLES.filter(
528 (role) =>
529 colorsByAppearance[activeAppearance][role].trim().toLowerCase() ===
530 colorsByAppearance[activeAppearance][selectedRole].trim().toLowerCase(),
531 )
532 : [selectedRole]
399533 : [];
400534 const selectedHighlightRolesKey = selectedHighlightRoles.join(",");
401535
......@@ -490,7 +624,10 @@ export function ThemeEditorPanel({
490624 };
491625 const showInspection = (inspection: ThemeElementInspection) => {
492626 hoverInspection = inspection;
493 showThemeInspectorHover(inspection, getThemeRoleLabel(inspection.role));
627 showThemeInspectorHover(
628 inspection,
629 getThemeEditorColorFamily(inspection.role)?.label ?? getThemeRoleLabel(inspection.role),
630 );
494631 };
495632 const handlePointerOver = (event: PointerEvent) => {
496633 const target = event.target;
......@@ -553,7 +690,11 @@ export function ThemeEditorPanel({
553690 hoverFrame ??= requestAnimationFrame(() => {
554691 hoverFrame = null;
555692 if (hoverInspection) {
556 showThemeInspectorHover(hoverInspection, getThemeRoleLabel(hoverInspection.role));
693 showThemeInspectorHover(
694 hoverInspection,
695 getThemeEditorColorFamily(hoverInspection.role)?.label ??
696 getThemeRoleLabel(hoverInspection.role),
697 );
557698 }
558699 });
559700 };
......@@ -854,19 +995,20 @@ export function ThemeEditorPanel({
854995 );
855996
856997 const renderRoleFields = (
857 roles: ReadonlyArray<ThemeColorRole>,
998 families: ReadonlyArray<ThemeEditorColorFamily>,
858999 gridClassName = "grid gap-2 sm:grid-cols-2",
8591000 ) => (
8601001 <div className={gridClassName}>
861 {roles.map((role) => (
1002 {families.map((family) => (
8621003 <ThemeColorField
863 key={role}
1004 key={family.id}
1005 label={family.label}
8641006 onChange={updateColor}
8651007 onSelect={selectThemeRole}
8661008 onToggleSelected={toggleThemeRole}
867 role={role}
868 selected={selectedRole === role}
869 value={colorsByAppearance[activeAppearance][role]}
1009 role={family.role}
1010 selected={selectedRole === family.role}
1011 value={colorsByAppearance[activeAppearance][family.role]}
8701012 />
8711013 ))}
8721014 </div>
......@@ -876,16 +1018,21 @@ export function ThemeEditorPanel({
8761018 const query = roleQuery.trim().toLowerCase();
8771019 const groups = THEME_EDITOR_ROLE_GROUPS.map((group) => ({
8781020 ...group,
879 roles: group.roles.filter(
880 (role) => !query || getThemeRoleLabel(role).toLowerCase().includes(query),
1021 families: group.families.filter(
1022 (family) =>
1023 !query ||
1024 [family.label, ...family.roles.map((role) => getThemeRoleLabel(role))]
1025 .join(" ")
1026 .toLowerCase()
1027 .includes(query),
8811028 ),
882 })).filter((group) => group.roles.length > 0);
1029 })).filter((group) => group.families.length > 0);
8831030 return isAdvanced ? (
8841031 <div className="space-y-5">
8851032 {groups.map((group) => (
8861033 <section className="space-y-2" key={group.id}>
8871034 <h4 className="text-sm font-medium text-foreground">{group.title}</h4>
888 {renderRoleFields(group.roles, "grid gap-1")}
1035 {renderRoleFields(group.families, "grid gap-1")}
8891036 </section>
8901037 ))}
8911038 {groups.length === 0 ? <p className="text-xs text-muted-foreground">No matches.</p> : null}
......@@ -1018,7 +1165,7 @@ export function ThemeEditorPanel({
10181165 {isInspecting
10191166 ? "Select an element · Esc to cancel"
10201167 : selectedRole
1021 ? `${getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}`
1168 ? `${isAdvanced ? (getThemeEditorColorFamily(selectedRole)?.label ?? getThemeRoleLabel(selectedRole)) : getThemeRoleLabel(selectedRole)} · ${usageCount ?? 0} ${usageCount === 1 ? "use" : "uses"}`
10221169 : "Select a color below"}
10231170 </p>
10241171 )}
apps/web/src/themePalette.ts+192
......@@ -1468,6 +1468,198 @@ function themeActionColors(
14681468 };
14691469}
14701470
1471/**
1472 * Update one Advanced-editor color family without normalizing the rest of an
1473 * imported or hand-tuned palette. The editor exposes a representative role
1474 * for each family; paired foregrounds and nearby states are derived only when
1475 * that representative is changed.
1476 */
1477export function updateThemeColorFamily(
1478 appearance: ThemeAppearance,
1479 colors: ThemeColors,
1480 role: ThemeColorRole,
1481 value: string,
1482): ThemeColors {
1483 const parsedSelected = parseThemeColor(value);
1484 if (!parsedSelected) return { ...colors, [role]: value };
1485 const normalized = formatOklchThemeColor(parsedSelected.color, parsedSelected.alpha);
1486
1487 const canvas = parseThemeRgbColor(
1488 colors.canvas,
1489 appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 },
1490 );
1491 const selected = themeOklchToRgb(parsedSelected.color);
1492 const selectedOn = (background: ThemeRgbColor) =>
1493 mixThemeRgbColors(background, selected, parsedSelected.alpha);
1494 const selectedOnCanvas = selectedOn(canvas);
1495 const accent = parseThemeRgbColor(colors.accent, { r: 168, g: 67, b: 112 });
1496 const canvasIsDark = themeRelativeLuminance(canvas) < 0.179;
1497 const terminalIsDark = themeRelativeLuminance(selectedOnCanvas) < 0.179;
1498 const colorOf = (color: ThemeRgbColor) => themeRgbToThemeColor(color);
1499 const foregroundOn = (background: ThemeRgbColor) => colorOf(readableThemeForeground(background));
1500 const selectedToneOn = (background: ThemeRgbColor) =>
1501 themeOklchToThemeColor(
1502 solveOklchLightness(
1503 parsedSelected.color,
1504 background,
1505 4.6,
1506 themeRelativeLuminance(background) < 0.179 ? "lighter" : "darker",
1507 ),
1508 );
1509 const statusColors = () => {
1510 const surface = mixThemeRgbColors(canvas, selectedOnCanvas, canvasIsDark ? 0.16 : 0.08);
1511 return {
1512 foreground: selectedToneOn(surface),
1513 surface: colorOf(surface),
1514 };
1515 };
1516
1517 switch (role) {
1518 case "canvas":
1519 return { ...colors, canvas: normalized, chrome: normalized, toolbar: normalized };
1520 case "surface":
1521 case "surfaceRaised":
1522 case "surfaceOverlay":
1523 case "input":
1524 case "sidebarControlSurface":
1525 return { ...colors, [role]: normalized };
1526 case "text":
1527 return {
1528 ...colors,
1529 text: normalized,
1530 toolbarForeground: normalized,
1531 toolbarControlForeground: normalized,
1532 };
1533 case "mutedForeground":
1534 return {
1535 ...colors,
1536 textMuted: normalized,
1537 mutedForeground: normalized,
1538 placeholder: normalized,
1539 secondaryLabel: normalized,
1540 iconMuted: normalized,
1541 sidebarMutedForeground: normalized,
1542 };
1543 case "border":
1544 return {
1545 ...colors,
1546 border: normalized,
1547 toolbarBorder: normalized,
1548 sidebarBorder: normalized,
1549 };
1550 case "secondary":
1551 return {
1552 ...colors,
1553 secondary: normalized,
1554 secondaryForeground: foregroundOn(selectedOnCanvas),
1555 muted: normalized,
1556 toolbarControl: normalized,
1557 };
1558 case "accentSurface":
1559 return {
1560 ...colors,
1561 accentSurface: normalized,
1562 accentSurfaceForeground: foregroundOn(selectedOnCanvas),
1563 toolbarControlHover: normalized,
1564 };
1565 case "accent": {
1566 const updateSurface = mixThemeRgbColors(canvas, selectedOnCanvas, canvasIsDark ? 0.32 : 0.16);
1567 return {
1568 ...colors,
1569 accent: normalized,
1570 accentForeground: foregroundOn(selectedOnCanvas),
1571 focus: normalized,
1572 update: normalized,
1573 updateForeground: selectedToneOn(updateSurface),
1574 updateSurface: colorOf(updateSurface),
1575 terminalCursor: normalized,
1576 };
1577 }
1578 case "messageAction": {
1579 const actionForeground = readableThemeForeground(selectedOnCanvas);
1580 const towardOpposite =
1581 actionForeground === THEME_LIGHT_FOREGROUND || actionForeground === THEME_WHITE_FOREGROUND
1582 ? THEME_BLACK_FOREGROUND
1583 : THEME_WHITE_FOREGROUND;
1584 const actionHover = mixThemeRgbColors(selected, towardOpposite, 0.12);
1585 return {
1586 ...colors,
1587 messageAction: normalized,
1588 messageActionForeground: colorOf(actionForeground),
1589 messageActionHover: formatOklchThemeColor(
1590 themeRgbToOklch(actionHover),
1591 parsedSelected.alpha,
1592 ),
1593 };
1594 }
1595 case "messageSurface":
1596 return {
1597 ...colors,
1598 messageSurface: normalized,
1599 messageForeground: foregroundOn(selectedOnCanvas),
1600 };
1601 case "codeBackground":
1602 return {
1603 ...colors,
1604 codeBackground: normalized,
1605 codeForeground: foregroundOn(selectedOnCanvas),
1606 };
1607 case "sidebar":
1608 return {
1609 ...colors,
1610 sidebar: normalized,
1611 sidebarForeground: foregroundOn(selectedOnCanvas),
1612 };
1613 case "sidebarRowSelected": {
1614 const sidebar = parseThemeRgbColor(colors.sidebar, canvas);
1615 const selectedOnSidebar = selectedOn(sidebar);
1616 return {
1617 ...colors,
1618 sidebarRowHover: colorOf(mixThemeRgbColors(sidebar, selectedOnSidebar, 0.5)),
1619 sidebarRowActive: colorOf(mixThemeRgbColors(sidebar, selectedOnSidebar, 0.8)),
1620 sidebarRowSelected: normalized,
1621 };
1622 }
1623 case "terminalBackground": {
1624 const terminalForeground = readableThemeForeground(selectedOnCanvas);
1625 return {
1626 ...colors,
1627 terminalBackground: normalized,
1628 terminalForeground: colorOf(terminalForeground),
1629 terminalSelection: colorOf(
1630 mixThemeRgbColors(selectedOnCanvas, accent, terminalIsDark ? 0.35 : 0.18),
1631 ),
1632 terminalScrollbar: colorOf(
1633 mixThemeRgbColors(selectedOnCanvas, terminalForeground, terminalIsDark ? 0.42 : 0.22),
1634 ),
1635 terminalScrollbarHover: colorOf(
1636 mixThemeRgbColors(selectedOnCanvas, terminalForeground, terminalIsDark ? 0.55 : 0.32),
1637 ),
1638 };
1639 }
1640 case "error": {
1641 const status = statusColors();
1642 return {
1643 ...colors,
1644 error: normalized,
1645 errorForeground: status.foreground,
1646 errorSurface: status.surface,
1647 };
1648 }
1649 case "warning": {
1650 const status = statusColors();
1651 return {
1652 ...colors,
1653 warning: normalized,
1654 warningForeground: status.foreground,
1655 warningSurface: status.surface,
1656 };
1657 }
1658 default:
1659 return { ...colors, [role]: normalized };
1660 }
1661}
1662
14711663export const GROVE_THEME: ThemeDefinition = {
14721664 id: GROVE_THEME_ID,
14731665 label: GROVE_THEME_LABEL,
docs/user/keybindings.md+3-1
......@@ -43,8 +43,10 @@ Repeating either shortcut closes that search, and switching shortcuts replaces t
4343`themeEditor.toggle` opens or closes the floating theme editor and defaults to
4444`mod+alt+shift+t`. Select a color label to spotlight the elements that use it; select the label
4545again to clear the spotlight. The swatch and hex field keep that color selected while you edit it.
46Advanced mode groups related app tokens into a smaller set of color families. Changing a family
47updates its paired text and interaction states while leaving every unrelated imported color intact.
4648Use **Inspect** to pick an element in the app and reveal its color token. Inspect disarms after one
47successful pick; its hover glow and badge preview the element and token that click will select.
49successful pick; its hover glow and badge preview the element and color family that click will select.
4850**Cancel** or `Escape` exits Inspect and clears its selection and spotlight.
4951
5052`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,