Skip to content

Commit 4360b63

Browse files
samejrclaude
andcommitted
fix(webapp): stop a negative contrast being thrown away on read, and hide the scale
Two things were wrong with Black's extra travel. Saving below zero didn't stick. The preference blob is parsed with a zod schema whose contrast field floored at 0, and every field there uses `.catch(undefined)` so an out-of-range value degrades quietly rather than failing the whole blob. The write reached the database intact; the read then discarded it and fell back to the default, which is why the handle sprang back to 0% a moment after release - and why it seemed to hold if the save hadn't settled yet. The floor now follows the offset. The negative range was also on show. Black's floor is -30 in the ramp, and the slider now presents that as 0%: it works in 0-100 whatever the theme, adding the offset on the way in and taking it off on the way out. So Black's "Default" tick sits at the left end where the lines are faintest, nothing below it is reachable by dragging, and no negative number is ever displayed. -30 is also the floor now rather than -100, since below it the rules disappear entirely. One consequence: Black's window is the same width in the ramp as before but shifted down, so its strongest setting is 70 rather than 100. That end is the last thing this theme needs, and the slider reads 0-100 on every theme as a result. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2110993 commit 4360b63

3 files changed

Lines changed: 45 additions & 37 deletions

File tree

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

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

101-
/** The contrast the slider ticks and labels as "Default". Matches
102-
* `DEFAULT_THEME_CONTRAST`, the value applied when none is saved. */
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. */
103103
const DEFAULT_CONTRAST_MARK = 0;
104104

105105
function themeIcon(value: ThemePreference, appearance: ThemeAppearance) {
@@ -881,12 +881,6 @@ export default function Page() {
881881
appearance. Only Black gets the extra travel below the base, so the slider
882882
needs to know rather than reading the raw preference. */
883883
const activeTheme = theme === "system" ? systemThemes[appearance] : theme;
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;
890884

891885
const saveSystemTheme = (end: "light" | "dark", value: string) => {
892886
const fetcher = end === "light" ? systemLightFetcher : systemDarkFetcher;
@@ -921,6 +915,18 @@ export default function Page() {
921915
{ method: "post" }
922916
);
923917

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+
924930
return (
925931
<PageContainer>
926932
<NavBar>
@@ -1107,25 +1113,22 @@ export default function Page() {
11071113
variant="settings"
11081114
className="w-44"
11091115
aria-label="Contrast"
1110-
min={minContrast}
1116+
min={MIN_CONTRAST}
11111117
max={100}
11121118
step={1}
11131119
marks={[
11141120
{
1115-
value: defaultContrast,
1121+
value: DEFAULT_CONTRAST_MARK,
11161122
label: "Reset to default",
1117-
onSelect: () => {
1118-
previewContrast(defaultContrast);
1119-
saveContrast(defaultContrast);
1120-
},
1123+
onSelect: resetContrast,
11211124
},
11221125
]}
11231126
valueTooltip={(value) =>
1124-
value === defaultContrast ? "Default" : `${value}%`
1127+
value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%`
11251128
}
1126-
value={[Math.max(minContrast, contrastPreview)]}
1127-
onValueChange={(values) => previewContrast(values[0] ?? 0)}
1128-
onValueCommit={(values) => saveContrast(values[0] ?? 0)}
1129+
value={[displayedContrast]}
1130+
onValueChange={(values) => previewContrast((values[0] ?? 0) - contrastOffset)}
1131+
onValueCommit={(values) => saveContrast((values[0] ?? 0) - contrastOffset)}
11291132
/>
11301133
</div>
11311134
</div>

apps/webapp/app/utils/dashboardPreferences.ts

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

49
/* Schema and pure parsing for the User.dashboardPreferences JSON column.
510
Kept out of the .server module so tests can exercise the schema without
@@ -50,8 +55,11 @@ const DashboardPreferences = z.object({
5055
/* An unknown value (e.g. written by a newer deploy) degrades to undefined
5156
instead of failing the whole blob and erasing every other setting */
5257
theme: ThemePreference.optional().catch(undefined),
53-
/** Interface contrast for the System themes, 0-100. */
54-
contrast: z.number().int().min(0).max(100).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),
5563
/** Swaps the Classic icon and badge accents for the high-contrast set. */
5664
iconContrast: z.boolean().optional().catch(undefined),
5765
/** Underlines inline links. */

apps/webapp/app/utils/themePreference.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,22 @@ 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-
/** 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. -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-
6256
/**
63-
* Where "Default" sits on the Black theme.
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.
6463
*
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.
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.
7067
*/
71-
export const DEFAULT_THEME_CONTRAST_BLACK = -40;
68+
export const BLACK_CONTRAST_OFFSET = 30;
7269

7370
export function normalizeThemeContrast(value: unknown): number {
7471
const num = typeof value === "string" ? Number(value) : value;
7572
if (typeof num !== "number" || !Number.isFinite(num)) return DEFAULT_THEME_CONTRAST;
76-
return Math.min(100, Math.max(MIN_THEME_CONTRAST_BLACK, Math.round(num)));
73+
return Math.min(100, Math.max(-BLACK_CONTRAST_OFFSET, Math.round(num)));
7774
}

0 commit comments

Comments
 (0)