Skip to content

Commit 267ab71

Browse files
samejrclaude
andcommitted
feat(webapp): add theme shortcuts, an icon contrast toggle and an H2 title
The storybook header's five theme segments each take mod+1..5, with a hover tooltip on the standard 500ms delay showing the theme and its key. The shortcuts live in this layout, so they only bind inside the storybook, and they preventDefault because browsers claim those keys for tab switching. Alongside them, an Icon contrast switch flips data-icon-contrast the same storybook-local way the theme control works — both hand the account preference back when you navigate away. "Storybook" becomes a Header2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7040f45 commit 267ab71

1 file changed

Lines changed: 123 additions & 20 deletions

File tree

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

Lines changed: 123 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
33
import { useEffect, useRef, useState } from "react";
44
import { redirect, typedjson, useTypedLoaderData, useTypedRouteLoaderData } from "remix-typedjson";
55
import { AppContainer } from "~/components/layout/AppLayout";
6-
import { Paragraph } from "~/components/primitives/Paragraph";
6+
import { Header2 } from "~/components/primitives/Headers";
77
import SegmentedControl from "~/components/primitives/SegmentedControl";
8+
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
9+
import { Switch } from "~/components/primitives/Switch";
10+
import { SimpleTooltip } from "~/components/primitives/Tooltip";
11+
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
812
import { applyThemePreference } from "~/hooks/useSystemThemeSync";
913
import { type loader as rootLoader } from "~/root";
1014
import { requireUser } from "~/services/session.server";
@@ -149,15 +153,97 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
149153
};
150154

151155
/* The storybook's theme is its own, so components can be checked in every theme
152-
without touching the account preference. Default is System. */
153-
const THEME_SEGMENTS: { label: string; value: ThemePreference }[] = [
154-
{ label: "System", value: "system" },
155-
{ label: "Light", value: "light" },
156-
{ label: "Dark", value: "dark" },
157-
{ label: "White", value: "white" },
158-
{ label: "Black", value: "black" },
156+
without touching the account preference. Default is System.
157+
158+
Each segment gets mod+1..5. The browser binds those to tab switching, hence
159+
preventDefault; the shortcuts only exist while this layout is mounted, so
160+
they're scoped to the storybook. */
161+
const THEME_OPTIONS: { label: string; value: ThemePreference; shortcut: ShortcutDefinition }[] = [
162+
{
163+
label: "System",
164+
value: "system",
165+
shortcut: { key: "1", modifiers: ["mod"], preventDefault: true },
166+
},
167+
{
168+
label: "Light",
169+
value: "light",
170+
shortcut: { key: "2", modifiers: ["mod"], preventDefault: true },
171+
},
172+
{
173+
label: "Dark",
174+
value: "dark",
175+
shortcut: { key: "3", modifiers: ["mod"], preventDefault: true },
176+
},
177+
{
178+
label: "White",
179+
value: "white",
180+
shortcut: { key: "4", modifiers: ["mod"], preventDefault: true },
181+
},
182+
{
183+
label: "Black",
184+
value: "black",
185+
shortcut: { key: "5", modifiers: ["mod"], preventDefault: true },
186+
},
159187
];
160188

189+
/** Hover hint carrying the theme name and its key, on the standard 500ms delay. */
190+
const TOOLTIP_DELAY_MS = 500;
191+
192+
function ThemeSegmentLabel({ label, shortcut }: { label: string; shortcut: ShortcutDefinition }) {
193+
return (
194+
<SimpleTooltip
195+
button={<span className="px-0.5">{label}</span>}
196+
content={
197+
<span className="flex items-center gap-1.5">
198+
{label}
199+
<ShortcutKey shortcut={shortcut} variant="small" />
200+
</span>
201+
}
202+
side="bottom"
203+
delayDuration={TOOLTIP_DELAY_MS}
204+
disableHoverableContent
205+
asChild
206+
/>
207+
);
208+
}
209+
210+
/** Binds one theme's shortcut. Separate component so each gets its own hook. */
211+
function ThemeShortcut({
212+
shortcut,
213+
onTrigger,
214+
}: {
215+
shortcut: ShortcutDefinition;
216+
onTrigger: () => void;
217+
}) {
218+
useShortcutKeys({ shortcut, action: onTrigger });
219+
return null;
220+
}
221+
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. */
224+
function useStorybookIconContrast() {
225+
const rootData = useTypedRouteLoaderData<typeof rootLoader>("root");
226+
const [iconContrast, setIconContrast] = useState(false);
227+
228+
const savedIconContrast = useRef(rootData?.iconContrast);
229+
savedIconContrast.current = rootData?.iconContrast;
230+
231+
useEffect(() => {
232+
document.documentElement.setAttribute("data-icon-contrast", iconContrast ? "true" : "false");
233+
}, [iconContrast]);
234+
235+
useEffect(() => {
236+
return () => {
237+
document.documentElement.setAttribute(
238+
"data-icon-contrast",
239+
savedIconContrast.current ? "true" : "false"
240+
);
241+
};
242+
}, []);
243+
244+
return [iconContrast, setIconContrast] as const;
245+
}
246+
161247
function useStorybookTheme() {
162248
const rootData = useTypedRouteLoaderData<typeof rootLoader>("root");
163249
const [theme, setTheme] = useState<ThemePreference>("system");
@@ -189,23 +275,40 @@ function useStorybookTheme() {
189275
export default function App() {
190276
const { sections } = useTypedLoaderData<typeof loader>();
191277
const [theme, setTheme] = useStorybookTheme();
278+
const [iconContrast, setIconContrast] = useStorybookIconContrast();
192279

193280
return (
194281
<AppContainer>
282+
{THEME_OPTIONS.map((option) => (
283+
<ThemeShortcut
284+
key={option.value}
285+
shortcut={option.shortcut}
286+
onTrigger={() => setTheme(option.value)}
287+
/>
288+
))}
195289
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
196290
<SideMenu sections={sections} />
197-
<div className="grid grid-rows-[2.75rem_1fr] overflow-hidden">
198-
<div className="flex items-center justify-between border-b border-grid-bright bg-background-bright pl-4 pr-2">
199-
<Paragraph variant="extra-small" className="text-text-dimmed">
200-
Storybook
201-
</Paragraph>
202-
<SegmentedControl
203-
name="storybook-theme"
204-
value={theme}
205-
options={THEME_SEGMENTS}
206-
variant="secondary/small"
207-
onChange={(value) => setTheme(value as ThemePreference)}
208-
/>
291+
<div className="grid grid-rows-[3rem_1fr] overflow-hidden">
292+
<div className="flex items-center justify-between gap-4 border-b border-grid-bright bg-background-bright pl-4 pr-2">
293+
<Header2>Storybook</Header2>
294+
<div className="flex flex-none items-center gap-3">
295+
<Switch
296+
variant="minimal/medium"
297+
label="Icon contrast"
298+
checked={iconContrast}
299+
onCheckedChange={setIconContrast}
300+
/>
301+
<SegmentedControl
302+
name="storybook-theme"
303+
value={theme}
304+
options={THEME_OPTIONS.map((option) => ({
305+
value: option.value,
306+
label: <ThemeSegmentLabel label={option.label} shortcut={option.shortcut} />,
307+
}))}
308+
variant="secondary/small"
309+
onChange={(value) => setTheme(value as ThemePreference)}
310+
/>
311+
</div>
209312
</div>
210313
<div className="overflow-y-auto">
211314
<Outlet />

0 commit comments

Comments
 (0)