Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions frontend/src/v2/__tests__/V2ThemeSwitch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import fs from 'fs';
import path from 'path';
import { render, screen, fireEvent, act } from '@testing-library/react';
import V2ThemeSwitch from '../components/V2ThemeSwitch';
import useV2ThemePref from '../hooks/useV2ThemePref';
import { setV2Theme, V2_THEME_KEY } from '../utils/theme';

// TASK-740 (D224): the rail switch writes the one stored theme preference that
// Settings > Theme reads, and a change made elsewhere moves the switch.

const Probe: React.FC = () => <span data-testid="pref">{useV2ThemePref()}</span>;

beforeEach(() => {
window.localStorage.clear();
document.documentElement.removeAttribute('data-v2-theme');
});

describe('V2ThemeSwitch', () => {
test('a click stores the same key and html attribute as Settings > Theme', () => {
render(<><V2ThemeSwitch /><Probe /></>);
fireEvent.click(screen.getByRole('button', { name: /dark/i }));
expect(window.localStorage.getItem(V2_THEME_KEY)).toBe('dark');
expect(document.documentElement.getAttribute('data-v2-theme')).toBe('dark');
expect(screen.getByRole('button', { name: /dark/i })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: /light/i })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByTestId('pref')).toHaveTextContent('dark');
});

test('a preference set elsewhere moves the switch', () => {
render(<V2ThemeSwitch />);
act(() => setV2Theme('light'));
expect(screen.getByRole('button', { name: /light/i })).toHaveAttribute('aria-pressed', 'true');
});

test('the bar variant is the small 390 switch, the rail one is the column', () => {
const { container, rerender } = render(<V2ThemeSwitch variant="bar" />);
expect(container.firstElementChild).toHaveClass('seg', 'seg--ic', 'seg--sm');
rerender(<V2ThemeSwitch />);
expect(container.firstElementChild).toHaveClass('seg--rail');
});

test('every modifier class the switch renders has a compound rule in its CSS', () => {
const css = fs.readFileSync(path.join(__dirname, '../components/v2-theme-switch.css'), 'utf8');
const { container, rerender } = render(<V2ThemeSwitch />);
const rail = container.firstElementChild as HTMLElement;
expect(rail).toHaveClass('seg--ic', 'seg--rail');
rerender(<V2ThemeSwitch variant="bar" />);
const bar = container.firstElementChild as HTMLElement;
expect(bar).toHaveClass('seg--sm');
['seg--ic svg.i', 'seg--rail', 'seg--rail button', 'seg--sm button'].forEach((tail) => {
expect(css).toContain(`.seg.v2-theme-seg.${tail}`);
});
});

