Skip to content

Commit b7862bc

Browse files
committed
chore: merge main into loop/command-palette-recents
Resolve merge conflict in docs/loop/feature-ledger.md (append-only ledger) by keeping the branch's reconciled superset, which already includes the reduce-motion (#51) row. Locale files auto-merged cleanly; i18n parity verified.
2 parents 8f9e01c + 8c468e4 commit b7862bc

15 files changed

Lines changed: 237 additions & 4 deletions

File tree

apps/electron/src/renderer/components/settings/SettingsToggle.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface SettingsToggleProps {
2525
className?: string
2626
/** Whether the toggle is inside a card (affects padding) */
2727
inCard?: boolean
28+
/** Optional test id forwarded to the underlying switch (for e2e) */
29+
testId?: string
2830
}
2931

3032
/**
@@ -48,6 +50,7 @@ export function SettingsToggle({
4850
disabled,
4951
className,
5052
inCard = true,
53+
testId,
5154
}: SettingsToggleProps) {
5255
const id = React.useId()
5356

@@ -73,6 +76,7 @@ export function SettingsToggle({
7376
onCheckedChange={onCheckedChange}
7477
disabled={disabled}
7578
data-layout="settings-control"
79+
data-testid={testId}
7680
className="ml-4 shrink-0"
7781
/>
7882
</div>
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* ReduceMotionContext
3+
*
4+
* App-wide "Reduce motion" accessibility preference.
5+
*
6+
* When enabled it:
7+
* - renders the app inside `<MotionConfig reducedMotion="always">` so every
8+
* `motion/react` component short-circuits its animations (MotionConfig
9+
* propagates through React context to all `motion` components in the tree,
10+
* including those in `packages/ui`);
11+
* - sets `data-reduce-motion="true"` on `<html>` so the global CSS guard in
12+
* `index.css` can collapse plain CSS animations/transitions too.
13+
*
14+
* When disabled it uses `reducedMotion="user"`, which still honors the OS
15+
* `prefers-reduced-motion` setting — a better default than always animating.
16+
*
17+
* The preference is persisted in `localStorage` (renderer-only, no backend),
18+
* mirroring the other lightweight UI prefs in `lib/local-storage.ts`.
19+
*/
20+
21+
import React, {
22+
createContext,
23+
useContext,
24+
useState,
25+
useEffect,
26+
useCallback,
27+
type ReactNode,
28+
} from 'react'
29+
import { MotionConfig } from 'motion/react'
30+
import * as storage from '@/lib/local-storage'
31+
32+
interface ReduceMotionContextType {
33+
reduceMotion: boolean
34+
setReduceMotion: (value: boolean) => void
35+
}
36+
37+
const ReduceMotionContext = createContext<ReduceMotionContextType | null>(null)
38+
39+
const REDUCE_MOTION_ATTR = 'data-reduce-motion'
40+
41+
/** Reflect the preference onto <html> so the global CSS guard can react. */
42+
function applyReduceMotionAttribute(enabled: boolean): void {
43+
const root = document.documentElement
44+
if (enabled) {
45+
root.setAttribute(REDUCE_MOTION_ATTR, 'true')
46+
} else {
47+
root.removeAttribute(REDUCE_MOTION_ATTR)
48+
}
49+
}
50+
51+
export function ReduceMotionProvider({ children }: { children: ReactNode }) {
52+
const [reduceMotion, setReduceMotionState] = useState<boolean>(() =>
53+
storage.get<boolean>(storage.KEYS.reduceMotion, false),
54+
)
55+
56+
// Keep the DOM attribute in sync (also covers the initial value on mount).
57+
useEffect(() => {
58+
applyReduceMotionAttribute(reduceMotion)
59+
}, [reduceMotion])
60+
61+
const setReduceMotion = useCallback((value: boolean) => {
62+
setReduceMotionState(value)
63+
storage.set(storage.KEYS.reduceMotion, value)
64+
applyReduceMotionAttribute(value)
65+
}, [])
66+
67+
return (
68+
<ReduceMotionContext.Provider value={{ reduceMotion, setReduceMotion }}>
69+
<MotionConfig reducedMotion={reduceMotion ? 'always' : 'user'}>
70+
{children}
71+
</MotionConfig>
72+
</ReduceMotionContext.Provider>
73+
)
74+
}
75+
76+
export function useReduceMotion(): ReduceMotionContextType {
77+
const ctx = useContext(ReduceMotionContext)
78+
if (!ctx) {
79+
throw new Error('useReduceMotion must be used within a ReduceMotionProvider')
80+
}
81+
return ctx
82+
}

