From 5fc44215aa6e438842a2f8f532091ea2a7815df2 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 27 Jul 2026 16:42:57 -0500 Subject: [PATCH 1/3] fix(init): skip the welcome animation for reduced-motion users The openspec init welcome animation had no off switch: it repainted eight frames on a 120ms loop with ANSI cursor-clearing, which is a seizure and nausea trigger for motion-sensitive users (#722). canAnimate() now also yields the existing static welcome screen when: - the OS reduced-motion preference is on (macOS Reduce Motion, GNOME animations disabled), detected best-effort with a 500ms timeout and animation kept on any lookup failure - OPENSPEC_NO_ANIMATION is set - the new init --no-animation flag is passed Closes #722 Co-Authored-By: Claude Fable 5 --- .changeset/init-no-animation.md | 5 ++ docs/cli.md | 4 + src/cli/index.ts | 4 +- src/core/completions/command-registry.ts | 4 + src/core/init.ts | 6 +- src/ui/welcome-screen.ts | 58 +++++++++++- test/core/init.test.ts | 2 +- test/ui/welcome-screen.test.ts | 107 ++++++++++++++++++++++- 8 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 .changeset/init-no-animation.md diff --git a/.changeset/init-no-animation.md b/.changeset/init-no-animation.md new file mode 100644 index 0000000000..196838d2bd --- /dev/null +++ b/.changeset/init-no-animation.md @@ -0,0 +1,5 @@ +--- +'@fission-ai/openspec': patch +--- + +Respect reduced-motion preferences in `openspec init`: the welcome animation is skipped when the OS reduced-motion setting is on (macOS Reduce Motion, GNOME animations disabled), when `OPENSPEC_NO_ANIMATION` is set, or when the new `--no-animation` flag is passed. The static welcome screen is shown instead. diff --git a/docs/cli.md b/docs/cli.md index 7b2d711d2e..8101a2a223 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -101,9 +101,12 @@ openspec init [path] [options] | `--tools ` | Configure AI tools non-interactively. Use `all`, `none`, or comma-separated list | | `--force` | Auto-cleanup legacy files without prompting | | `--profile ` | Override global profile for this init run (`core` or `custom`) | +| `--no-animation` | Show a static welcome screen instead of the animated one | `--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`). +The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set, when `NO_COLOR` is set, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled). + **Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`, `zcode` > This list mirrors `AI_TOOLS` in `src/core/config.ts`. See [Supported Tools](supported-tools.md) for each tool's skill and command paths. @@ -1200,6 +1203,7 @@ openspec completion uninstall | `OPENSPEC_CONCURRENCY` | Default concurrency for bulk validation (default: 6) | | `EDITOR` or `VISUAL` | Editor for `openspec config edit` | | `NO_COLOR` | Disable color output when set | +| `OPENSPEC_NO_ANIMATION` | Disable the `openspec init` welcome animation when set | --- diff --git a/src/cli/index.ts b/src/cli/index.ts index e8ee2e9151..51f7bd967f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -144,7 +144,8 @@ program .option('--tools ', toolsOptionDescription) .option('--force', 'Auto-cleanup legacy files without prompting') .option('--profile ', 'Override global config profile (core or custom)') - .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string }) => { + .option('--no-animation', 'Show a static welcome screen instead of the animated one') + .action(async (targetPath = '.', options?: { tools?: string; force?: boolean; profile?: string; animation?: boolean }) => { try { // Validate that the path is a valid directory const resolvedPath = path.resolve(targetPath); @@ -170,6 +171,7 @@ program tools: options?.tools, force: options?.force, profile: options?.profile, + animation: options?.animation, }); await initCommand.execute(targetPath); } catch (error) { diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 76f2a28587..6bf5dcfc41 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -23,6 +23,10 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ takesValue: true, values: ['core', 'custom'], }, + { + name: 'no-animation', + description: 'Show a static welcome screen instead of the animated one', + }, ], }, { diff --git a/src/core/init.ts b/src/core/init.ts index faf83f658c..0090fd66e6 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -97,6 +97,8 @@ type InitCommandOptions = { force?: boolean; interactive?: boolean; profile?: string; + /** Commander's --no-animation flag: false disables the welcome animation. */ + animation?: boolean; }; /** @@ -116,12 +118,14 @@ export class InitCommand { private readonly force: boolean; private readonly interactiveOption?: boolean; private readonly profileOverride?: string; + private readonly animation: boolean; constructor(options: InitCommandOptions = {}) { this.toolsArg = options.tools; this.force = options.force ?? false; this.interactiveOption = options.interactive; this.profileOverride = options.profile; + this.animation = options.animation ?? true; } async execute(targetPath: string): Promise { @@ -184,7 +188,7 @@ export class InitCommand { const canPrompt = this.canPromptInteractively(); if (canPrompt) { const { showWelcomeScreen } = await import('../ui/welcome-screen.js'); - await showWelcomeScreen(this.getActiveWorkflows()); + await showWelcomeScreen(this.getActiveWorkflows(), { animate: this.animation }); } // Get tool states before processing diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts index efb4eb8889..9be79537f0 100644 --- a/src/ui/welcome-screen.ts +++ b/src/ui/welcome-screen.ts @@ -4,6 +4,10 @@ */ import chalk from 'chalk'; +import { + execFileSync, + type ExecFileSyncOptionsWithStringEncoding, +} from 'node:child_process'; import { WELCOME_ANIMATION } from './ascii-patterns.js'; import { getOnboardingCommands } from '../core/onboarding-commands.js'; @@ -66,6 +70,47 @@ function renderFrame(artLines: string[], textLines: string[]): string { return lines.join('\n'); } +const REDUCED_MOTION_EXEC_OPTIONS: ExecFileSyncOptionsWithStringEncoding = { + encoding: 'utf8', + timeout: 500, + // SIGKILL so a wedged lookup can never outlive the timeout and stall init. + killSignal: 'SIGKILL', + stdio: ['ignore', 'pipe', 'ignore'], +}; + +/** + * Best-effort check of the OS-level reduced-motion preference (#722). + * Any lookup failure (missing binary, unset key, timeout) means + * "no preference detected" and animation stays enabled. + */ +export function prefersReducedMotion( + platform: NodeJS.Platform = process.platform +): boolean { + try { + if (platform === 'darwin') { + // The key only exists once the user has toggled Reduce Motion; when it + // is unset `defaults` exits non-zero and lands in the catch below. + const out = execFileSync( + 'defaults', + ['read', 'com.apple.universalaccess', 'reduceMotion'], + REDUCED_MOTION_EXEC_OPTIONS + ); + return out.trim() === '1'; + } + if (platform === 'linux') { + const out = execFileSync( + 'gsettings', + ['get', 'org.gnome.desktop.interface', 'enable-animations'], + REDUCED_MOTION_EXEC_OPTIONS + ); + return out.trim() === 'false'; + } + } catch { + // Detection is best-effort only. + } + return false; +} + /** * Checks if the terminal supports animation */ @@ -76,10 +121,16 @@ function canAnimate(): boolean { // Respect NO_COLOR if (process.env.NO_COLOR) return false; + // Manual override for users who need reduced motion (#722) + if (process.env.OPENSPEC_NO_ANIMATION) return false; + // Check terminal width const columns = process.stdout.columns || 80; if (columns < MIN_WIDTH) return false; + // Last so only interactive terminals pay for the OS lookup + if (prefersReducedMotion()) return false; + return true; } @@ -116,10 +167,13 @@ async function waitForEnter(): Promise { * Shows the animated welcome screen. * Returns when user presses Enter. */ -export async function showWelcomeScreen(workflows: readonly string[]): Promise { +export async function showWelcomeScreen( + workflows: readonly string[], + options: { animate?: boolean } = {} +): Promise { const textLines = getWelcomeText(workflows); - if (!canAnimate()) { + if (options.animate === false || !canAnimate()) { // Fallback: show static welcome const frame = WELCOME_ANIMATION.frames[3]; // Peak frame process.stdout.write('\n' + renderFrame(frame, textLines) + '\n\n'); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 3b4b5570a9..965ead4912 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -899,7 +899,7 @@ describe('InitCommand - profile and detection features', () => { expect(showWelcomeScreenMock).toHaveBeenCalled(); // The welcome screen must be handed the profile's workflows, otherwise it // advertises commands this profile never installs. - expect(showWelcomeScreenMock).toHaveBeenCalledWith(['explore', 'new']); + expect(showWelcomeScreenMock).toHaveBeenCalledWith(['explore', 'new'], { animate: true }); expect(confirmMock).not.toHaveBeenCalled(); const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md'); diff --git a/test/ui/welcome-screen.test.ts b/test/ui/welcome-screen.test.ts index c1ff7b2290..38d170ce3d 100644 --- a/test/ui/welcome-screen.test.ts +++ b/test/ui/welcome-screen.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js'; -const { useKeypressMock } = vi.hoisted(() => ({ +const { useKeypressMock, execFileSyncMock } = vi.hoisted(() => ({ useKeypressMock: vi.fn(), + execFileSyncMock: vi.fn(), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: execFileSyncMock, })); vi.mock('@inquirer/core', () => ({ @@ -23,6 +28,7 @@ vi.mock('@inquirer/core', () => ({ describe('welcome screen', () => { const originalNoColor = process.env.NO_COLOR; + const originalNoAnimation = process.env.OPENSPEC_NO_ANIMATION; const originalStdinIsTTY = process.stdin.isTTY; const originalStdoutIsTTY = process.stdout.isTTY; const originalColumns = process.stdout.columns; @@ -38,11 +44,18 @@ describe('welcome screen', () => { beforeEach(() => { delete process.env.NO_COLOR; + delete process.env.OPENSPEC_NO_ANIMATION; Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); Object.defineProperty(process.stdout, 'columns', { value: 100, configurable: true }); writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); useKeypressMock.mockClear(); + // Deterministic default: no OS-level reduced-motion preference detectable, + // so animated-path tests behave the same on every machine. + execFileSyncMock.mockReset(); + execFileSyncMock.mockImplementation(() => { + throw new Error('not available in tests'); + }); }); afterEach(() => { @@ -51,6 +64,11 @@ describe('welcome screen', () => { } else { process.env.NO_COLOR = originalNoColor; } + if (originalNoAnimation === undefined) { + delete process.env.OPENSPEC_NO_ANIMATION; + } else { + process.env.OPENSPEC_NO_ANIMATION = originalNoAnimation; + } Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true }); Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true }); Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true }); @@ -119,4 +137,91 @@ describe('welcome screen', () => { expect(line.length).toBeLessThanOrEqual(59); } }); + + it('renders statically when OPENSPEC_NO_ANIMATION is set', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + process.env.OPENSPEC_NO_ANIMATION = '1'; + + await showWelcomeScreen(CORE_WORKFLOWS); + + expect(useKeypressMock).not.toHaveBeenCalled(); + const output = writtenOutput(); + expect(output).toContain('Welcome to OpenSpec'); + // No cursor-up repaints: the frame is drawn exactly once. + expect(output).not.toMatch(/\x1b\[\d+A/); + }); + + it('renders statically when animate is disabled via options', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + + await showWelcomeScreen(CORE_WORKFLOWS, { animate: false }); + + expect(useKeypressMock).not.toHaveBeenCalled(); + const output = writtenOutput(); + expect(output).toContain('Welcome to OpenSpec'); + expect(output).not.toMatch(/\x1b\[\d+A/); + }); + + it.runIf(process.platform === 'darwin' || process.platform === 'linux')( + 'renders statically when the OS prefers reduced motion', + async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + execFileSyncMock.mockImplementation((file: string) => + file === 'defaults' ? '1\n' : 'false\n' + ); + + await showWelcomeScreen(CORE_WORKFLOWS); + + expect(useKeypressMock).not.toHaveBeenCalled(); + expect(writtenOutput()).toContain('Welcome to OpenSpec'); + } + ); +}); + +describe('prefersReducedMotion', () => { + beforeEach(() => { + execFileSyncMock.mockReset(); + }); + + it('detects macOS Reduce Motion', async () => { + const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js'); + execFileSyncMock.mockReturnValue('1\n'); + + expect(prefersReducedMotion('darwin')).toBe(true); + expect(execFileSyncMock).toHaveBeenCalledWith( + 'defaults', + ['read', 'com.apple.universalaccess', 'reduceMotion'], + expect.objectContaining({ timeout: 500 }) + ); + }); + + it('treats a disabled or unset macOS preference as no preference', async () => { + const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js'); + + execFileSyncMock.mockReturnValue('0\n'); + expect(prefersReducedMotion('darwin')).toBe(false); + + // `defaults read` exits non-zero while the key has never been toggled. + execFileSyncMock.mockImplementation(() => { + throw new Error('The domain/default pair does not exist'); + }); + expect(prefersReducedMotion('darwin')).toBe(false); + }); + + it('detects GNOME reduced motion via disabled animations', async () => { + const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js'); + + execFileSyncMock.mockReturnValue('false\n'); + expect(prefersReducedMotion('linux')).toBe(true); + + execFileSyncMock.mockReturnValue('true\n'); + expect(prefersReducedMotion('linux')).toBe(false); + }); + + it('returns false without spawning anything on other platforms', async () => { + const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js'); + + expect(prefersReducedMotion('win32')).toBe(false); + expect(execFileSyncMock).not.toHaveBeenCalled(); + }); }); From 24193fbe7b8bea70afe24a26f33cdaa1edef7e12 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 27 Jul 2026 17:01:13 -0500 Subject: [PATCH 2/3] fix(init): honor an empty OPENSPEC_NO_ANIMATION value Presence is what counts, like NO_COLOR: OPENSPEC_NO_ANIMATION= (set but empty) now also disables the welcome animation, matching the documented 'when set' behavior. CodeRabbit review follow-up. Co-Authored-By: Claude Fable 5 --- src/ui/welcome-screen.ts | 5 +++-- test/ui/welcome-screen.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts index 9be79537f0..11ed380cbe 100644 --- a/src/ui/welcome-screen.ts +++ b/src/ui/welcome-screen.ts @@ -121,8 +121,9 @@ function canAnimate(): boolean { // Respect NO_COLOR if (process.env.NO_COLOR) return false; - // Manual override for users who need reduced motion (#722) - if (process.env.OPENSPEC_NO_ANIMATION) return false; + // Manual override for users who need reduced motion (#722). Like NO_COLOR, + // presence is what counts: even an empty value disables the animation. + if (process.env.OPENSPEC_NO_ANIMATION !== undefined) return false; // Check terminal width const columns = process.stdout.columns || 80; diff --git a/test/ui/welcome-screen.test.ts b/test/ui/welcome-screen.test.ts index 38d170ce3d..69238e56a4 100644 --- a/test/ui/welcome-screen.test.ts +++ b/test/ui/welcome-screen.test.ts @@ -151,6 +151,16 @@ describe('welcome screen', () => { expect(output).not.toMatch(/\x1b\[\d+A/); }); + it('honors OPENSPEC_NO_ANIMATION even when set to an empty value', async () => { + const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); + process.env.OPENSPEC_NO_ANIMATION = ''; + + await showWelcomeScreen(CORE_WORKFLOWS); + + expect(useKeypressMock).not.toHaveBeenCalled(); + expect(writtenOutput()).not.toMatch(/\x1b\[\d+A/); + }); + it('renders statically when animate is disabled via options', async () => { const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js'); From 5cdb0a323ba1027f1bb4ddd86f7bcaa9241917db Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 27 Jul 2026 17:05:22 -0500 Subject: [PATCH 3/3] docs(init): state animation-skip env semantics precisely Co-Authored-By: Claude Fable 5 --- docs/cli.md | 2 +- src/ui/welcome-screen.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 8101a2a223..3b7f3acfbf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -105,7 +105,7 @@ openspec init [path] [options] `--profile custom` uses whatever workflows are currently selected in global config (`openspec config profile`). -The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set, when `NO_COLOR` is set, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled). +The welcome animation is also skipped when the `OPENSPEC_NO_ANIMATION` environment variable is set (any value, including empty), when `NO_COLOR` is set to a non-empty value, or when the OS reduced-motion preference is enabled (macOS Reduce Motion, GNOME animations disabled). **Supported tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codeartsagent`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `hermes`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `vibe`, `oh-my-pi`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`, `zcode` diff --git a/src/ui/welcome-screen.ts b/src/ui/welcome-screen.ts index 11ed380cbe..32db8b1c65 100644 --- a/src/ui/welcome-screen.ts +++ b/src/ui/welcome-screen.ts @@ -121,8 +121,8 @@ function canAnimate(): boolean { // Respect NO_COLOR if (process.env.NO_COLOR) return false; - // Manual override for users who need reduced motion (#722). Like NO_COLOR, - // presence is what counts: even an empty value disables the animation. + // Manual override for users who need reduced motion (#722). Presence is + // what counts: even an empty value disables the animation. if (process.env.OPENSPEC_NO_ANIMATION !== undefined) return false; // Check terminal width