Raise frontend contrast, unify the page frame, and add browser identity (#328)

This commit is contained in:
Aaron Po
2026-10-08 00:16:50 -04:00
parent 0e51630439
commit ff775e3b8a
46 changed files with 492 additions and 152 deletions

View File

@@ -31,7 +31,7 @@ const TextTokenSample = ({
}) => {
return (
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/70">
{label}
</p>
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
@@ -54,7 +54,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
Core
</p>
<div className="grid gap-2 sm:grid-cols-2">
@@ -69,7 +69,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
Status
</p>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
@@ -81,7 +81,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
</div>
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
Content
</p>
<div className="grid gap-2 sm:grid-cols-3">

View File

@@ -0,0 +1,167 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useMemo } from 'react';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { expect, fn, userEvent, within } from 'storybook/test';
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
import ThemeSegmentedControl from './ThemeSegmentedControl';
const themeSegmentedControlDescription = `The shared theme picker, used by the theme guide and by the landing page's try-out band. Each option pairs a gradient swatch of the theme's palette with its name and \`shortVibe\` mood line, and selection is carried by both color and elevation. Picking an option sets \`data-theme\` on \`<html>\` and writes the \`${themeCookieName}\` cookie, so the choice survives the next request and the server renders the same theme. The initial selection comes from the root route's loader, so these stories run behind a memory data router whose \`root\` route supplies the theme the cookie would have provided.`;
interface HarnessProps {
labels?: 'full' | 'short';
className?: string;
onChange?: (theme: ThemeName) => void;
/** Theme the root loader hands the control, standing in for the theme cookie. */
seedTheme?: ThemeName;
}
const ThemeSegmentedControlHarness = ({
labels,
className,
onChange,
seedTheme = defaultThemeName,
}: HarnessProps) => {
// Rebuilt per arg change so the docs controls remount the control rather
// than leaving it seeded from the previous render.
const router = useMemo(
() =>
createMemoryRouter([
{
id: 'root',
path: '/',
loader: () => ({ theme: seedTheme }),
element: (
<div className="max-w-md rounded-box bg-base-100 p-4 text-base-content">
<ThemeSegmentedControl
labels={labels}
className={className}
onChange={onChange}
/>
</div>
),
},
]),
[labels, className, onChange, seedTheme],
);
return <RouterProvider router={router} />;
};
const meta = {
title: 'Themes/ThemeSegmentedControl',
component: ThemeSegmentedControlHarness,
tags: ['autodocs'],
args: {
labels: 'full',
seedTheme: defaultThemeName,
onChange: fn(),
},
argTypes: {
labels: { control: 'inline-radio', options: ['full', 'short'] },
seedTheme: {
control: 'select',
options: biergartenThemes.map((theme) => theme.value),
},
},
parameters: {
usesDataRouter: true,
docs: {
description: {
component: themeSegmentedControlDescription,
},
},
},
} satisfies Meta<typeof ThemeSegmentedControlHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
const getOptions = (canvasElement: HTMLElement) => {
const group = within(canvasElement).getByRole('radiogroup', { name: 'Theme selector' });
return { group, radios: within(group).getAllByRole('radio') as HTMLInputElement[] };
};
/** Full labels, as the theme guide renders it. */
export const Default: Story = {
play: async ({ canvasElement }) => {
const { group, radios } = getOptions(canvasElement);
await expect(radios).toHaveLength(biergartenThemes.length);
for (const theme of biergartenThemes) {
await expect(within(group).getByText(theme.label)).toBeVisible();
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
}
const [lager] = radios;
await expect(lager.value).toBe(defaultThemeName);
await expect(lager.checked).toBe(true);
},
};
/** Short labels, as the landing page renders it in the narrower try-out card. */
export const ShortLabels: Story = {
args: {
labels: 'short',
},
play: async ({ canvasElement }) => {
const { group } = getOptions(canvasElement);
for (const theme of biergartenThemes) {
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
}
await expect(within(group).queryByText(biergartenThemes[0].label)).toBeNull();
},
};
/** A returning visitor whose cookie already names a theme other than the default. */
export const SeededFromCookie: Story = {
args: {
seedTheme: 'biergarten-cassis',
},
play: async ({ canvasElement }) => {
const { radios } = getOptions(canvasElement);
for (const radio of radios) {
await expect(radio.checked).toBe(radio.value === 'biergarten-cassis');
}
},
};
export const SelectingATheme: Story = {
play: async ({ canvasElement, args }) => {
const previousTheme = document.documentElement.getAttribute('data-theme');
const { group, radios } = getOptions(canvasElement);
await userEvent.click(within(group).getByText('Biergarten Stout'));
for (const radio of radios) {
await expect(radio.checked).toBe(radio.value === 'biergarten-stout');
}
await expect(args.onChange).toHaveBeenCalledWith('biergarten-stout');
await expect(document.documentElement.getAttribute('data-theme')).toBe('biergarten-stout');
await expect(document.cookie).toContain(`${themeCookieName}=biergarten-stout`);
// The control themes the whole document, so hand <html> back to the
// toolbar's theme before the next story mounts.
if (previousTheme === null) {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', previousTheme);
}
},
};
/** Single column, for a sidebar or any container too narrow for the paired grid. */
export const SingleColumn: Story = {
args: {
className: 'grid grid-cols-1 gap-2',
},
play: async ({ canvasElement }) => {
const { radios } = getOptions(canvasElement);
await expect(radios).toHaveLength(biergartenThemes.length);
},
};

View File

@@ -19,7 +19,7 @@ interface ThemeSegmentedControlProps {
const ThemeSegmentedControl = ({
labels = 'full',
className = 'join join-vertical sm:join-horizontal',
className = 'grid grid-cols-2 gap-2',
onChange,
}: ThemeSegmentedControlProps) => {
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
@@ -33,7 +33,11 @@ const ThemeSegmentedControl = ({
return (
<label
key={theme.value}
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
className={`group flex min-h-20 cursor-pointer items-center gap-3 rounded-box border p-3 text-left transition-all duration-200 ${
checked
? 'border-primary bg-primary text-primary-content shadow-md'
: 'border-base-content/15 bg-base-200/45 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-base-200'
}`}
>
<input
type="radio"
@@ -47,7 +51,34 @@ const ThemeSegmentedControl = ({
onChange?.(theme.value);
}}
/>
{labels === 'short' ? theme.shortLabel : theme.label}
<span
aria-hidden="true"
className={`size-9 shrink-0 rounded-full border-4 shadow-inner transition-transform duration-200 group-hover:scale-105 ${
checked ? 'border-primary-content/70' : 'border-base-100'
}`}
style={{
background:
theme.value === 'biergarten-lager'
? 'linear-gradient(135deg, #f5c451 0%, #a96728 100%)'
: theme.value === 'biergarten-stout'
? 'linear-gradient(135deg, #d69b45 0%, #382116 100%)'
: theme.value === 'biergarten-cassis'
? 'linear-gradient(135deg, #c777b9 0%, #4c214e 100%)'
: 'linear-gradient(135deg, #f7e6a5 0%, #82a85d 100%)',
}}
/>
<span className="min-w-0">
<span className="block truncate text-sm font-bold">
{labels === 'short' ? theme.shortLabel : theme.label}
</span>
<span
className={`mt-0.5 block truncate text-xs ${
checked ? 'text-primary-content/75' : 'text-base-content/60'
}`}
>
{theme.shortVibe}
</span>
</span>
</label>
);
})}

View File

@@ -6,6 +6,8 @@ export interface ThemeOption {
label: string;
/** Used where the segmented control is narrow, such as the landing page. */
shortLabel: string;
/** A compact mood descriptor for theme pickers. */
shortVibe: string;
vibe: string;
}
@@ -17,24 +19,28 @@ export const biergartenThemes: ThemeOption[] = [
value: 'biergarten-lager',
label: 'Biergarten Lager',
shortLabel: 'Lager',
shortVibe: 'Muted parchment',
vibe: 'Muted parchment, mellow amber, daytime beer garden',
},
{
value: 'biergarten-stout',
label: 'Biergarten Stout',
shortLabel: 'Stout',
shortVibe: 'Charred barrel',
vibe: 'Charred barrel, deep roast, cozy evening cellar',
},
{
value: 'biergarten-cassis',
label: 'Biergarten Cassis',
shortLabel: 'Cassis',
shortVibe: 'Blackberry barrel',
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
},
{
value: 'biergarten-weizen',
label: 'Biergarten Weizen',
shortLabel: 'Weizen',
shortVibe: 'Bright spring',
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
},
];