test('in Auto, an OS light/dark flip moves the switch', () => {
let dark = false;
let fire: () => void = () => {};
const original = window.matchMedia;
window.matchMedia = jest.fn().mockImplementation(() => ({
get matches() { return dark; },
addEventListener: (_: string, cb: () => void) => { fire = cb; },
removeEventListener: jest.fn(),
})) as unknown as typeof window.matchMedia;
try {
render(<V2ThemeSwitch />);
expect(screen.getByRole('button', { name: /light/i })).toHaveAttribute('aria-pressed', 'true');
act(() => { dark = true; fire(); });
expect(screen.getByRole('button', { name: /dark/i })).toHaveAttribute('aria-pressed', 'true');
} finally {
window.matchMedia = original;
}
});
});
2 changes: 2 additions & 0 deletions frontend/src/v2/components/V2NavRail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import V2FeedbackMenu from './V2FeedbackMenu';
import V2AccountMenu from './V2AccountMenu';
import V2ThemeSwitch from './V2ThemeSwitch';
import { useAuth } from '../../context/AuthContext';
import { useTranslation } from 'react-i18next';
import Badge from '@mui/material/Badge';
Expand Down Expand Up @@ -127,6 +128,7 @@ const V2NavRail: React.FC<V2NavRailProps> = ({ onPodsMobileNav, needsYouCount })
<div className="v2-rail__utility">
<V2FeedbackMenu />
</div>
<V2ThemeSwitch />
<span className="v2-rail__foot-sep" aria-hidden="true" />
{/* Account */}
<div className="v2-rail__user">
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/v2/components/V2PodsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import V2Avatar from './V2Avatar';
import V2Segmented from './V2Segmented';
import { ShellGlyph } from '../icons/glyphs';
import V2ThemeSwitch from './V2ThemeSwitch';
import { UseV2PodsResult, V2Pod, V2PodMember, useV2Pods } from '../hooks/useV2Pods';
import { useV2Pinned } from '../hooks/useV2Pinned';
import { useAuth } from '../../context/AuthContext';
Expand Down Expand Up @@ -490,6 +491,7 @@ const V2PodsSidebar: React.FC<V2PodsSidebarProps> = ({
{isPage && (
<div className="v2-pods__bar">
<h1 className="v2-pods__page-title">{t('podsSidebar.workspace.listTitle')}</h1>
<V2ThemeSwitch variant="bar" />
<button
ref={searchToggleRef}
type="button"
Expand Down
10 changes: 4 additions & 6 deletions frontend/src/v2/components/V2SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import V2AvatarCropDialog from './V2AvatarCropDialog';
import V2Segmented from './V2Segmented';
import { nextPaperAvatarPreset } from '../utils/avatarPresets';
import { LANGUAGE_REGISTRY } from '../../i18n';
import { V2_THEMES, V2ThemePref, getV2Theme, setV2Theme } from '../utils/theme';
import { V2_THEMES, V2ThemePref, setV2Theme } from '../utils/theme';
import useV2ThemePref from '../hooks/useV2ThemePref';

type TokenStatus = {
hasToken?: boolean;
Expand Down Expand Up @@ -537,11 +538,8 @@ const V2ApiTokenSection: React.FC = () => {
const V2LanguageSection: React.FC = () => {
const { i18n, t } = useTranslation();
const active = i18n.resolvedLanguage || i18n.language || 'en';
const [theme, setTheme] = useState<V2ThemePref>(() => getV2Theme());
const chooseTheme = (pref: V2ThemePref) => {
setTheme(pref);
setV2Theme(pref);
};
const theme = useV2ThemePref();
const chooseTheme = (pref: V2ThemePref) => setV2Theme(pref);

return (
<div className="v2-settings__look">
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/v2/components/V2ThemeSwitch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React, { useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { getV2Theme, resolveV2Theme, setV2Theme, subscribeV2Theme } from '../utils/theme';
import './v2-theme-switch.css';

const Sun = () => (
<svg className="i" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</svg>
);
const Moon = () => (
<svg className="i" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z" /></svg>
);

// D224 / TASK-740: the sun and moon pair from the d215 rail. It writes the same
// stored preference as Settings > Theme (setV2Theme), so there is one setting.
// An 'auto' preference shows whichever set the OS is giving; a click makes it
// explicit.
// 'rail' is the 1440 sidebar; 'bar' is the 390 Pods top bar (the rail is gone there).
const V2ThemeSwitch: React.FC<{ variant?: 'rail' | 'bar' }> = ({ variant = 'rail' }) => {
const { t } = useTranslation();
const active = useSyncExternalStore(
subscribeV2Theme,
() => resolveV2Theme(getV2Theme()),
() => 'light',
);
return (
<div className={`seg seg--ic v2-theme-seg ${variant === 'bar' ? 'seg--sm' : 'seg--rail'}`} role="group" aria-label={t('common.theme.label')}>
<button type="button" data-t="light" title={t('common.theme.light')} aria-label={t('common.theme.light')}
aria-pressed={active === 'light'} onClick={() => setV2Theme('light')}><Sun /></button>
<button type="button" data-t="dark" title={t('common.theme.dark')} aria-label={t('common.theme.dark')}
aria-pressed={active === 'dark'} onClick={() => setV2Theme('dark')}><Moon /></button>
</div>
);
};

export default V2ThemeSwitch;
13 changes: 13 additions & 0 deletions frontend/src/v2/components/v2-theme-switch.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/* TASK-740 (D224): the d215 rail theme switch, lifted from
docs/design/artboards/d215/index.html (.seg, .seg--ic, .seg--rail, lines
169-178). Scoped by .v2-theme-seg so the generic .seg names stay local; --sm is the 390 Pods top bar (.mtop). The
artboard's short colour names map to --v2-* as in primitives.css; the on state
is aria-pressed (the artboard's .on), the same look. */
.seg.v2-theme-seg { display: inline-flex; gap: 2px; padding: 2px; border-radius: var(--v2-r-9); background: var(--v2-surface-tint); box-shadow: inset 0 0 0 1px var(--v2-border-soft); flex: none; }
.seg.v2-theme-seg button { all: unset; box-sizing: border-box; cursor: pointer; height: 28px; padding: 0; width: 32px; border-radius: var(--v2-r-7); display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: var(--v2-text-secondary); white-space: nowrap; }
.seg.v2-theme-seg button[aria-pressed="true"] { background: var(--v2-surface); color: var(--v2-text-primary); box-shadow: 0 0 0 1px var(--v2-key-edge), 0 1px 2px rgba(16, 24, 40, 0.08); }
.seg.v2-theme-seg button:focus-visible { box-shadow: var(--v2-focus-ring); outline: 1px solid var(--v2-accent); }
.seg.v2-theme-seg.seg--ic svg.i { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.seg.v2-theme-seg.seg--rail { flex-direction: column; margin-bottom: 4px; }
.seg.v2-theme-seg.seg--rail button { height: 30px; }
.seg.v2-theme-seg.seg--sm button { width: 28px; height: 24px; }
7 changes: 7 additions & 0 deletions frontend/src/v2/hooks/useV2ThemePref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { useSyncExternalStore } from 'react';
import { getV2Theme, subscribeV2Theme, V2ThemePref } from '../utils/theme';

// The stored theme preference, re-read whenever setV2Theme changes it.
export default function useV2ThemePref(): V2ThemePref {
return useSyncExternalStore(subscribeV2Theme, getV2Theme, () => 'auto');
}
23 changes: 23 additions & 0 deletions frontend/src/v2/utils/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,34 @@ export function applyV2Theme(pref: V2ThemePref): void {
document.documentElement.setAttribute('data-v2-theme', pref);
}

const listeners = new Set<() => void>();

// Lets every mounted control (the Settings picker, the rail switch) follow the
// one stored preference instead of keeping a copy of it.
// It also fires when the OS flips light/dark, so an 'auto' preference stays current.
export function subscribeV2Theme(listener: () => void): () => void {
listeners.add(listener);
const mql = typeof window !== 'undefined' ? window.matchMedia?.('(prefers-color-scheme: dark)') : undefined;
mql?.addEventListener?.('change', listener);
return () => {
listeners.delete(listener);
mql?.removeEventListener?.('change', listener);
};
}

// 'auto' follows the OS; the rail switch needs the set actually showing.
export function resolveV2Theme(pref: V2ThemePref): 'light' | 'dark' {
if (pref !== 'auto') return pref;
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
}

export function setV2Theme(pref: V2ThemePref): void {
try {
window.localStorage.setItem(V2_THEME_KEY, pref);
} catch {
// Storage can be blocked; the choice still applies for this page view.
}
applyV2Theme(pref);
listeners.forEach((listener) => listener());
}
Loading