Skip to content

Commit cc03dfb

Browse files
committed
fix(webapp): address light-theme PR review feedback
- restore white spinners on primary buttons (GitHub connect, Vercel onboarding) - resnap the contrast preview to the stored value when a save fails - the dark backdrop reliably hides on the Light theme - suppressHydrationWarning on <html> for the pre-paint system-theme script - aria-labels for the theme select and contrast slider - lowercase currentcolor for stylelint - DashboardPreferences schema moves to a pure module so tests skip the server graph - user-facing wording for the release note
1 parent e963f1b commit cc03dfb

11 files changed

Lines changed: 148 additions & 94 deletions

File tree

.server-changes/light-theme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: feature
44
---
55

6-
Adds an opt-in Interface theme setting on the account page behind a feature flag. Choose Dark (the new default), System preference (follows your OS), Light, or the original Classic look, and fine-tune the new themes with a contrast slider. System preference switches automatically when your OS appearance changes.
6+
Adds an Interface theme setting on the account page: choose Dark, System preference, Light, or the original Classic look, and fine-tune the new themes with a contrast slider. System preference follows your OS appearance automatically.

apps/webapp/app/components/BackgroundWrapper.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ const BACKDROPS = [
1616
table: blurredDashboardBackgroundTable,
1717
},
1818
tableFill: "#101214",
19-
className: "hidden lg:block light:lg:hidden",
19+
/* `light` is a zero-specificity variant, so `light:lg:hidden` ties with
20+
`lg:block` - the `!` guarantees the dark backdrop loses on Light. */
21+
className: "hidden lg:block light:lg:hidden!",
2022
},
2123
{
2224
images: {

apps/webapp/app/components/integrations/VercelOnboardingModal.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { InputGroup } from "~/components/primitives/InputGroup";
1717
import { Label } from "~/components/primitives/Label";
1818
import { Paragraph } from "~/components/primitives/Paragraph";
1919
import { Select, SelectItem } from "~/components/primitives/Select";
20-
import { Spinner } from "~/components/primitives/Spinner";
20+
import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner";
2121
import { Switch } from "~/components/primitives/Switch";
2222
import {
2323
Tooltip,
@@ -859,7 +859,7 @@ export function VercelOnboardingModal({
859859
variant="primary/medium"
860860
onClick={handleProjectSelection}
861861
disabled={!selectedVercelProject || fetcher.state !== "idle"}
862-
LeadingIcon={fetcher.state !== "idle" ? Spinner : undefined}
862+
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
863863
>
864864
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
865865
</Button>
@@ -930,7 +930,7 @@ export function VercelOnboardingModal({
930930
variant="primary/medium"
931931
onClick={handleUpdateEnvMapping}
932932
disabled={envMappingFetcher.state !== "idle"}
933-
LeadingIcon={envMappingFetcher.state !== "idle" ? Spinner : undefined}
933+
LeadingIcon={envMappingFetcher.state !== "idle" ? SpinnerWhite : undefined}
934934
>
935935
Next
936936
</Button>
@@ -1112,7 +1112,7 @@ export function VercelOnboardingModal({
11121112
LeadingIcon={
11131113
fromMarketplaceContext &&
11141114
(completeOnboardingFetcher.state !== "idle" || isRedirecting)
1115-
? Spinner
1115+
? SpinnerWhite
11161116
: undefined
11171117
}
11181118
>
@@ -1168,7 +1168,7 @@ export function VercelOnboardingModal({
11681168
disabled={completeOnboardingFetcher.state !== "idle" || isRedirecting}
11691169
LeadingIcon={
11701170
completeOnboardingFetcher.state !== "idle" || isRedirecting
1171-
? Spinner
1171+
? SpinnerWhite
11721172
: undefined
11731173
}
11741174
>

apps/webapp/app/components/primitives/Slider.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,14 @@ export type SliderProps = ComponentProps<typeof RadixSlider.Root> & {
3636
variant: VariantName;
3737
};
3838

39-
export function Slider({ variant, className, LeadingIcon, TrailingIcon, ...props }: SliderProps) {
39+
export function Slider({
40+
variant,
41+
className,
42+
LeadingIcon,
43+
TrailingIcon,
44+
"aria-label": ariaLabel,
45+
...props
46+
}: SliderProps) {
4047
const variation = variants[variant];
4148
return (
4249
<div className={cn("group flex items-center", variation.container)}>
@@ -52,7 +59,9 @@ export function Slider({ variant, className, LeadingIcon, TrailingIcon, ...props
5259
<RadixSlider.Track className={cn("relative grow rounded-full", variation.track)}>
5360
<RadixSlider.Range className={cn("absolute h-full rounded-full", variation.range)} />
5461
</RadixSlider.Track>
62+
{/* The thumb is the role="slider" element, so the label lives here */}
5563
<RadixSlider.Thumb
64+
aria-label={ariaLabel}
5665
className={cn(
5766
"block cursor-pointer rounded-full transition focus:outline-hidden",
5867
variation.thumb

apps/webapp/app/root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ export default function App() {
185185
<html
186186
lang="en"
187187
className="h-full"
188+
// The pre-paint script below may flip data-theme before hydration
189+
suppressHydrationWarning
188190
data-theme={resolvedTheme}
189191
data-theme-preference={themePreference}
190192
// Contrast overlay input for the System themes; Classic never reads it

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
2+
import { useEffect, useState } from "react";
23
import { conformZodMessage, parseWithZod } from "@conform-to/zod";
34
import { ComputerDesktopIcon, MoonIcon, SunIcon, SwatchIcon } from "@heroicons/react/20/solid";
45
import {
@@ -213,6 +214,17 @@ export default function Page() {
213214
? normalizeThemePreference(pendingTheme)
214215
: normalizeThemePreference(user.dashboardPreferences.theme);
215216

217+
// Dragging previews the contrast via the CSS var before it persists; once the
218+
// save settles, resnap the page and the thumb to the stored value so a failed
219+
// or rejected save doesn't leave a phantom contrast level on screen.
220+
const [contrastPreview, setContrastPreview] = useState(contrast);
221+
useEffect(() => {
222+
if (contrastFetcher.state === "idle") {
223+
setContrastPreview(contrast);
224+
document.documentElement.style.setProperty("--theme-contrast", String(contrast / 100));
225+
}
226+
}, [contrastFetcher.state, contrast]);
227+
216228
const [form, { name, email, marketingEmails }] = useForm({
217229
id: "account",
218230
// TODO: type this
@@ -305,6 +317,7 @@ export default function Page() {
305317
<div className="flex w-full items-center justify-between gap-4">
306318
<Label>Interface theme</Label>
307319
<Select<ThemePreference, ThemePreference>
320+
aria-label="Interface theme"
308321
value={theme}
309322
setValue={(value) =>
310323
themeFetcher.submit(
@@ -338,15 +351,18 @@ export default function Page() {
338351
<Slider
339352
variant="settings"
340353
className="w-44"
354+
aria-label="Contrast"
341355
min={0}
342356
max={100}
343357
step={5}
344-
defaultValue={[contrast]}
358+
value={[contrastPreview]}
345359
onValueChange={(values) => {
346360
// Live preview before the preference persists
361+
const value = values[0] ?? 0;
362+
setContrastPreview(value);
347363
document.documentElement.style.setProperty(
348364
"--theme-contrast",
349-
String((values[0] ?? 0) / 100)
365+
String(value / 100)
350366
);
351367
}}
352368
onValueCommit={(values) =>

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import {
4646
SettingsRow,
4747
SettingsRowDescription,
4848
} from "~/components/primitives/SettingsLayout";
49-
import { Spinner } from "~/components/primitives/Spinner";
49+
import { Spinner, SpinnerWhite } from "~/components/primitives/Spinner";
5050
import { Switch } from "~/components/primitives/Switch";
5151
import { TextLink } from "~/components/primitives/TextLink";
5252
import {
@@ -609,8 +609,7 @@ export function ConnectGitHubRepoModal({
609609
name="action"
610610
value="connect-repo"
611611
variant="primary/medium"
612-
LeadingIcon={isConnectRepositoryLoading ? Spinner : undefined}
613-
leadingIconClassName="text-white"
612+
LeadingIcon={isConnectRepositoryLoading ? SpinnerWhite : undefined}
614613
disabled={isConnectRepositoryLoading}
615614
>
616615
Connect repository

apps/webapp/app/services/dashboardPreferences.server.ts

Lines changed: 16 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,29 @@
1-
import { z } from "zod";
21
import { $transaction, prisma } from "~/db.server";
32
import { logger } from "./logger.server";
43
import { type UserFromSession } from "./session.server";
5-
6-
const FavoritePage = z.object({
7-
/** Stable id, generated client-side when the page is favorited. */
8-
id: z.string(),
9-
/** App-relative URL including any search params (filters, tabs). */
10-
url: z.string(),
11-
/** Display label shown in the side menu; user-renamable. */
12-
label: z.string(),
13-
/** Key into the favorite page icon registry. */
14-
icon: z.string().optional(),
15-
});
16-
17-
export type FavoritePage = z.infer<typeof FavoritePage>;
18-
19-
const SideMenuPreferences = z.object({
20-
isCollapsed: z.boolean().default(false),
21-
/** Expanded side menu width in px, set by the resize handle. */
22-
width: z.number().optional(),
23-
// Map for section collapsed states - keys are section identifiers
24-
collapsedSections: z.record(z.string(), z.boolean()).optional(),
25-
/** Organization-specific settings */
26-
organizations: z
27-
.record(
28-
z.string(),
29-
z.object({
30-
orderedItems: z.record(z.string(), z.array(z.string())),
31-
})
32-
)
33-
.optional(),
34-
/** Pages the user favorited, in display order. */
35-
favorites: z.array(FavoritePage).optional(),
36-
/** Custom top-to-bottom order of side menu sections (section ids). */
37-
sectionOrder: z.array(z.string()).optional(),
38-
/** Per-item visibility overrides (item id -> hidden). Items absent fall back to their default. */
39-
hiddenItems: z.record(z.string(), z.boolean()).optional(),
40-
/** Custom item order within a section (section id -> item ids). */
41-
sectionItemOrder: z.record(z.string(), z.array(z.string())).optional(),
42-
});
43-
44-
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
4+
import {
5+
type DashboardPreferences,
6+
type FavoritePage,
7+
parseDashboardPreferences,
8+
SideMenuPreferences,
9+
} from "~/utils/dashboardPreferences";
10+
11+
export type {
12+
DashboardPreferences,
13+
FavoritePage,
14+
SideMenuPreferences,
15+
} from "~/utils/dashboardPreferences";
4516

4617
import { type SideMenuSectionId } from "~/components/navigation/sideMenuTypes";
4718
export type { SideMenuSectionId };
4819

49-
import { ThemePreference } from "~/utils/themePreference";
20+
import { type ThemePreference } from "~/utils/themePreference";
5021
export { normalizeThemePreference, type ThemePreference } from "~/utils/themePreference";
5122

52-
const DashboardPreferences = z.object({
53-
version: z.literal("1"),
54-
theme: ThemePreference.optional(),
55-
/** Interface contrast for the System themes, 0-100. */
56-
contrast: z.number().int().min(0).max(100).optional(),
57-
currentProjectId: z.string().optional(),
58-
projects: z.record(
59-
z.string(),
60-
z.object({
61-
currentEnvironment: z.object({ id: z.string() }),
62-
})
63-
),
64-
sideMenu: SideMenuPreferences.optional(),
65-
});
66-
67-
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
68-
6923
export function getDashboardPreferences(data?: any | null): DashboardPreferences {
70-
if (!data) {
71-
return {
72-
version: "1",
73-
projects: {},
74-
};
75-
}
76-
77-
const result = DashboardPreferences.safeParse(data);
78-
if (!result.success) {
79-
logger.error("Failed to parse DashboardPreferences", { data, error: result.error });
80-
return {
81-
version: "1",
82-
projects: {},
83-
};
84-
}
85-
86-
return result.data;
24+
return parseDashboardPreferences(data, (error) => {
25+
logger.error("Failed to parse DashboardPreferences", { data, error });
26+
});
8727
}
8828

8929
/**

apps/webapp/app/tailwind.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@
299299
Classic never sets the variable. Marker set by the chip components. */
300300
:is([data-theme="dark"], [data-theme="light"]) .contrast-chip {
301301
box-shadow: inset 0 0 0 1px
302-
color-mix(in srgb, currentColor calc(var(--theme-contrast, 0) * 70%), transparent);
302+
color-mix(in srgb, currentcolor calc(var(--theme-contrast, 0) * 70%), transparent);
303303
}
304304

305305
/*
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { z } from "zod";
2+
import { ThemePreference } from "~/utils/themePreference";
3+
4+
/* Schema and pure parsing for the User.dashboardPreferences JSON column.
5+
Kept out of the .server module so tests can exercise the schema without
6+
pulling in the server env graph. */
7+
8+
export const FavoritePage = z.object({
9+
/** Stable id, generated client-side when the page is favorited. */
10+
id: z.string(),
11+
/** App-relative URL including any search params (filters, tabs). */
12+
url: z.string(),
13+
/** Display label shown in the side menu; user-renamable. */
14+
label: z.string(),
15+
/** Key into the favorite page icon registry. */
16+
icon: z.string().optional(),
17+
});
18+
19+
export type FavoritePage = z.infer<typeof FavoritePage>;
20+
21+
export const SideMenuPreferences = z.object({
22+
isCollapsed: z.boolean().default(false),
23+
/** Expanded side menu width in px, set by the resize handle. */
24+
width: z.number().optional(),
25+
// Map for section collapsed states - keys are section identifiers
26+
collapsedSections: z.record(z.string(), z.boolean()).optional(),
27+
/** Organization-specific settings */
28+
organizations: z
29+
.record(
30+
z.string(),
31+
z.object({
32+
orderedItems: z.record(z.string(), z.array(z.string())),
33+
})
34+
)
35+
.optional(),
36+
/** Pages the user favorited, in display order. */
37+
favorites: z.array(FavoritePage).optional(),
38+
/** Custom top-to-bottom order of side menu sections (section ids). */
39+
sectionOrder: z.array(z.string()).optional(),
40+
/** Per-item visibility overrides (item id -> hidden). Items absent fall back to their default. */
41+
hiddenItems: z.record(z.string(), z.boolean()).optional(),
42+
/** Custom item order within a section (section id -> item ids). */
43+
sectionItemOrder: z.record(z.string(), z.array(z.string())).optional(),
44+
});
45+
46+
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
47+
48+
const DashboardPreferences = z.object({
49+
version: z.literal("1"),
50+
theme: ThemePreference.optional(),
51+
/** Interface contrast for the System themes, 0-100. */
52+
contrast: z.number().int().min(0).max(100).optional(),
53+
currentProjectId: z.string().optional(),
54+
projects: z.record(
55+
z.string(),
56+
z.object({
57+
currentEnvironment: z.object({ id: z.string() }),
58+
})
59+
),
60+
sideMenu: SideMenuPreferences.optional(),
61+
});
62+
63+
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
64+
65+
const DEFAULT_PREFERENCES: DashboardPreferences = {
66+
version: "1",
67+
projects: {},
68+
};
69+
70+
/** Parses the stored JSON, falling back to defaults on missing or invalid data. */
71+
export function parseDashboardPreferences(
72+
data?: any | null,
73+
onError?: (error: z.ZodError) => void
74+
): DashboardPreferences {
75+
if (!data) {
76+
return { ...DEFAULT_PREFERENCES };
77+
}
78+
79+
const result = DashboardPreferences.safeParse(data);
80+
if (!result.success) {
81+
onError?.(result.error);
82+
return { ...DEFAULT_PREFERENCES };
83+
}
84+
85+
return result.data;
86+
}

0 commit comments

Comments
 (0)