Skip to content

Commit c0acc4b

Browse files
authored
fix(web): remove the theme setting and force every install back to light (#6168)
Product removed theme selection: the workspace surfaces shipped for team workspaces have no dark tokens, so dark mode renders a broken app. Deleting the picker is not sufficient on its own. Every install that ever opened it still has `theme: 'dark'` — or `'system'`, which resolves dark on a dark OS — persisted in localStorage, and a stored value does not move when the default does. So the theme is now coerced on READ, at all three points a persisted value can reach the document: - `loadConfig()` funnels `parsed.theme` through `resolveAppTheme()` and marks the config migrated so the coerced value is written back once. - `applyAppearanceToDocument()` stamps `data-theme="light"` unconditionally. The attribute must be PRESENT, not merely non-dark: every dark CSS rule is gated on `html:not([data-theme])` / `html:not([data-theme="light"])`, and every JS theme reader (shiki, ConnectorLogo, SketchEditor, TerminalViewer, connectorBrandColor, MentionNode) falls back to `prefers-color-scheme` only when the attribute is absent. - The pre-hydration inline script in `app/layout.tsx` stamps light before React mounts, outside its try/catch so a throwing storage read still leaves the attribute set. Electron's `themeSource` defaults to `system`, which colours everything the web layer does not own (macOS vibrancy glass, native menus/dialogs) on a dark-mode Mac — including the splash, before the renderer's appearance IPC lands. It is now pinned to light before the first window exists. Removed surfaces: - `AppearanceSection` in SettingsDialog, orphaned by #6156 (zero call sites), together with its `settings-general-block--appearance` styles. Its docblock claimed the control was "deliberately kept … NON-ALIGNMENT #9"; that decision is superseded, so the comment goes with the code. - The onboarding welcome page's sun/moon toggle — the last reachable theme writer — and the `onThemeChange` prop chain behind it (App → EntryView/ProjectView → EntryShell → OnboardingView). - The orphaned theme row in EntrySettingsMenu and its styles. - i18n keys `settings.appearance`, `settings.appearanceHint`, `settings.themeSystem`, `settings.themeLight`, `settings.themeDark` across `types.ts` and all 19 locales. - Analytics `trackSettingsAppearanceClick`, `SettingsAppearanceClickProps`, and the `settings_popover` `appearance` element. `'appearance'` survives only as a legacy settings deep-link token that `normalizeSettingsSection` folds into General, so an old link is not a type error. Red-first: tests/state/force-light-theme.test.ts and tests/components/theme-settings-removed.test.tsx were written against the unmodified branch and went red (11 failing) before any source change.
1 parent 4f4a6a7 commit c0acc4b

46 files changed

Lines changed: 494 additions & 486 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/main/runtime.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,6 +1468,22 @@ export type SplashWindowHandle = {
14681468
window: BrowserWindow;
14691469
};
14701470

1471+
/**
1472+
* Pin Electron's native appearance to light.
1473+
*
1474+
* The app has one theme now, so `themeSource` is not a preference to sync — it
1475+
* is a constant. Leaving it at Electron's `system` default lets a dark-mode OS
1476+
* colour everything the web layer does not own: the macOS vibrancy glass
1477+
* (`vibrancy: "under-window"`), native menus and dialogs, and the renderer's
1478+
* own `prefers-color-scheme` before `data-theme` is stamped.
1479+
*
1480+
* Idempotent, so both the splash path and the `od:appearance:set-theme` handler
1481+
* can call it.
1482+
*/
1483+
export function pinNativeAppearanceToLight(): void {
1484+
nativeTheme.themeSource = "light";
1485+
}
1486+
14711487
/**
14721488
* Create and immediately show the light brand-splash window. The packaged entry
14731489
* calls this BEFORE awaiting the daemon/web sidecars so the animation masks the
@@ -1477,6 +1493,12 @@ export type SplashWindowHandle = {
14771493
* + matching size so the reveal swap reads as a single window, never a flash.
14781494
*/
14791495
export function createSplashWindow(): SplashWindowHandle {
1496+
// Open Design ships light-only (the theme setting was removed), so pin the
1497+
// native appearance before the first window exists. Electron defaults
1498+
// `themeSource` to `system`, which paints the macOS vibrancy glass and the
1499+
// native chrome dark on a dark-mode Mac — visible on the splash and again in
1500+
// the gap before the renderer's `od:appearance:set-theme` lands.
1501+
pinNativeAppearanceToLight();
14801502
// Stamp creation time at the instant the window appears (see SplashWindowHandle).
14811503
const startedAt = Date.now();
14821504
const splash = new BrowserWindow({
@@ -2520,10 +2542,11 @@ export async function createDesktopRuntime(options: DesktopRuntimeOptions): Prom
25202542
if (theme !== "light" && theme !== "dark" && theme !== "system") return;
25212543
// Pin the native appearance to the app theme. The macOS frosted window
25222544
// (vibrancy: under-window) draws its glass in the SYSTEM appearance by
2523-
// default, so an explicitly light app over a dark OS sat on dark glass
2524-
// and read as a muddy gray (#94); forcing the native theme keeps the
2525-
// glass material in step with the app's tokens. `system` restores
2526-
// following the OS for the follow-system theme setting.
2545+
// default, so a light app over a dark OS sat on dark glass and read as a
2546+
// muddy gray (#94); forcing the native theme keeps the glass material in
2547+
// step with the app's tokens. The host protocol still carries all three
2548+
// values as generic infrastructure, but the app ships light-only, so this
2549+
// is the same value `pinNativeAppearanceToLight` already set at startup.
25272550
nativeTheme.themeSource = theme;
25282551
});
25292552

apps/web/app/layout.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@ export const viewport: Viewport = {
1919
};
2020

2121
/**
22-
* Inline script that runs before React hydrates to apply the saved theme
23-
* preference without a flash of unstyled content. It reads the same
24-
* localStorage key used by `state/config.ts` and sets `data-theme` on
25-
* `<html>` immediately — before any CSS or React paint.
22+
* Inline script that runs before React hydrates so the first paint already
23+
* carries the app's appearance — no flash of unstyled content.
24+
*
25+
* `data-theme` is pinned to `light` unconditionally, and deliberately OUTSIDE
26+
* the try/catch: Open Design ships light-only (product removed the theme
27+
* setting), and a stored `dark` / `system` from the old picker must never reach
28+
* the document. Every dark CSS rule is gated on the attribute being absent, so
29+
* a storage read that throws must still leave the attribute stamped.
2630
* Keep the accent variable mix ratios in sync with `accentVars()` in
2731
* `src/state/appearance.ts`; this script cannot import application modules.
2832
*/
29-
const themeInitScript = `(function(){try{var c=JSON.parse(localStorage.getItem('open-design:config')||'{}');var t=c.theme;if(t==='light'||t==='dark')document.documentElement.setAttribute('data-theme',t);var a=typeof c.accentColor==='string'&&/^#[0-9a-fA-F]{6}$/.test(c.accentColor.trim())?c.accentColor.trim().toLowerCase():'#353535';if(c.configMigrationVersion!==3&&(a==='#87ea5c'||a==='#c96442'))a='#353535';var s=document.documentElement.style;s.setProperty('--accent',a);s.setProperty('--accent-strong','color-mix(in srgb, '+a+' 82%, var(--text-strong))');s.setProperty('--accent-soft','color-mix(in srgb, '+a+' 12%, var(--bg-subtle))');s.setProperty('--accent-tint','color-mix(in srgb, '+a+' 6%, var(--bg-panel))');s.setProperty('--accent-hover','color-mix(in srgb, '+a+' 86%, var(--text-strong))');}catch(e){}})();`;
33+
const themeInitScript = `(function(){document.documentElement.setAttribute('data-theme','light');try{var c=JSON.parse(localStorage.getItem('open-design:config')||'{}');var a=typeof c.accentColor==='string'&&/^#[0-9a-fA-F]{6}$/.test(c.accentColor.trim())?c.accentColor.trim().toLowerCase():'#353535';if(c.configMigrationVersion!==3&&(a==='#87ea5c'||a==='#c96442'))a='#353535';var s=document.documentElement.style;s.setProperty('--accent',a);s.setProperty('--accent-strong','color-mix(in srgb, '+a+' 82%, var(--text-strong))');s.setProperty('--accent-soft','color-mix(in srgb, '+a+' 12%, var(--bg-subtle))');s.setProperty('--accent-tint','color-mix(in srgb, '+a+' 6%, var(--bg-panel))');s.setProperty('--accent-hover','color-mix(in srgb, '+a+' 86%, var(--text-strong))');}catch(e){}})();`;
3034

3135
export default function RootLayout({ children }: { children: ReactNode }) {
3236
return (

apps/web/src/App.tsx

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,16 +1171,14 @@ function AppInner() {
11711171
agents,
11721172
]);
11731173

1174-
// Sync theme preference to the <html> element so CSS variables pick it up.
1175-
// useLayoutEffect (vs useEffect) fires before the browser paints, so a
1176-
// live theme switch in Settings applies atomically — no 1-frame flash of
1177-
// the old theme. Safe here because the component tree is ssr:false.
1174+
// Stamp the app appearance onto the <html> element so CSS variables pick it
1175+
// up. The theme itself is a constant (light-only), but the accent still comes
1176+
// from config, and the stamp must be re-applied whenever that changes.
1177+
// useLayoutEffect (vs useEffect) fires before the browser paints, so no
1178+
// 1-frame flash. Safe here because the component tree is ssr:false.
11781179
useLayoutEffect(() => {
1179-
applyAppearanceToDocument({
1180-
theme: config.theme ?? 'system',
1181-
accentColor: config.accentColor,
1182-
});
1183-
}, [config.theme, config.accentColor]);
1180+
applyAppearanceToDocument({ accentColor: config.accentColor });
1181+
}, [config.accentColor]);
11841182

11851183
// Tell the daemon what the user is currently looking at, so the MCP
11861184
// server can surface it as `get_active_context` to a coding agent in
@@ -1836,32 +1834,6 @@ function AppInner() {
18361834
[],
18371835
);
18381836

1839-
// Quick theme switch from the settings dropdown in the entry view.
1840-
// Skips the full SettingsDialog round-trip so the appearance flip
1841-
// feels instantaneous; the live preview comes for free because the
1842-
// `useLayoutEffect` above re-runs `applyAppearanceToDocument` the
1843-
// moment `config.theme` changes. We still persist to localStorage
1844-
// and the daemon so the choice survives reloads.
1845-
const handleThemeChange = useCallback(
1846-
(theme: AppConfig['theme']) => {
1847-
const current = latestPersistedConfigRef.current;
1848-
const next = { ...current, theme };
1849-
latestPersistedConfigRef.current = next;
1850-
// Apply to the DOM synchronously inside the click handler so the theme
1851-
// flips instantly. Otherwise the visible switch waits on the (heavier)
1852-
// React re-render of the whole tree before the layout effect re-applies
1853-
// it — which reads as a perceptible lag after the click.
1854-
applyAppearanceToDocument({
1855-
theme: theme ?? 'system',
1856-
accentColor: current.accentColor,
1857-
});
1858-
saveConfig(next);
1859-
void syncConfigToDaemon(next);
1860-
setConfig(next);
1861-
},
1862-
[],
1863-
);
1864-
18651837
const handleAgentChange = useCallback(
18661838
(agentId: string) => {
18671839
const next = { ...latestPersistedConfigRef.current, agentId };
@@ -3384,7 +3356,6 @@ function AppInner() {
33843356
onAgentModelChange={handleAgentModelChange}
33853357
onApiModelChange={handleApiModelChange}
33863358
onRefreshAgents={refreshAgents}
3387-
onThemeChange={handleThemeChange}
33883359
onOpenSettings={openSettings}
33893360
onOpenAmrSettings={openAmrSettings}
33903361
onOpenMcpSettings={openMcpSettings}
@@ -3434,7 +3405,6 @@ function AppInner() {
34343405
onSkillsRefresh={refreshSkills}
34353406
onSkillsChanged={handleSkillsChanged}
34363407
onRefreshAgents={refreshAgents}
3437-
onThemeChange={handleThemeChange}
34383408
skillsLoading={skillsLoading}
34393409
designSystemsLoading={dsLoading}
34403410
projectsLoading={projectsLoading}

apps/web/src/analytics/events.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,6 @@ import type {
110110
SettingsMediaProvidersClickProps,
111111
SettingsConnectorsClickProps,
112112
SettingsLanguageClickProps,
113-
SettingsAppearanceClickProps,
114113
SettingsNotificationsClickProps,
115114
SettingsPetsClickProps,
116115
SettingsPrivacyClickProps,
@@ -904,13 +903,6 @@ export function trackSettingsLanguageClick(
904903
send(track, 'ui_click', props);
905904
}
906905

907-
export function trackSettingsAppearanceClick(
908-
track: Track,
909-
props: SettingsAppearanceClickProps,
910-
): void {
911-
send(track, 'ui_click', props);
912-
}
913-
914906
export function trackSettingsNotificationsClick(
915907
track: Track,
916908
props: SettingsNotificationsClickProps,

apps/web/src/components/EntrySettingsMenu.tsx

Lines changed: 5 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
trackSettingsPopoverSurfaceView,
2424
} from '../analytics/events';
2525
import { createSocialSharePayload } from '../providers/registry';
26-
import type { AppConfig, AppTheme } from '../types';
26+
import type { AppConfig } from '../types';
2727
import { formatDiscordPresenceCount, useDiscordPresence } from './useDiscordPresence';
2828
import { Icon } from './Icon';
2929
import { SocialShareGrid } from './SocialShareGrid';
@@ -46,6 +46,9 @@ export type EntrySettingsSection =
4646
| 'integrations'
4747
| 'mcpClient'
4848
| 'language'
49+
// Legacy deep-link token: the theme setting is gone (the app ships
50+
// light-only) and SettingsDialog folds this into General, but the token stays
51+
// accepted so an old link does not become a type error at the call site.
4952
| 'appearance'
5053
| 'notifications'
5154
| 'pet'
@@ -55,19 +58,8 @@ export type EntrySettingsSection =
5558
| 'memory'
5659
| 'designSystems';
5760

58-
const ENTRY_THEME_OPTIONS: Array<{
59-
value: AppTheme;
60-
icon: 'sun-moon' | 'sun' | 'moon';
61-
labelKey: 'settings.themeSystem' | 'settings.themeLight' | 'settings.themeDark';
62-
}> = [
63-
{ value: 'system', icon: 'sun-moon', labelKey: 'settings.themeSystem' },
64-
{ value: 'light', icon: 'sun', labelKey: 'settings.themeLight' },
65-
{ value: 'dark', icon: 'moon', labelKey: 'settings.themeDark' },
66-
];
67-
6861
interface Props {
6962
config: AppConfig;
70-
onThemeChange: (theme: AppTheme) => void;
7163
onOpenSettings: (section?: EntrySettingsSection) => void;
7264
// Fired when the gear trigger is clicked. Used by the in-project header to
7365
// emit the `artifact_header` / `settings` ui_click; the home/entry shell
@@ -80,7 +72,6 @@ interface Props {
8072

8173
export function EntrySettingsMenu({
8274
config,
83-
onThemeChange,
8475
onOpenSettings,
8576
onTrackTriggerClick,
8677
trackingPageName,
@@ -96,7 +87,6 @@ export function EntrySettingsMenu({
9687
const wrapRef = useRef<HTMLDivElement | null>(null);
9788
const triggerRef = useRef<HTMLButtonElement | null>(null);
9889
const langListRef = useRef<HTMLDivElement | null>(null);
99-
const activeTheme = config.theme ?? 'system';
10090
const discordOnlineLabel = discordPresence
10191
? t('entry.discordOnlineLabel', {
10292
count: formatDiscordPresenceCount(discordPresence.onlineCount),
@@ -155,7 +145,7 @@ export function EntrySettingsMenu({
155145
}, [open]);
156146

157147
// surface_view — fire once each time the settings popover opens so the
158-
// share / language / appearance funnels have a denominator.
148+
// share / language funnels have a denominator.
159149
useEffect(() => {
160150
if (!open) return;
161151
trackSettingsPopoverSurfaceView(analytics.track, {
@@ -285,42 +275,6 @@ export function EntrySettingsMenu({
285275
</div>
286276
</section>
287277

288-
<section className="entry-settings-menu__section">
289-
<div className="entry-settings-menu__section-title">
290-
<Icon name="palette" size={14} />
291-
<span>{t('settings.appearance')}</span>
292-
</div>
293-
<div className="entry-settings-menu__theme-row">
294-
{ENTRY_THEME_OPTIONS.map((option) => {
295-
const active = activeTheme === option.value;
296-
return (
297-
<button
298-
key={option.value}
299-
type="button"
300-
role="menuitemradio"
301-
aria-checked={active}
302-
className={`entry-settings-menu__theme${
303-
active ? ' is-active' : ''
304-
}`}
305-
onClick={() => {
306-
trackSettingsPopoverClick(analytics.track, {
307-
page_name: pageName,
308-
area: 'settings_popover',
309-
element: 'appearance',
310-
value: option.value,
311-
});
312-
onThemeChange(option.value);
313-
setOpen(false);
314-
}}
315-
>
316-
<Icon name={option.icon} size={14} />
317-
<span>{t(option.labelKey)}</span>
318-
</button>
319-
);
320-
})}
321-
</div>
322-
</section>
323-
324278
<section className="entry-settings-menu__section">
325279
<div className="entry-settings-menu__section-title">
326280
<Icon name="external-link" size={14} />

apps/web/src/components/EntryShell.tsx

Lines changed: 2 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ import type {
7878
ApiProtocol,
7979
ApiProtocolConfig,
8080
AppConfig,
81-
AppTheme,
8281
ConnectionTestResponse,
8382
DesignSystemSummary,
8483
ExecMode,
@@ -433,10 +432,6 @@ interface Props {
433432
onSkillsRefresh?: () => Promise<void> | void;
434433
onSkillsChanged?: (affectedSkillId?: string) => void;
435434
onRefreshAgents: () => Promise<AgentInfo[]> | AgentInfo[];
436-
// Quick theme switch from the avatar-popover dropdown. Lets the user
437-
// flip between system / light / dark without opening the full Settings
438-
// dialog. App owns persistence; this component just calls the callback.
439-
onThemeChange: (theme: AppTheme) => void;
440435
onCreateProject: (input: EntryCreateProjectInput) => Promise<boolean> | boolean | void;
441436
onCreatePluginShareProject: (
442437
pluginId: string,
@@ -573,7 +568,6 @@ export function EntryShell({
573568
onSkillsRefresh,
574569
onSkillsChanged,
575570
onRefreshAgents,
576-
onThemeChange,
577571
onCreateProject,
578572
onCreatePluginShareProject,
579573
onImportClaudeDesign,
@@ -861,15 +855,6 @@ export function EntryShell({
861855
await open();
862856
return true;
863857
}
864-
// Resolve the effective light/dark theme so the rail's account-menu theme toggle
865-
// flips to the opposite of what's actually shown (system → resolved).
866-
const activeTheme: AppTheme = config.theme ?? 'system';
867-
const resolvedDark =
868-
activeTheme === 'dark' ||
869-
(activeTheme === 'system' &&
870-
typeof window !== 'undefined' &&
871-
typeof window.matchMedia === 'function' &&
872-
window.matchMedia('(prefers-color-scheme: dark)').matches);
873858
// Workspace-only destinations. Personal and team workspaces both use these;
874859
// signed-out/local state falls back to home once the context has resolved.
875860
// `community` is allowed in both states, so it is not guarded.
@@ -1387,7 +1372,7 @@ export function EntryShell({
13871372

13881373
// #5517 removes the entry top-bar settings cog: the nav-rail account menu owns
13891374
// the settings entry (EntryNavRail onOpenSettings), so the top strip no longer
1390-
// carries a redundant one. Theme switching lives in 设置·通用 alone.
1375+
// carries a redundant one.
13911376

13921377

13931378
if (view === 'onboarding') {
@@ -1409,7 +1394,6 @@ export function EntryShell({
14091394
onConfigPersist={onConfigPersist}
14101395
onRefreshAgents={onRefreshAgents}
14111396
onFinish={finishOnboarding}
1412-
onThemeChange={onThemeChange}
14131397
onGoBuild={() => {
14141398
onCompleteOnboarding();
14151399
refreshWorkspaceSurfacesAfterOnboarding();
@@ -1834,7 +1818,6 @@ function OnboardingView({
18341818
onConfigPersist,
18351819
onRefreshAgents,
18361820
onFinish,
1837-
onThemeChange,
18381821
onGoBuild,
18391822
}: {
18401823
config: AppConfig;
@@ -1856,7 +1839,6 @@ function OnboardingView({
18561839
// `survey` is passed on the About-you completion paths (not on skip) so the
18571840
// shell can build a personalized Home recommendation.
18581841
onFinish: (survey?: { role: string; useCases: string[] }) => void;
1859-
onThemeChange: (theme: AppTheme) => void;
18601842
onGoBuild: () => void;
18611843
}) {
18621844
const t = useT();
@@ -3108,16 +3090,8 @@ function OnboardingView({
31083090

31093091
// Connect step, default face: a minimal, centered Open Design Cloud sign-in
31103092
// landing. No stepper, no runtime cards — just the cloud CTA, a secondary
3111-
// link into the full runtime chooser, and a top-left language/theme bar.
3093+
// link into the full runtime chooser, and a top-left language bar.
31123094
if (step === 0 && connectExpanded === null) {
3113-
const activeTheme: AppTheme = config.theme ?? 'system';
3114-
const resolvedDark =
3115-
activeTheme === 'dark' ||
3116-
(activeTheme === 'system' &&
3117-
typeof window !== 'undefined' &&
3118-
typeof window.matchMedia === 'function' &&
3119-
window.matchMedia('(prefers-color-scheme: dark)').matches);
3120-
const themeIcon: 'sun' | 'moon' = resolvedDark ? 'moon' : 'sun';
31213095
const cloudBusy = amrLoginPending;
31223096
const amrStatusResolving = !amrStatusResolved;
31233097
return (
@@ -3127,15 +3101,6 @@ function OnboardingView({
31273101
>
31283102
<div className="onboarding-cloud__topbar">
31293103
<LanguageMenu compact placement="down" align="end" />
3130-
<button
3131-
type="button"
3132-
className="onboarding-cloud__theme"
3133-
aria-label={resolvedDark ? t('settings.themeLight') : t('settings.themeDark')}
3134-
title={resolvedDark ? t('settings.themeLight') : t('settings.themeDark')}
3135-
onClick={() => onThemeChange(resolvedDark ? 'light' : 'dark')}
3136-
>
3137-
<Icon name={themeIcon} size={25} />
3138-
</button>
31393104
</div>
31403105
<div className="onboarding-cloud__center">
31413106
<span

0 commit comments

Comments
 (0)