Skip to content

Commit 2110993

Browse files
samejrclaude
andcommitted
fix(webapp): make the contrast slider's negative half actually apply
The ramps read `--theme-contrast`, and the live preview wrote the raw slider value straight into it. Below zero that becomes a negative percentage inside `color-mix`, which is invalid, so the browser dropped the declaration and the token kept whatever it had - dragging into the negative range did nothing at all. Only a full page render went through the split in root.tsx and behaved. Both client writers now go through one `applyThemeContrast` helper that sets the strengthen half and the fade half together, so there's a single place that knows how a signed value maps onto the two variables. Black's floor drops to -100, which takes the grid lines all the way to the page colour, and "Default" on Black is now -40 rather than 0. That number mirrors the light end: on White the rules measure 1.16:1 and 1.27:1 against their page, and -40 puts Black's at 1.15:1 and 1.19:1 against #000 - the same near-invisible weight from the other direction. The base palette sits at 1.33:1 and 1.46:1, heavier than this theme wants when the rules are the only structure there. The default tick and the "Default" label both follow the per-theme value, so resetting on Black lands on -40. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a344664 commit 2110993

3 files changed

Lines changed: 45 additions & 11 deletions

File tree

‎apps/webapp/app/hooks/useSystemThemeSync.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ export function resolveThemePreference(
6060
* through the server and comes back via the root loader, and anything that waits
6161
* for that is at the mercy of whether the revalidation actually lands.
6262
*/
63+
/**
64+
* Write the contrast preference to the document.
65+
*
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.
72+
*/
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));
77+
}
78+
6379
export function applyThemePreference(
6480
preference: ThemePreference,
6581
systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES

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

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { prisma } from "~/db.server";
5656
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
5757
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
5858
import {
59+
applyThemeContrast,
5960
applyThemePreference,
6061
type ThemeAppearance,
6162
useThemeAppearance,
@@ -79,6 +80,7 @@ import {
7980
normalizeThemeContrast,
8081
normalizeUnderlineLinks,
8182
normalizeThemePreference,
83+
DEFAULT_THEME_CONTRAST_BLACK,
8284
MIN_THEME_CONTRAST_BLACK,
8385
SystemDarkTheme,
8486
SystemLightTheme,
@@ -879,7 +881,12 @@ export default function Page() {
879881
appearance. Only Black gets the extra travel below the base, so the slider
880882
needs to know rather than reading the raw preference. */
881883
const activeTheme = theme === "system" ? systemThemes[appearance] : theme;
882-
const minContrast = activeTheme === "black" ? MIN_THEME_CONTRAST_BLACK : MIN_CONTRAST;
884+
const isBlack = activeTheme === "black";
885+
const minContrast = isBlack ? MIN_THEME_CONTRAST_BLACK : MIN_CONTRAST;
886+
/* Black's rules want to sit far fainter than the base palette puts them, so
887+
"Default" means something different there - both the tick and the label
888+
follow it. */
889+
const defaultContrast = isBlack ? DEFAULT_THEME_CONTRAST_BLACK : DEFAULT_CONTRAST_MARK;
883890

884891
const saveSystemTheme = (end: "light" | "dark", value: string) => {
885892
const fetcher = end === "light" ? systemLightFetcher : systemDarkFetcher;
@@ -898,15 +905,15 @@ export default function Page() {
898905
useEffect(() => {
899906
if (contrastFetcher.state === "idle") {
900907
setContrastPreview(contrast);
901-
document.documentElement.style.setProperty("--theme-contrast", String(contrast / 100));
908+
applyThemeContrast(contrast);
902909
}
903910
}, [contrastFetcher.state, contrast]);
904911

905912
// Dragging previews through the CSS var; releasing (or clicking the default
906913
// mark) persists it.
907914
const previewContrast = (value: number) => {
908915
setContrastPreview(value);
909-
document.documentElement.style.setProperty("--theme-contrast", String(value / 100));
916+
applyThemeContrast(value);
910917
};
911918
const saveContrast = (value: number) =>
912919
contrastFetcher.submit(
@@ -1105,16 +1112,16 @@ export default function Page() {
11051112
step={1}
11061113
marks={[
11071114
{
1108-
value: DEFAULT_CONTRAST_MARK,
1115+
value: defaultContrast,
11091116
label: "Reset to default",
11101117
onSelect: () => {
1111-
previewContrast(DEFAULT_CONTRAST_MARK);
1112-
saveContrast(DEFAULT_CONTRAST_MARK);
1118+
previewContrast(defaultContrast);
1119+
saveContrast(defaultContrast);
11131120
},
11141121
},
11151122
]}
11161123
valueTooltip={(value) =>
1117-
value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%`
1124+
value === defaultContrast ? "Default" : `${value}%`
11181125
}
11191126
value={[Math.max(minContrast, contrastPreview)]}
11201127
onValueChange={(values) => previewContrast(values[0] ?? 0)}

‎apps/webapp/app/utils/themePreference.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,21 @@ export function normalizeUnderlineLinks(value: unknown): boolean {
5454
/** Interface contrast for the System themes, 0 to 100. Missing or invalid
5555
* values fall back to the default bump. */
5656
/** Floor of the contrast range on the Black theme, where the slider can also run
57-
* *below* the base palette to fade the grid lines back toward the page. Every
58-
* other theme floors at 0; a negative value simply has no ramp to act on there,
59-
* so it reads as 0. */
60-
export const MIN_THEME_CONTRAST_BLACK = -50;
57+
* *below* the base palette to fade the grid lines back toward the page. -100
58+
* takes them all the way to the page colour, i.e. gone. Every other theme floors
59+
* at 0; a negative value simply has no ramp to act on there, so it reads as 0. */
60+
export const MIN_THEME_CONTRAST_BLACK = -100;
61+
62+
/**
63+
* Where "Default" sits on the Black theme.
64+
*
65+
* Chosen to mirror how the light end reads: on White the grid lines measure
66+
* 1.16:1 (dimmed) and 1.27:1 (bright) against their page. -40 puts Black's at
67+
* 1.15:1 and 1.19:1 against #000 - the same near-invisible weight, from the other
68+
* direction. The base palette sits at 1.33:1 and 1.46:1, which is heavier than
69+
* this theme wants when the rules are the only structure on the page.
70+
*/
71+
export const DEFAULT_THEME_CONTRAST_BLACK = -40;
6172

6273
export function normalizeThemeContrast(value: unknown): number {
6374
const num = typeof value === "string" ? Number(value) : value;

0 commit comments

Comments
 (0)