apps/electron/src/renderer/index.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,3 +1371,18 @@ html.dark[data-scenic] .fullscreen-overlay-background {
13711371
min-height: 31px;
13721372
padding: 6px 8px;
13731373
}
1374+
1375+
/* Reduce motion: when the user enables "Reduce motion" in Appearance settings,
1376+
near-instantly complete CSS animations/transitions across the app. This
1377+
complements `motion/react`'s MotionConfig reducedMotion="always" (which handles
1378+
`motion` components) by covering plain CSS effects too. */
1379+
[data-reduce-motion='true'] *,
1380+
[data-reduce-motion='true'] *::before,
1381+
[data-reduce-motion='true'] *::after {
1382+
animation-duration: 0.001ms !important;
1383+
animation-delay: 0ms !important;
1384+
animation-iteration-count: 1 !important;
1385+
transition-duration: 0.001ms !important;
1386+
transition-delay: 0ms !important;
1387+
scroll-behavior: auto !important;
1388+
}

apps/electron/src/renderer/lib/local-storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const KEYS = {
5555

5656
// Appearance
5757
showConnectionIcons: 'show-connection-icons',
58+
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
5859

5960
// What's New
6061
whatsNewLastSeenVersion: 'whats-new-last-seen-version',

apps/electron/src/renderer/main.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { captureConsoleIntegration } from '@sentry/react'
66
import { Provider as JotaiProvider, useAtomValue } from 'jotai'
77
import App from './App'
88
import { ThemeProvider } from './context/ThemeContext'
9+
import { ReduceMotionProvider } from './context/ReduceMotionContext'
910
import { windowWorkspaceIdAtom } from './atoms/sessions'
1011
import { Toaster } from '@/components/ui/sonner'
1112
import { PetWindowController } from '@/components/pet/PetWindowController'
@@ -106,9 +107,11 @@ function Root() {
106107

107108
return (
108109
<ThemeProvider activeWorkspaceId={workspaceId}>
109-
<App />
110-
<Toaster />
111-
<PetWindowController />
110+
<ReduceMotionProvider>
111+
<App />
112+
<Toaster />
113+
<PetWindowController />
114+
</ReduceMotionProvider>
112115
</ThemeProvider>
113116
)
114117
}

apps/electron/src/renderer/pages/settings/AppearanceSettingsPage.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { ScrollArea } from '@/components/ui/scroll-area'
1414
import { HeaderMenu } from '@/components/ui/HeaderMenu'
1515
import { EditPopover, EditButton, getEditConfig } from '@/components/ui/EditPopover'
1616
import { useTheme } from '@/context/ThemeContext'
17+
import { useReduceMotion } from '@/context/ReduceMotionContext'
1718
import { useAppShellContext } from '@/context/AppShellContext'
1819
import { routes } from '@/lib/navigate'
1920
import { FolderOpen, Monitor, RefreshCw, Sun, Moon } from 'lucide-react'
@@ -139,6 +140,9 @@ export default function AppearanceSettingsPage() {
139140
await window.electronAPI?.setRichToolDescriptions?.(checked)
140141
}, [])
141142

