Skip to content

Commit a385493

Browse files
samejrclaude
andcommitted
feat(webapp): make Dark the default theme and retire Classic
Dark's palette was already Classic's: Dark had no base block, so it inherited every @theme token and only re-declared 17 greys as contrast expressions whose 0% value was Classic's own. Comparing all 109 custom properties between the two themes at contrast 0 found no difference, so this is a rename plus a default change rather than a re-skin. - Dark is the default and the fallback for any unknown stored preference, so a saved "classic" resolves there once it stops parsing. No data migration. - Default contrast becomes 0, which is exactly Classic's palette, and the slider floor drops from 15 to 0 so that look stays reachable. The "Default" tick now agrees with the applied default instead of sitting at 30. - Flag-off and logged-out pages already pinned contrast to 0, so every unflagged user sees no change at all. Contrast now travels between charcoal stops instead of mixing toward #fff and #000. The ramp is a hand-picked set of cool-leaning greys whose tint peaks in the midtones, and mixing to pure white flattened that: at full contrast border-bright landed on a +8 blue lean where the ramp's own grey at that brightness carries +17. Interpolating stop to stop keeps every intermediate value inside the palette, and it no longer reaches pure white or black — the Black and White themes are there for that. Each token moves two stops, except the text group, which moves one so bright, dimmed and faint keep their spacing. Charts: Dark takes the brighter series colours back, and the darkened pair moves under the accessibility preference, where it also lines the dark themes up with Light so a chart doesn't shift hue when the OS flips. That preference is now "Distinguish without color", covering the contrast bump and the shape differentiation still to come. The stored key stays iconContrast. Verified by pixel-diffing every page against a Classic baseline captured first: the only differences are the renamed row, an animated spinner mid-frame and the slider thumb re-mapped to the new floor. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 10e1fde commit a385493

9 files changed

Lines changed: 163 additions & 137 deletions

File tree

apps/webapp/app/components/primitives/charts/Chart.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { AnimatedNumber } from "../AnimatedNumber";
55
import TooltipPortal from "../TooltipPortal";
66

77
// Format: { THEME_NAME: CSS_SELECTOR }
8-
// dark covers Classic too; :is() keeps it one scoped selector when the prefix is
8+
// dark covers Black too; :is() keeps it one scoped selector when the prefix is
99
// interpolated as `${prefix} [data-chart=...]` below (a comma would break scoping).
10-
const THEMES = { light: "", dark: ':is([data-theme="dark"], [data-theme="classic"])' } as const;
10+
// `light` is deliberately unscoped: it emits the base rule that the dark
11+
// selector then overrides, so every theme resolves to one of the two.
12+
const THEMES = { light: "", dark: ':is([data-theme="dark"], [data-theme="black"])' } as const;
1113

1214
export type ChartState = "loading" | "noData" | "invalid" | "loaded" | undefined;
1315

apps/webapp/app/components/themeOptions.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { SwatchIcon } from "@heroicons/react/24/outline";
21
import { type FunctionComponent } from "react";
32
import { CircleFilledIcon } from "~/assets/icons/CircleFilledIcon";
43
import { CircleOutlineIcon } from "~/assets/icons/CircleOutlineIcon";
@@ -24,22 +23,15 @@ export const THEME_OPTIONS: ThemeOption[] = [
2423
];
2524

2625
/** Light and Dark with their surfaces pinned flat, so grid lines carry the
27-
* layout. Account page only, alongside Classic. The icons here are the
26+
* layout. Account page only. The icons here are the
2827
* dark-theme pair; `themeOptionIcon` swaps them per active theme. */
2928
const FLAT_OPTIONS: ThemeOption[] = [
3029
{ value: "white", label: "White", icon: CircleFilledIcon },
3130
{ value: "black", label: "Black", icon: CircleOutlineIcon },
3231
];
3332

34-
/** Legacy theme, offered on the account page only. */
35-
export const CLASSIC_OPTION: ThemeOption = {
36-
value: "classic",
37-
label: "Classic",
38-
icon: SwatchIcon,
39-
};
40-
4133
/** Every theme, for the account page's full picker. */
42-
export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, ...FLAT_OPTIONS, CLASSIC_OPTION];
34+
export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, ...FLAT_OPTIONS];
4335

