Skip to content

Commit 9cfaf36

Browse files
samejrclaude
andcommitted
refactor(webapp): make the contrast percentage mean the same thing in every theme
The stored value was a position on one shared scale, and Black's slider was offset on top of it. So Black at 100% stored 70, and switching to Light showed 70% - the number followed the implementation rather than the user. It's now a percentage of whichever theme's range is active. 35% is 35% wherever you switch to; each theme decides where its own 0% and 100% sit. Black's range is -30 to 100, so its 0% fades the grid lines below the base palette - where this theme wants to start - and its 100% is full strength like everywhere else. The others run 0 to 100 as they always did. The mapping moved into CSS, one line per theme, because the alternative was teaching three JS call sites which theme had resolved - and on `system` that isn't settled until hydration, so the server would have picked the wrong range and corrected it on the client. Now JS writes a single `--theme-contrast-percent` and each theme block turns it into the strengthen and fade halves the ramps read. Two things fall out of this. The stored value is a plain 0-100 again, so the schema floor and the normalizer go back to 0 - no negative ever leaves the page. And Black finally defaults to its faint end for a new user, rather than only showing them where the default tick was: 0% is the bottom of Black's range, not the base palette. The slider is a straight pass-through again, and the page no longer needs to resolve the active theme at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4360b63 commit 9cfaf36

6 files changed

Lines changed: 58 additions & 68 deletions

File tree