143+
// Reduce motion toggle (renderer-only preference, persisted in localStorage)
144+
const { reduceMotion, setReduceMotion } = useReduceMotion()
145+
142146
// Pet companion settings + custom pets (synced via shared Jotai atoms)
143147
const {
144148
pets,
@@ -376,6 +380,13 @@ export default function AppearanceSettingsPage() {
376380
checked={richToolDescriptions}
377381
onCheckedChange={handleRichToolDescriptionsChange}
378382
/>
383+
<SettingsToggle
384+
label={t("settings.appearance.reduceMotion")}
385+
description={t("settings.appearance.reduceMotionDesc")}
386+
checked={reduceMotion}
387+
onCheckedChange={setReduceMotion}
388+
testId="reduce-motion-toggle"
389+
/>
379390
</SettingsCard>
380391
</SettingsSection>
381392

apps/electron/src/renderer/pages/settings/SettingsNavigator.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ function SettingsItemRow({ item, isSelected, isFirst, onSelect }: SettingsItemRo
6767
}
6868

6969
return (
70-
<div className="settings-item" data-selected={isSelected || undefined}>
70+
<div
71+
className="settings-item"
72+
data-selected={isSelected || undefined}
73+
data-testid={`settings-nav-${item.id}`}
74+
>
7175
{/* Separator - only show if not first */}
7276
{!isFirst && (
7377
<div className="settings-separator pl-12 pr-4">
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Feature assertion: the "Reduce motion" toggle in Settings → Appearance
3+
* actually applies and persists an app-wide reduced-motion preference.
4+
*
5+
* Drives the real UI over CDP entirely in the draft/no-session state (no seeded
6+
* conversation, no backend connection): opens Settings → Appearance, flips the
7+
* toggle, and asserts the observable effects — the switch state, the
8+
* `data-reduce-motion` attribute on <html>, and the persisted localStorage value.
9+
* Toggling twice proves it both applies and reverts, not merely renders.
10+
*/
11+
12+
import type { Assertion } from '../runner';
13+
14+
const SETTINGS_NAV = '[data-testid="nav:settings"]';
15+
const APPEARANCE_NAV = '[data-testid="settings-nav-appearance"]';
16+
const TOGGLE = '[data-testid="reduce-motion-toggle"]';
17+
const STORAGE_KEY = 'craft-reduce-motion';
18+
19+
/** Read the toggle's aria-checked ("true" | "false" | null). */
20+
function ariaCheckedExpr(): string {
21+
return `(() => {
22+
const el = document.querySelector(${JSON.stringify(TOGGLE)});
23+
return el ? el.getAttribute('aria-checked') : null;
24+
})()`;
25+
}
26+
27+
/** True when <html> carries the reduce-motion marker attribute. */
28+
function htmlMarkedExpr(): string {
29+
return `document.documentElement.getAttribute('data-reduce-motion') === 'true'`;
30+
}
31+
32+
/** The persisted localStorage value for the preference. */
33+
function storedValueExpr(): string {
34+
return `window.localStorage.getItem(${JSON.stringify(STORAGE_KEY)})`;
35+
}
36+
37+
const assertion: Assertion = {
38+
name: 'reduce-motion toggle applies and persists an app-wide preference',
39+
async run(app) {
40+
const { session } = app;
41+
42+
// App fully mounted.
43+
await session.waitForFunction(
44+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
45+
{ timeoutMs: 30000, message: 'app did not mount' },
46+
);
47+
48+
// Open Settings → Appearance (real user path).
49+
await session.click(SETTINGS_NAV, { timeoutMs: 15000 });
50+
await session.click(APPEARANCE_NAV, { timeoutMs: 15000 });
51+
52+
// The toggle is the feature under test — its presence is the first signal.
53+
await session.waitForSelector(TOGGLE, {
54+
timeoutMs: 15000,
55+
message: 'reduce-motion toggle did not render',
56+
});
57+
58+
// Initial state: off, no marker on <html>, not persisted true.
59+
const initialChecked = await session.evaluate<string | null>(ariaCheckedExpr());
60+
if (initialChecked !== 'false') {
61+
throw new Error(`expected toggle off initially, saw aria-checked=${initialChecked}`);
62+
}
63+
if (await session.evaluate<boolean>(htmlMarkedExpr())) {
64+
throw new Error('expected no data-reduce-motion attribute before enabling');
65+
}
66+
67+
// Enable → toggle on, <html> marked, persisted true.
68+
await session.click(TOGGLE);
69+
await session.waitForFunction(
70+
`${ariaCheckedExpr()} === 'true'`,
71+
{ timeoutMs: 5000, message: 'toggle did not switch on' },
72+
);
73+
await session.waitForFunction(htmlMarkedExpr(), {
74+
timeoutMs: 5000,
75+
message: 'data-reduce-motion was not applied to <html> when enabled',
76+
});
77+
const storedOn = await session.evaluate<string | null>(storedValueExpr());
78+
if (storedOn !== 'true') {
79+
throw new Error(`expected persisted "true" after enabling, saw ${JSON.stringify(storedOn)}`);
80+
}
81+
82+
// Disable → toggle off, marker removed, persisted false.
83+
await session.click(TOGGLE);
84+
await session.waitForFunction(
85+
`${ariaCheckedExpr()} === 'false'`,
86+
{ timeoutMs: 5000, message: 'toggle did not switch off' },
87+
);
88+
await session.waitForFunction(`!(${htmlMarkedExpr()})`, {
89+
timeoutMs: 5000,
90+
message: 'data-reduce-motion was not removed from <html> when disabled',
91+
});
92+
const storedOff = await session.evaluate<string | null>(storedValueExpr());
93+
if (storedOff !== 'false') {
94+
throw new Error(`expected persisted "false" after disabling, saw ${JSON.stringify(storedOff)}`);
95+
}
96+
},
97+
};
98+
99+
export default assertion;

packages/shared/src/i18n/locales/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,8 @@
786786
"settings.appearance.mode": "Modus",
787787
"settings.appearance.noToolIcons": "Keine Werkzeugsymbol-Zuordnungen gefunden",
788788
"settings.appearance.richToolDescriptions": "Ausführliche Werkzeugbeschreibungen",
789+
"settings.appearance.reduceMotion": "Bewegung reduzieren",
790+
"settings.appearance.reduceMotionDesc": "Animationen und Übergänge in der gesamten App minimieren.",
789791
"settings.appearance.pet": "Begleiter",
790792
"settings.appearance.petDesc": "Ein Begleiter, der auf die Aktivität des Agents reagiert.",
791793
"settings.appearance.petEnabled": "Begleiter anzeigen",

packages/shared/src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,8 @@
786786
"settings.appearance.mode": "Mode",
787787
"settings.appearance.noToolIcons": "No tool icon mappings found",
788788
"settings.appearance.richToolDescriptions": "Rich tool descriptions",
789+
"settings.appearance.reduceMotion": "Reduce motion",
790+
"settings.appearance.reduceMotionDesc": "Minimize animations and transitions throughout the app.",
789791
"settings.appearance.pet": "Pet",
790792
"settings.appearance.petDesc": "A companion that reacts to what the agent is doing.",
791793
"settings.appearance.petEnabled": "Show pet companion",

0 commit comments

Comments
 (0)