4436
export const THEME_OPTIONS_BY_VALUE = Object.fromEntries(
4537
ALL_THEME_OPTIONS.map((option) => [option.value, option])

apps/webapp/app/hooks/useThemeMode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type ThemeMode = "dark" | "light";
55

66
/* Which themes read as light. Taken from the enum that also drives the "Light"
77
end of the `system` preference, so a new theme only has to be classified once
8-
- anything not in here (dark, classic, black) reads as dark. */
8+
- anything not in here (dark, black) reads as dark. */
99
const LIGHT_THEMES = new Set<string>(SystemLightTheme.options);
1010

1111
/**

apps/webapp/app/root.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,22 +85,24 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
8585
};
8686

8787
const user = await getUser(request);
88-
// Theme switching is feature-flagged; while off, everyone stays on the
89-
// classic theme even if a preference was saved earlier. Admins always get
90-
// the switcher so the team can dogfood before the flag flips. Cached: the
91-
// root loader runs on every document request and client navigation.
88+
// Theme switching is feature-flagged; while off, everyone stays on Dark at
89+
// contrast 0 even if a preference was saved earlier - that pairing renders
90+
// the exact palette the Classic theme used to ship. Admins always get the
91+
// switcher so the team can dogfood before the flag flips. Cached: the root
92+
// loader runs on every document request and client navigation.
9293
const showThemeSwitcher = user
9394
? user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false }))
9495
: false;
95-
// Logged-out pages (login, invites) always render the branded Classic look.
96+
// Logged-out pages (login, invites) always render the branded dark look.
9697
const themePreference: ThemePreference = showThemeSwitcher
9798
? normalizeThemePreference(user?.dashboardPreferences.theme)
98-
: "classic";
99+
: "dark";
99100
const themeContrast = showThemeSwitcher
100101
? normalizeThemeContrast(user?.dashboardPreferences.contrast)
101102
: 0;
102-
// Icon and badge accents. Off by default, and forced off with the switcher
103-
// hidden so logged-out and unflagged pages render the Classic set.
103+
// The "Distinguish without color" accents. Off by default, and forced off
104+
// with the switcher hidden so logged-out and unflagged pages render the
105+
// standard set.
104106
const iconContrast = showThemeSwitcher
105107
? normalizeIconContrast(user?.dashboardPreferences.iconContrast)
106108
: false;
@@ -167,7 +169,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
167169
export function ErrorBoundary() {
168170
return (
169171
<>
170-
<html lang="en" className="h-full" data-theme="classic">
172+
<html lang="en" className="h-full" data-theme="dark">
171173
<head>
172174
<meta charSet="utf-8" />
173175

apps/webapp/app/routes/account._index/route.tsx

Lines changed: 43 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -82,18 +82,18 @@ import { cn } from "~/utils/cn";
8282

8383
export const meta = pageMeta("Your profile");
8484

85-
/** Floor of the contrast slider. The stored value and the colours it drives are
86-
* unchanged - this only stops the slider offering the bottom of the range. */
87-
const MIN_CONTRAST = 15;
85+
/** Floor of the contrast slider. 0 is meaningful now: it's the palette the
86+
* Classic theme shipped, so the bottom of the range has to stay reachable. */
87+
const MIN_CONTRAST = 0;
8888

89-
/** The contrast the slider ticks and labels as "Default". Note this is not the
90-
* same as `DEFAULT_THEME_CONTRAST`, the value applied when none is saved. */
91-
const DEFAULT_CONTRAST_MARK = 30;
89+
/** The contrast the slider ticks and labels as "Default". Matches
90+
* `DEFAULT_THEME_CONTRAST`, the value applied when none is saved. */
91+
const DEFAULT_CONTRAST_MARK = 0;
9292

9393
function themeIcon(value: ThemePreference, appearance: ThemeAppearance) {
9494
const Icon = themeOptionIcon(THEME_OPTIONS_BY_VALUE[value], appearance);
9595
// shrink-0: without it the icon is the flex item that gives way to a long
96-
// label, and "System"/"Classic" squash it to a sliver.
96+
// label, and "System" squashes it to a sliver.
9797
return <Icon className="size-4 shrink-0 text-text-bright" />;
9898
}
9999

@@ -739,42 +739,41 @@ export default function Page() {
739739
</div>
740740
</div>
741741
</div>
742-
{theme !== "classic" && (
743-
<div className="flex min-h-16 w-full items-center border-b border-grid-dimmed">
744-
<div className="flex w-full items-center justify-between gap-4">
745-
<div className={cn("flex-1", SETTINGS_ROW_TITLE_GAP)}>
746-
<Label>Contrast</Label>
747-
<SettingsRowDescription>Adjust the interface contrast</SettingsRowDescription>
748-
</div>
749-
<div className="flex flex-none items-center">
750-
<Slider
751-
variant="settings"
752-
className="w-44"
753-
aria-label="Contrast"
754-
min={MIN_CONTRAST}
755-
max={100}
756-
step={1}
757-
marks={[
758-
{
759-
value: DEFAULT_CONTRAST_MARK,
760-
label: "Reset to default",
761-
onSelect: () => {
762-
previewContrast(DEFAULT_CONTRAST_MARK);
763-
saveContrast(DEFAULT_CONTRAST_MARK);
764-
},
742+
<div className="flex min-h-16 w-full items-center border-b border-grid-dimmed">
743+
<div className="flex w-full items-center justify-between gap-4">
744+
<div className={cn("flex-1", SETTINGS_ROW_TITLE_GAP)}>
745+
<Label>Contrast</Label>
746+
<SettingsRowDescription>Adjust the interface contrast</SettingsRowDescription>
747+
</div>
748+
<div className="flex flex-none items-center">
749+
<Slider
750+
variant="settings"
751+
className="w-44"
752+
aria-label="Contrast"
753+
min={MIN_CONTRAST}
754+
max={100}
755+
step={1}
756+
marks={[
757+
{
758+
value: DEFAULT_CONTRAST_MARK,
759+
label: "Reset to default",
760+
onSelect: () => {
761+
previewContrast(DEFAULT_CONTRAST_MARK);
762+
saveContrast(DEFAULT_CONTRAST_MARK);
765763
},
766-
]}
767-
valueTooltip={(value) =>
768-
value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%`
769-
}
770-
value={[contrastPreview]}
771-
onValueChange={(values) => previewContrast(values[0] ?? 0)}
772-
onValueCommit={(values) => saveContrast(values[0] ?? 0)}
773-
/>
774-
</div>
764+
},
765+
]}
766+
valueTooltip={(value) =>
767+
value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%`
768+
}
769+
value={[contrastPreview]}
770+
onValueChange={(values) => previewContrast(values[0] ?? 0)}
771+
onValueCommit={(values) => saveContrast(values[0] ?? 0)}
772+
/>
775773
</div>
776774
</div>
777-
)}
775+
</div>
776+
778777
<div className="mt-8 w-full border-b border-grid-dimmed pb-3">
779778
<Header2>Interface</Header2>
780779
</div>
@@ -796,15 +795,16 @@ export default function Page() {
796795
<div className="flex min-h-16 w-full items-center border-b border-grid-dimmed">
797796
<div className="flex w-full items-center justify-between gap-4">
798797
<div className={cn("flex-1", SETTINGS_ROW_TITLE_GAP)}>
799-
<Label>Icon contrast</Label>
798+
<Label>Distinguish without color</Label>
800799
<SettingsRowDescription>
801-
Increase the contrast of icons and badges
800+
Raise the contrast of icons, badges and charts, and give anything that relies
801+
on color alone a distinct shape
802802
</SettingsRowDescription>
803803
</div>
804804
<div className="flex flex-none items-center">
805805
<Switch
806806
variant="minimal/medium"
807-
aria-label="Icon contrast"
807+
aria-label="Distinguish without color"
808808
checked={iconContrast}
809809
onCheckedChange={(checked) =>
810810
iconContrastFetcher.submit(

apps/webapp/app/routes/resources.preferences.theme.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export async function action({ request }: ActionFunctionArgs) {
88
const user = await requireUser(request);
99

1010
// Same gate as the account page: while the flag is off, everyone stays on the
11-
// classic theme, so a preference must not be writable from the menu either.
11+
// default theme, so a preference must not be writable from the menu either.
1212
const showThemeSwitcher =
1313
user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false }));
1414
if (!showThemeSwitcher) {

apps/webapp/app/routes/storybook/route.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,9 @@ function ThemeShortcut({
219219
return null;
220220
}
221221

222-
/* Icon contrast is a plain attribute on <html>, so the storybook can flip it
223-
locally the same way it does the theme, and hand it back on the way out. */
222+
/* The accessibility preference is a plain attribute on <html>, so the storybook
223+
can flip it locally the same way it does the theme, and hand it back on the
224+
way out. The stored key is still `iconContrast`. */
224225
function useStorybookIconContrast() {
225226
const rootData = useTypedRouteLoaderData<typeof rootLoader>("root");
226227
const [iconContrast, setIconContrast] = useState(false);
@@ -294,7 +295,7 @@ export default function App() {
294295
<div className="flex flex-none items-center gap-3">
295296
<Switch
296297
variant="minimal/medium"
297-
label="Icon contrast"
298+
label="Distinguish without color"
298299
checked={iconContrast}
299300
onCheckedChange={setIconContrast}
300301
/>

0 commit comments

Comments
 (0)