Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/components/AskCodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export function AskCodeCard(props: AskCodeCardProps) {
>
<Show when={loading() && !response()}>
<span
class="askcode-loading-pulse"
style={{
color: theme.fgSubtle,
animation: 'askcode-pulse 1.5s ease-in-out infinite',
Expand All @@ -160,6 +161,7 @@ export function AskCodeCard(props: AskCodeCardProps) {
<Show when={response()}>{response()}</Show>
<Show when={loading() && response()}>
<span
class="askcode-loading-pulse"
style={{
color: theme.accent,
'font-size': sf(11),
Expand Down
1 change: 1 addition & 0 deletions src/components/HelpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ export function HelpDialog(props: HelpDialogProps) {
</button>
</Show>
<kbd
class="keybinding-key"
role="button"
tabIndex={0}
aria-label={
Expand Down
12 changes: 11 additions & 1 deletion src/components/TilingLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ import { createCtrlShiftWheelResizeHandler } from '../lib/wheelZoom';

const VIEWPORT_EPSILON_PX = 4;

function shouldAnimateTaskAppearance(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return true;
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

/** Tiling-layout top-level child. Distinct from `PanelChild` because this
* layout owns its own horizontal drag model — fixed placeholders, per-panel
* min/max widths, pixel-precise persisted sizes — that doesn't map onto the
Expand Down Expand Up @@ -244,6 +249,7 @@ export function TilingLayout() {
content: () => {
const task = store.tasks[panelId];
const terminal = store.terminals[panelId];
const appearanceClass = shouldAnimateTaskAppearance() ? 'task-appearing' : undefined;
// eslint-disable-next-line solid/components-return-once
if (!task && !terminal) return <div />;
return (
Expand All @@ -252,7 +258,7 @@ export function TilingLayout() {
class={
task?.closingStatus === 'removing' || terminal?.closingStatus === 'removing'
? 'task-removing'
: 'task-appearing'
: appearanceClass
}
style={{
height: '100%',
Expand All @@ -267,6 +273,10 @@ export function TilingLayout() {
if (e.animationName === 'taskAppear')
e.currentTarget.classList.remove('task-appearing');
}}
onAnimationCancel={(e) => {
if (e.animationName === 'taskAppear')
e.currentTarget.classList.remove('task-appearing');
}}
>
<ErrorBoundary
fallback={(err, reset) => (
Expand Down
71 changes: 71 additions & 0 deletions src/lib/reduced-motion-styles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { describe, expect, it } from 'vitest';

const css = readFileSync(resolve(__dirname, '../styles.css'), 'utf8');
const helpDialog = readFileSync(resolve(__dirname, '../components/HelpDialog.tsx'), 'utf8');
const tilingLayout = readFileSync(resolve(__dirname, '../components/TilingLayout.tsx'), 'utf8');

function reducedMotionBlock(): { block: string; end: number } {
const marker = '@media (prefers-reduced-motion: reduce)';
const start = css.indexOf(marker);
expect(start).toBeGreaterThanOrEqual(0);
expect(css.lastIndexOf(marker)).toBe(start);

const openingBrace = css.indexOf('{', start);
let depth = 0;
for (let index = openingBrace; index < css.length; index += 1) {
if (css[index] === '{') depth += 1;
if (css[index] === '}') depth -= 1;
if (depth === 0) return { block: css.slice(start, index + 1), end: index };
}

throw new Error('Unclosed reduced-motion media query');
}

function expectRule(block: string, selectors: string[], declaration: RegExp): void {
const selectorPattern = selectors
.map((selector) => selector.replaceAll('.', '\\.'))
.join('\\s*,\\s*');
const rule = new RegExp(
`${selectorPattern}\\s*\\{[^}]*${declaration.source}[^}]*\\}`,
declaration.flags,
);
expect(block).toMatch(rule);
}

describe('reduced-motion styles', () => {
it('pairs each main-app selector group with its reduced-motion override', () => {
const { block, end } = reducedMotionBlock();

expectRule(block, ['.projects-collapser'], /transition:\s*none\s*;/);
expectRule(
block,
[
'.task-appearing',
'.task-item-appearing',
'.task-removing',
'.task-item-removing',
'.status-dot-pulse',
],
/animation:\s*none\s*;/,
);
expectRule(block, ['.status-dot-pulse'], /outline:\s*1px solid var\(--fg-muted\)\s*;/);
expectRule(
block,
['.askcode-loading-pulse', '.keybinding-key'],
/animation:\s*none\s*!important\s*;/,
);
expect(block).not.toContain('.inline-spinner');
expect(block).not.toContain('keybinding-recording-pulse');
expect(css.slice(end + 1).trim()).toBe('');
});

it('avoids stale entry and keybinding animation classes', () => {
expect(tilingLayout).toContain("window.matchMedia('(prefers-reduced-motion: reduce)').matches");
expect(tilingLayout).toContain('const appearanceClass = shouldAnimateTaskAppearance()');
expect(tilingLayout).toContain('onAnimationCancel');
expect(helpDialog).toContain('class="keybinding-key"');
expect(helpDialog).not.toContain('keybinding-recording-pulse');
});
});
33 changes: 27 additions & 6 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -982,12 +982,6 @@ textarea::placeholder {
overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
.projects-collapser {
transition: none;
}
}

.tiling-layout-shell {
position: relative;
flex: 1;
Expand Down Expand Up @@ -2199,3 +2193,30 @@ body.dragging-task * {
opacity: 0.3;
}
}

/* Keep reduced-motion overrides last so class-based rules win by source order. */
@media (prefers-reduced-motion: reduce) {
.projects-collapser {
transition: none;
}

.task-appearing,
.task-item-appearing,
.task-removing,
.task-item-removing,
.status-dot-pulse {
animation: none;
}

/* Preserve a static busy-state distinction after removing the pulse. */
.status-dot-pulse {
outline: 1px solid var(--fg-muted);
outline-offset: 1px;
}

/* These animations are declared inline and require an important override. */
.askcode-loading-pulse,
.keybinding-key {
animation: none !important;
}
}
Loading