@@ -81,7 +81,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
-
+
Content
diff --git a/web/frontend/app/features/theme/components/ThemeSegmentedControl.stories.tsx b/web/frontend/app/features/theme/components/ThemeSegmentedControl.stories.tsx
new file mode 100644
index 00000000..25ae5bcb
--- /dev/null
+++ b/web/frontend/app/features/theme/components/ThemeSegmentedControl.stories.tsx
@@ -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 \`\` 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: (
+
+
+
+ ),
+ },
+ ]),
+ [labels, className, onChange, seedTheme],
+ );
+
+ return
;
+};
+
+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
;
+
+export default meta;
+type Story = StoryObj;
+
+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 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);
+ },
+};
diff --git a/web/frontend/app/features/theme/components/ThemeSegmentedControl.tsx b/web/frontend/app/features/theme/components/ThemeSegmentedControl.tsx
new file mode 100644
index 00000000..0c33b7e9
--- /dev/null
+++ b/web/frontend/app/features/theme/components/ThemeSegmentedControl.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react';
+import { useRouteLoaderData } from 'react-router';
+import type { loader as rootLoader } from '../../../root';
+import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
+
+// The semantic aliases resolve at :root, so the attribute has to land on
+// rather than a nested element for the whole document to switch.
+const applyTheme = (theme: ThemeName) => {
+ document.documentElement.setAttribute('data-theme', theme);
+ document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
+};
+
+interface ThemeSegmentedControlProps {
+ /** `short` renders "Lager"; `full` renders "Biergarten Lager". */
+ labels?: 'full' | 'short';
+ className?: string;
+ onChange?: (theme: ThemeName) => void;
+}
+
+const ThemeSegmentedControl = ({
+ labels = 'full',
+ className = 'grid grid-cols-2 gap-2',
+ onChange,
+}: ThemeSegmentedControlProps) => {
+ const rootTheme = useRouteLoaderData('root')?.theme ?? defaultThemeName;
+ const [selectedTheme, setSelectedTheme] = useState(rootTheme);
+
+ return (
+
+ {biergartenThemes.map((theme) => {
+ const checked = selectedTheme === theme.value;
+
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default ThemeSegmentedControl;
diff --git a/web/frontend/app/features/theme/components/ThemeSwitcher.tsx b/web/frontend/app/features/theme/components/ThemeSwitcher.tsx
index 58f3ee92..2c5d2af6 100644
--- a/web/frontend/app/features/theme/components/ThemeSwitcher.tsx
+++ b/web/frontend/app/features/theme/components/ThemeSwitcher.tsx
@@ -1,12 +1,8 @@
import { useState } from 'react';
import { useRouteLoaderData } from 'react-router';
import type { loader as rootLoader } from '../../../root';
-import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
-
-const applyTheme = (theme: ThemeName) => {
- document.documentElement.setAttribute('data-theme', theme);
- document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
-};
+import { biergartenThemes, defaultThemeName, type ThemeName } from '../themes';
+import ThemeSegmentedControl from './ThemeSegmentedControl';
const ThemeSwitcher = () => {
const rootTheme = useRouteLoaderData('root')?.theme ?? defaultThemeName;
@@ -39,35 +35,7 @@ const ThemeSwitcher = () => {
Theme switcher
Pick a theme and preview it immediately.
-
- {biergartenThemes.map((theme) => {
- const checked = selectedTheme === theme.value;
-
- return (
-
- );
- })}
-
+
>
diff --git a/web/frontend/app/features/theme/themes.ts b/web/frontend/app/features/theme/themes.ts
index 70a3b54a..0b7f9f62 100644
--- a/web/frontend/app/features/theme/themes.ts
+++ b/web/frontend/app/features/theme/themes.ts
@@ -4,6 +4,10 @@ export type ThemeName =
export interface ThemeOption {
value: ThemeName;
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;
}
@@ -14,21 +18,29 @@ 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',
},
];
diff --git a/web/frontend/app/features/users/components/profile/EmptyState.tsx b/web/frontend/app/features/users/components/profile/EmptyState.tsx
index 24232baa..556febd5 100644
--- a/web/frontend/app/features/users/components/profile/EmptyState.tsx
+++ b/web/frontend/app/features/users/components/profile/EmptyState.tsx
@@ -1,7 +1,7 @@
const EmptyState = ({ message }: { message: string }) => (
);
diff --git a/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx
index c99a9a89..a74ada6d 100644
--- a/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx
+++ b/web/frontend/app/features/users/components/profile/ProfileSidebarCard.tsx
@@ -33,7 +33,7 @@ const ProfileSidebarCard = ({
{displayName}
-
@{username}
+
@{username}
{isOwnProfile ? (
@@ -52,7 +52,7 @@ const ProfileSidebarCard = ({
{bio}
-
+
{location}
@@ -68,7 +68,7 @@ const ProfileSidebarCard = ({
{stats.map((stat) => (
- {stat.label}
+ {stat.label}
{stat.value}
diff --git a/web/frontend/app/features/users/components/profile/ProfileTabs.tsx b/web/frontend/app/features/users/components/profile/ProfileTabs.tsx
index 1713f021..d39f10e2 100644
--- a/web/frontend/app/features/users/components/profile/ProfileTabs.tsx
+++ b/web/frontend/app/features/users/components/profile/ProfileTabs.tsx
@@ -24,7 +24,7 @@ const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
{TABS.map((tab) => (
{tab.label}
diff --git a/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx
index e377faa5..d538e063 100644
--- a/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx
+++ b/web/frontend/app/features/users/components/profile/tabs/ActivityTab.tsx
@@ -36,11 +36,11 @@ const ActivityTab = ({ activity }: ActivityTabProps) => {
)}
{item.comment && (
-
+
“{item.comment}”
)}
-
+
{item.time}
diff --git a/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx
index 6d60e4ae..41e67ba0 100644
--- a/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx
+++ b/web/frontend/app/features/users/components/profile/tabs/FollowingTab.tsx
@@ -42,7 +42,7 @@ const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
) : (
{f.name}
)}
-
{f.meta}
+
{f.meta}