apps/webapp/app/hooks/useSystemThemeSync.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,13 @@ export function resolveThemePreference(
6363
/**
6464
* Write the contrast preference to the document.
6565
*
66-
* The value is signed: above zero strengthens the palette, below zero fades it
67-
* (Black only). The ramps can't take a negative percentage - `color-mix` treats
68-
* one as invalid and drops the whole declaration - so it's split into two
69-
* always-positive variables. Anything writing this must go through here; the
70-
* live preview used to set `--theme-contrast` on its own and silently did
71-
* nothing at all below zero.
66+
* Just the percent: each theme maps it onto its own range in CSS, so there's
67+
* nothing here that needs to know which theme resolved. That used to write
68+
* `--theme-contrast` directly, which meant the live preview and the server
69+
* render could disagree, and a value outside 0-100 silently did nothing.
7270
*/
73-
export function applyThemeContrast(contrast: number) {
74-
const root = document.documentElement;
75-
root.style.setProperty("--theme-contrast", String(Math.max(0, contrast) / 100));
76-
root.style.setProperty("--theme-fade", String(Math.max(0, -contrast) / 100));
71+
export function applyThemeContrast(percent: number) {
72+
document.documentElement.style.setProperty("--theme-contrast-percent", String(percent / 100));
7773
}
7874

7975
export function applyThemePreference(

apps/webapp/app/root.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -226,15 +226,8 @@ export default function App() {
226226
// Underlines links carrying the inline-text-link marker class
227227
data-underline-links={underlineLinks ? "true" : "false"}
228228
// Contrast overlay input for the System themes; Classic never reads it
229-
style={
230-
{
231-
// Split at 0 so the existing ramps never see a negative percentage:
232-
// `--theme-contrast` strengthens as before, `--theme-fade` carries
233-
// the Black-only half below the base.
234-
"--theme-contrast": Math.max(0, themeContrast) / 100,
235-
"--theme-fade": Math.max(0, -themeContrast) / 100,
236-
} as CSSProperties
237-
}
229+
// Just the percent; each theme maps it onto its own contrast range in CSS
230+
style={{ "--theme-contrast-percent": themeContrast / 100 } as CSSProperties}
238231
>
239232
<head>
240233
<script

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

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ import {
8080
normalizeThemeContrast,
8181
normalizeUnderlineLinks,
8282
normalizeThemePreference,
83-
BLACK_CONTRAST_OFFSET,
8483
SystemDarkTheme,
8584
SystemLightTheme,
8685
type ThemePreference,
@@ -97,9 +96,9 @@ export const meta = pageMeta("Your profile");
9796
* Classic theme shipped, so the bottom of the range has to stay reachable. */
9897
const MIN_CONTRAST = 0;
9998

100-
/** Where the slider ticks and labels "Default", in the 0-100 the user sees. On
101-
* Black that lands on the theme's faint floor rather than the base palette; the
102-
* offset below does the translating. */
99+
/** Where the slider ticks and labels "Default". 0 is the bottom of whichever
100+
* theme's range is active - the base palette on most, the faded grid lines on
101+
* Black. */
103102
const DEFAULT_CONTRAST_MARK = 0;
104103

105104
function themeIcon(value: ThemePreference, appearance: ThemeAppearance) {
@@ -877,11 +876,6 @@ export default function Page() {
877876
);
878877
const systemThemes = { light: systemLightTheme, dark: systemDarkTheme };
879878

880-
/* Which of the four is on screen, resolving `system` through the OS
881-
appearance. Only Black gets the extra travel below the base, so the slider
882-
needs to know rather than reading the raw preference. */
883-
const activeTheme = theme === "system" ? systemThemes[appearance] : theme;
884-
885879
const saveSystemTheme = (end: "light" | "dark", value: string) => {
886880
const fetcher = end === "light" ? systemLightFetcher : systemDarkFetcher;
887881
// Re-resolve straight away: on `system` this changes which theme is showing
@@ -915,18 +909,6 @@ export default function Page() {
915909
{ method: "post" }
916910
);
917911

918-
/* The slider always runs 0-100 as far as the user is concerned. Black's range
919-
sits that whole window lower, so its stored value is the displayed one minus
920-
the offset: 0% on screen is -30 in the ramp, and nothing negative is ever
921-
shown or reachable by dragging. */
922-
const contrastOffset = activeTheme === "black" ? BLACK_CONTRAST_OFFSET : 0;
923-
const displayedContrast = Math.max(MIN_CONTRAST, contrastPreview + contrastOffset);
924-
const resetContrast = () => {
925-
const stored = DEFAULT_CONTRAST_MARK - contrastOffset;
926-
previewContrast(stored);
927-
saveContrast(stored);
928-
};
929-
930912
return (
931913
<PageContainer>
932914
<NavBar>
@@ -1120,15 +1102,18 @@ export default function Page() {
11201102
{
11211103
value: DEFAULT_CONTRAST_MARK,
11221104
label: "Reset to default",
1123-
onSelect: resetContrast,
1105+
onSelect: () => {
1106+
previewContrast(DEFAULT_CONTRAST_MARK);
1107+
saveContrast(DEFAULT_CONTRAST_MARK);
1108+
},
11241109
},
11251110
]}
11261111
valueTooltip={(value) =>
11271112
value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%`
11281113
}
1129-
value={[displayedContrast]}
1130-
onValueChange={(values) => previewContrast((values[0] ?? 0) - contrastOffset)}
1131-
onValueCommit={(values) => saveContrast((values[0] ?? 0) - contrastOffset)}
1114+
value={[contrastPreview]}
1115+
onValueChange={(values) => previewContrast(values[0] ?? 0)}
1116+
onValueCommit={(values) => saveContrast(values[0] ?? 0)}
11321117
/>
11331118
</div>
11341119
</div>

apps/webapp/app/tailwind.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,25 @@
808808
}
809809
}
810810

811+
/*
812+
The contrast slider stores a plain 0-100, and it means "how far along this
813+
theme's range" rather than a position on one shared scale. That keeps the
814+
number portable: 35% is 35% whichever theme you switch to, even though the two
815+
themes put their 35% in different places.
816+
817+
Every ramp reads `--theme-contrast` (strengthen) and `--theme-fade` (fade below
818+
the base, Black only). Mapping the percent onto those here rather than in JS
819+
means nothing on the client has to know which theme resolved - which matters
820+
for `system`, where that isn't settled until hydration.
821+
822+
Default range: 0 to 100, so the percent passes straight through and there's
823+
nothing to fade.
824+
*/
825+
:root {
826+
--theme-contrast: var(--theme-contrast-percent, 0);
827+
--theme-fade: 0;
828+
}
829+
811830
/*
812831
Light theme. Overrides the themable variables only; raw palettes stay put.
813832
Code/editor values come from the trigger.light VS Code theme.
@@ -1216,6 +1235,16 @@
12161235
--color-background-deep: #000000;
12171236
--color-background-dimmed: #000000;
12181237
--color-background-bright: #000000;
1238+
/*
1239+
Black's range is -30 to 100 rather than 0 to 100: its 0% fades the grid lines
1240+
below the base palette, which is where this theme wants to start, and its
1241+
100% is full strength like everywhere else. r = p * 1.3 - 0.3, split into the
1242+
two always-positive halves the ramps need - `color-mix` rejects a negative
1243+
percentage outright.
1244+
*/
1245+
--theme-contrast: max(0, var(--theme-contrast-percent, 0) * 1.3 - 0.3);
1246+
--theme-fade: max(0, 0.3 - var(--theme-contrast-percent, 0) * 1.3);
1247+
12191248
/*
12201249
Grid lines get the extra travel below the base that the other themes don't
12211250
have. Everything here sits flat on #000, so the rules are the only thing

apps/webapp/app/utils/dashboardPreferences.ts

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import { z } from "zod";
2-
import {
3-
BLACK_CONTRAST_OFFSET,
4-
SystemDarkTheme,
5-
SystemLightTheme,
6-
ThemePreference,
7-
} from "~/utils/themePreference";
2+
import { SystemDarkTheme, SystemLightTheme, ThemePreference } from "~/utils/themePreference";
83

94
/* Schema and pure parsing for the User.dashboardPreferences JSON column.
105
Kept out of the .server module so tests can exercise the schema without
@@ -55,11 +50,9 @@ const DashboardPreferences = z.object({
5550
/* An unknown value (e.g. written by a newer deploy) degrades to undefined
5651
instead of failing the whole blob and erasing every other setting */
5752
theme: ThemePreference.optional().catch(undefined),
58-
/** Interface contrast. 0-100, and down to -BLACK_CONTRAST_OFFSET on Black,
59-
* which fades its grid lines below the base palette. A floor of 0 here threw
60-
* the negative half away on read - `.catch(undefined)` meant it degraded
61-
* silently to the default, so the value saved and then snapped back. */
62-
contrast: z.number().int().min(-BLACK_CONTRAST_OFFSET).max(100).optional().catch(undefined),
53+
/** Interface contrast, 0-100. A percentage of the active theme's own range
54+
* rather than a shared scale, so it stays meaningful across themes. */
55+
contrast: z.number().int().min(0).max(100).optional().catch(undefined),
6356
/** Swaps the Classic icon and badge accents for the high-contrast set. */
6457
iconContrast: z.boolean().optional().catch(undefined),
6558
/** Underlines inline links. */

apps/webapp/app/utils/themePreference.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,16 @@ export function normalizeUnderlineLinks(value: unknown): boolean {
5353

5454
/** Interface contrast for the System themes, 0 to 100. Missing or invalid
5555
* values fall back to the default bump. */
56-
/**
57-
* How far below the base palette the Black theme's grid lines may fade.
58-
*
59-
* Black pins every surface flat on #000, so the rules are the only thing giving
60-
* a page structure - and the base palette draws them heavier than that theme
61-
* wants. This is also Black's default: the faint end is where it should start,
62-
* and the slider only goes up from there.
63-
*
64-
* The slider hides this entirely. It works in 0-100 whatever the theme, adding
65-
* the offset on the way in and taking it off on the way out, so Black's floor
66-
* reads as 0% and the user never sees a negative number.
56+
/*
57+
* Contrast is stored as a plain 0-100: "how far along the active theme's range",
58+
* not a position on one shared scale. Each theme maps it onto its own range in
59+
* tailwind.css - Black's starts below the base palette so its 0% fades the grid
60+
* lines, while the others start at the base. Keeping the stored value a
61+
* percentage is what lets 35% stay 35% when you switch themes.
6762
*/
68-
export const BLACK_CONTRAST_OFFSET = 30;
6963

7064
export function normalizeThemeContrast(value: unknown): number {
7165
const num = typeof value === "string" ? Number(value) : value;
7266
if (typeof num !== "number" || !Number.isFinite(num)) return DEFAULT_THEME_CONTRAST;
73-
return Math.min(100, Math.max(-BLACK_CONTRAST_OFFSET, Math.round(num)));
67+
return Math.min(100, Math.max(0, Math.round(num)));
7468
}

0 commit comments

Comments
 (0)