@thegetty/quire-cli 1.0.0-rc.41 → 1.0.0-rc.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -19,6 +19,20 @@ process.removeAllListeners('warning')
19
19
  */
20
20
  process.env.QUIRE_LOG_LEVEL = config.get('logLevel') || 'info'
21
21
 
22
+ /**
23
+ * Set REDUCED_MOTION env var from config before importing CLI modules
24
+ *
25
+ * The reporter reads REDUCED_MOTION to decide whether to use animated
26
+ * spinners or static text output. Setting it here ensures the reporter
27
+ * picks up the config value at module load time.
28
+ * If REDUCED_MOTION is already set in the shell environment, we do not override it.
29
+ *
30
+ * @see lib/reporter/index.js
31
+ */
32
+ if (process.env.REDUCED_MOTION === undefined && config.get('reducedMotion') === true) {
33
+ process.env.REDUCED_MOTION = '1'
34
+ }
35
+
22
36
  /**
23
37
  * Set NO_COLOR env var from config before importing CLI modules
24
38
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thegetty/quire-cli",
3
3
  "description": "Quire command-line interface",
4
- "version": "1.0.0-rc.41",
4
+ "version": "1.0.0-rc.43",
5
5
  "author": "Getty Digital",
6
6
  "license": "SEE LICENSE IN https://github.com/thegetty/quire/blob/main/LICENSE",
7
7
  "bugs": {
@@ -174,12 +174,15 @@ class Quire11ty {
174
174
 
175
175
  configureEleventyEnv({ mode: 'production', debug: options.debug })
176
176
 
177
- reporter.start('Building site...', { showElapsed: true })
178
-
179
177
  const eleventy = await createEleventyInstance(options)
180
178
 
181
179
  eleventy.setDryRun(options.dryRun)
182
180
 
181
+ // Print a static info line before Eleventy's build output begins.
182
+ // A spinner is not used here because write() writes directly to stdout
183
+ // and would overwrite the spinner line.
184
+ reporter.info('Building site...')
185
+
183
186
  try {
184
187
  await eleventy.write()
185
188
  reporter.succeed('Build complete')
@@ -204,17 +207,29 @@ class Quire11ty {
204
207
 
205
208
  configureEleventyEnv({ mode: 'development', debug: options.debug })
206
209
 
207
- reporter.start('Starting development server...')
208
-
209
210
  const eleventy =
210
211
  await createEleventyInstance({ ...options, runMode: 'serve' })
211
212
 
212
213
  // Store reference for lifecycle management (graceful shutdown)
213
214
  this.activeInstance = eleventy
214
215
 
215
- // Initialize Eleventy before serving (required for eleventyServe)
216
+ // Initialize Eleventy (required before watch/serve)
216
217
  await eleventy.init()
217
218
 
219
+ // Print a static info line before Eleventy's build output begins.
220
+ // A spinner is not used here because watch() writes directly to stdout
221
+ // and would overwrite the spinner line.
222
+ reporter.info('Building site...')
223
+
224
+ // Build the site and start file watchers.
225
+ // watch() performs the initial build via write(), then sets up chokidar
226
+ // file watchers for incremental rebuilds on file changes.
227
+ // This matches the Eleventy CLI sequence: init() → watch() → serve()
228
+ // @see https://github.com/11ty/eleventy/blob/main/cmd.cjs
229
+ await eleventy.watch()
230
+
231
+ reporter.start('Starting development server...')
232
+
218
233
  // Register a ready callback to resolve the spinner when the server is listening
219
234
  // @see https://www.11ty.dev/docs/dev-server/#options
220
235
  eleventy.eleventyServe.config.serverOptions = {
@@ -228,6 +243,7 @@ class Quire11ty {
228
243
  },
229
244
  }
230
245
 
246
+ // Start the HTTP dev server (serves the built _site/ directory)
231
247
  await eleventy.serve(options.port)
232
248
  }
233
249
  }
@@ -15,6 +15,7 @@ export {
15
15
  quietOption,
16
16
  verboseOption,
17
17
  debugOption,
18
+ reducedMotionOption,
18
19
  outputModeOptions,
19
20
  outputModeHelpText,
20
21
  withOutputModes,
@@ -124,6 +124,24 @@ export const debugOption = [
124
124
  { conflicts: ['quiet'] }
125
125
  ]
126
126
 
127
+ /**
128
+ * Reduced motion option - disable spinner animation and line overwriting
129
+ *
130
+ * When enabled, the reporter outputs static text on new lines instead of
131
+ * animated spinners that overwrite the current line. This is useful for:
132
+ * - Screen reader users (animated overwriting disrupts reading flow)
133
+ * - Users who prefer reduced motion
134
+ * - Environments where terminal animation is problematic
135
+ *
136
+ * Respects config default: `quire settings set reducedMotion true`
137
+ * Also respects REDUCED_MOTION environment variable.
138
+ *
139
+ * @type {Array}
140
+ */
141
+ export const reducedMotionOption = [
142
+ '--reduced-motion', 'disable spinner animation and line overwriting',
143
+ ]
144
+
127
145
  /**
128
146
  * Color options - enable/disable colored output
129
147
  *
@@ -1,7 +1,7 @@
1
1
  import test from 'ava'
2
2
  import { Command } from 'commander'
3
3
  import { arrayToOption } from './index.js'
4
- import { colorOption, noColorOption, quietOption, verboseOption, debugOption } from './options.js'
4
+ import { colorOption, noColorOption, quietOption, verboseOption, debugOption, reducedMotionOption } from './options.js'
5
5
 
6
6
  // ─────────────────────────────────────────────────────────────────────────────
7
7
  // Integration tests for option conflicts
@@ -21,6 +21,7 @@ function createTestProgram() {
21
21
  .addOption(arrayToOption(quietOption))
22
22
  .addOption(arrayToOption(verboseOption))
23
23
  .addOption(arrayToOption(debugOption))
24
+ .addOption(arrayToOption(reducedMotionOption))
24
25
  .action(() => {})
25
26
  .exitOverride() // Throw instead of process.exit
26
27
  .configureOutput({
@@ -110,6 +111,56 @@ test('options order does not affect conflict detection', (t) => {
110
111
  t.throws(() => program2.parse(['node', 'test', '--verbose', '--quiet']))
111
112
  })
112
113
 
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // Reduced motion option tests
116
+ // ─────────────────────────────────────────────────────────────────────────────
117
+
118
+ test('--reduced-motion alone is valid', (t) => {
119
+ const program = createTestProgram()
120
+
121
+ t.notThrows(() => {
122
+ program.parse(['node', 'test', '--reduced-motion'])
123
+ })
124
+ })
125
+
126
+ test('--reduced-motion sets opts.reducedMotion to true', (t) => {
127
+ const program = createTestProgram()
128
+ program.parse(['node', 'test', '--reduced-motion'])
129
+
130
+ t.is(program.opts().reducedMotion, true)
131
+ })
132
+
133
+ test('no flag leaves opts.reducedMotion as undefined', (t) => {
134
+ const program = createTestProgram()
135
+ program.parse(['node', 'test'])
136
+
137
+ t.is(program.opts().reducedMotion, undefined)
138
+ })
139
+
140
+ test('--reduced-motion combines with --verbose without conflict', (t) => {
141
+ const program = createTestProgram()
142
+
143
+ t.notThrows(() => {
144
+ program.parse(['node', 'test', '--reduced-motion', '--verbose'])
145
+ })
146
+ })
147
+
148
+ test('--reduced-motion combines with --quiet without conflict', (t) => {
149
+ const program = createTestProgram()
150
+
151
+ t.notThrows(() => {
152
+ program.parse(['node', 'test', '--reduced-motion', '--quiet'])
153
+ })
154
+ })
155
+
156
+ test('--reduced-motion combines with --debug without conflict', (t) => {
157
+ const program = createTestProgram()
158
+
159
+ t.notThrows(() => {
160
+ program.parse(['node', 'test', '--reduced-motion', '--debug'])
161
+ })
162
+ })
163
+
113
164
  // ─────────────────────────────────────────────────────────────────────────────
114
165
  // Color option tests
115
166
  // ─────────────────────────────────────────────────────────────────────────────
@@ -48,6 +48,13 @@ export default {
48
48
  * Default PDF engine for quire pdf command.
49
49
  */
50
50
  pdfEngine: 'pagedjs',
51
+ /**
52
+ * Disable spinner animation and line overwriting.
53
+ * When enabled, progress output uses static text instead of animated spinners,
54
+ * and each stage prints on a new line rather than overwriting the current line.
55
+ * Can be overridden per-command with --reduced-motion.
56
+ */
57
+ reducedMotion: false,
51
58
  /**
52
59
  * Project starter template to use when creating new projects.
53
60
  */
@@ -41,6 +41,10 @@ export default {
41
41
  type: 'boolean',
42
42
  description: 'Color message text by log level (e.g., red for errors). Requires logUseColor'
43
43
  },
44
+ reducedMotion: {
45
+ type: 'boolean',
46
+ description: 'Disable spinner animation and line overwriting (for screen readers and reduced-motion preferences)'
47
+ },
44
48
  projectTemplate: {
45
49
  type: 'string',
46
50
  format: 'uri',
@@ -14,14 +14,27 @@
14
14
  * | Default | (none) | Show spinner with basic status |
15
15
  * | Quiet | `-q, --quiet` | Suppress all output (for CI/scripts) |
16
16
  * | Verbose | `-v, --verbose` | Show detailed progress with paths, timing |
17
+ * | Reduced Motion | `--reduced-motion` | Static text, no animation, no line overwriting |
17
18
  *
18
19
  * Note: `--debug` is handled separately and enables DEBUG namespace logging,
19
20
  * not reporter output. Use verbose mode for detailed user-facing output.
20
21
  *
22
+ * ## Reduced Motion
23
+ *
24
+ * When reduced motion is enabled (via `--reduced-motion` flag, `REDUCED_MOTION`
25
+ * environment variable, or `reducedMotion` config setting), the reporter:
26
+ * - Prints static text instead of animated spinners
27
+ * - Outputs each stage on a new line (no line overwriting)
28
+ * - Still shows status symbols (✔ ✖ ⚠ ℹ) for completion states
29
+ *
30
+ * This mode is designed for screen reader users and environments where
31
+ * terminal animation is problematic.
32
+ *
21
33
  * ## Config Defaults
22
34
  *
23
35
  * Users can set default values via `quire settings`:
24
36
  * - `quire settings set verbose true` - Always run in verbose mode
37
+ * - `quire settings set reducedMotion true` - Disable spinner animation
25
38
  *
26
39
  * CLI flags override config settings. Use `--no-verbose` to disable verbose
27
40
  * mode even when enabled in config.
@@ -81,11 +94,27 @@ import config from '#lib/conf/config.js'
81
94
 
82
95
  const debug = createDebug('lib:reporter')
83
96
 
97
+ /**
98
+ * Status symbols for reduced motion output
99
+ *
100
+ * These match the symbols used by ora for visual consistency,
101
+ * ensuring the same information is conveyed with or without animation.
102
+ * @private
103
+ */
104
+ const STATUS_SYMBOLS = {
105
+ start: '–',
106
+ succeed: '✔',
107
+ fail: '✖',
108
+ warn: '⚠',
109
+ info: 'ℹ',
110
+ }
111
+
84
112
  /**
85
113
  * Reporter class for CLI progress feedback
86
114
  *
87
115
  * Wraps ora spinner with additional features:
88
116
  * - Respects --quiet and --json flags
117
+ * - Supports reduced motion (static text, no line overwriting)
89
118
  * - Supports elapsed time display
90
119
  * - Provides consistent API across commands
91
120
  */
@@ -111,6 +140,22 @@ class Reporter {
111
140
  /** @type {string} */
112
141
  #baseText = ''
113
142
 
143
+ /**
144
+ * Check if reduced motion mode is active
145
+ *
146
+ * Reduced motion disables spinner animation and line overwriting.
147
+ * Reads from the REDUCED_MOTION environment variable, which is set by:
148
+ * - `--reduced-motion` CLI flag (via preAction hook)
149
+ * - `REDUCED_MOTION=1` shell environment variable
150
+ * - `reducedMotion: true` config setting (via bin/cli.js)
151
+ *
152
+ * @private
153
+ * @returns {boolean}
154
+ */
155
+ #isReducedMotion() {
156
+ return Boolean(process.env.REDUCED_MOTION)
157
+ }
158
+
114
159
  /**
115
160
  * Configure reporter for current command context
116
161
  *
@@ -134,7 +179,8 @@ class Reporter {
134
179
  this.#json = options.json || false
135
180
  // CLI flag takes precedence, then config setting, then default false
136
181
  this.#verbose = options.verbose ?? config.get('verbose') ?? false
137
- debug('configured: quiet=%s, json=%s, verbose=%s', this.#quiet, this.#json, this.#verbose)
182
+ debug('configured: quiet=%s, json=%s, verbose=%s, reducedMotion=%s',
183
+ this.#quiet, this.#json, this.#verbose, this.#isReducedMotion())
138
184
  return this
139
185
  }
140
186
 
@@ -175,6 +221,13 @@ class Reporter {
175
221
  return this
176
222
  }
177
223
 
224
+ // Reduced motion: print static text on a new line, no spinner
225
+ if (this.#isReducedMotion()) {
226
+ debug('spinner start (reduced motion): %s', text)
227
+ console.log(`${STATUS_SYMBOLS.start} ${text}`)
228
+ return this
229
+ }
230
+
178
231
  debug('spinner start: %s', text)
179
232
  this.#spinner = ora({
180
233
  text,
@@ -235,6 +288,13 @@ class Reporter {
235
288
  return this
236
289
  }
237
290
 
291
+ // Reduced motion: print new line instead of overwriting
292
+ if (this.#isReducedMotion()) {
293
+ debug('spinner update (reduced motion): %s', text)
294
+ console.log(`${STATUS_SYMBOLS.start} ${text}`)
295
+ return this
296
+ }
297
+
238
298
  if (this.#spinner) {
239
299
  debug('spinner update: %s', text)
240
300
  this.#spinner.text = text
@@ -273,6 +333,13 @@ class Reporter {
273
333
  return this
274
334
  }
275
335
 
336
+ // Reduced motion: print static success line
337
+ if (this.#isReducedMotion()) {
338
+ debug('spinner succeed (reduced motion): %s', message)
339
+ console.log(`${STATUS_SYMBOLS.succeed} ${message}`)
340
+ return this
341
+ }
342
+
276
343
  if (this.#spinner) {
277
344
  debug('spinner succeed: %s', message)
278
345
  this.#spinner.succeed(message)
@@ -309,6 +376,13 @@ class Reporter {
309
376
  return this
310
377
  }
311
378
 
379
+ // Reduced motion: print static failure line
380
+ if (this.#isReducedMotion()) {
381
+ debug('spinner fail (reduced motion): %s', message)
382
+ console.log(`${STATUS_SYMBOLS.fail} ${message}`)
383
+ return this
384
+ }
385
+
312
386
  if (this.#spinner) {
313
387
  debug('spinner fail: %s', message)
314
388
  this.#spinner.fail(message)
@@ -343,6 +417,13 @@ class Reporter {
343
417
  return this
344
418
  }
345
419
 
420
+ // Reduced motion: print static warning line
421
+ if (this.#isReducedMotion()) {
422
+ debug('spinner warn (reduced motion): %s', message)
423
+ console.log(`${STATUS_SYMBOLS.warn} ${message}`)
424
+ return this
425
+ }
426
+
346
427
  if (this.#spinner) {
347
428
  debug('spinner warn: %s', message)
348
429
  this.#spinner.warn(message)
@@ -370,6 +451,13 @@ class Reporter {
370
451
  return this
371
452
  }
372
453
 
454
+ // Reduced motion: print static info line
455
+ if (this.#isReducedMotion()) {
456
+ debug('spinner info (reduced motion): %s', text)
457
+ console.log(`${STATUS_SYMBOLS.info} ${text}`)
458
+ return this
459
+ }
460
+
373
461
  if (this.#spinner) {
374
462
  debug('spinner info: %s', text)
375
463
  this.#spinner.info(text)
@@ -405,6 +493,13 @@ class Reporter {
405
493
  return this
406
494
  }
407
495
 
496
+ // Reduced motion: print detail directly (no spinner to stop/restart)
497
+ if (this.#isReducedMotion()) {
498
+ console.log(` ${text}`)
499
+ debug('detail (reduced motion): %s', text)
500
+ return this
501
+ }
502
+
408
503
  // Print detail with indentation below spinner
409
504
  if (this.#spinner) {
410
505
  // Temporarily stop spinner to print detail
@@ -591,3 +591,272 @@ test('multi-phase workflow with different outcomes', (t) => {
591
591
 
592
592
  t.pass()
593
593
  })
594
+
595
+ // ─────────────────────────────────────────────────────────────────────────────
596
+ // Reduced motion tests
597
+ // ─────────────────────────────────────────────────────────────────────────────
598
+
599
+ test.serial('reduced motion: start() does not create ora spinner', async (t) => {
600
+ const { sandbox } = t.context
601
+ const mockOra = sandbox.stub()
602
+
603
+ const { Reporter: MockedReporter } = await esmock('./index.js', {
604
+ ora: { default: mockOra },
605
+ })
606
+
607
+ const originalIsTTY = process.stdout.isTTY
608
+ const originalReduceMotion = process.env.REDUCED_MOTION
609
+ const consoleLog = sandbox.stub(console, 'log')
610
+
611
+ try {
612
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
613
+ process.env.REDUCED_MOTION = '1'
614
+
615
+ const rep = new MockedReporter()
616
+ rep.start('Building...')
617
+
618
+ t.false(mockOra.called, 'ora should not be called in reduced motion mode')
619
+ t.true(consoleLog.calledOnce, 'console.log should be called once')
620
+ t.true(consoleLog.firstCall.args[0].includes('Building...'),
621
+ 'output should include the text')
622
+ } finally {
623
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
624
+ if (originalReduceMotion === undefined) {
625
+ delete process.env.REDUCED_MOTION
626
+ } else {
627
+ process.env.REDUCED_MOTION = originalReduceMotion
628
+ }
629
+ }
630
+ })
631
+
632
+ test.serial('reduced motion: update() prints new line instead of overwriting', async (t) => {
633
+ const { sandbox } = t.context
634
+ const mockOra = sandbox.stub()
635
+
636
+ const { Reporter: MockedReporter } = await esmock('./index.js', {
637
+ ora: { default: mockOra },
638
+ })
639
+
640
+ const originalIsTTY = process.stdout.isTTY
641
+ const originalReduceMotion = process.env.REDUCED_MOTION
642
+ const consoleLog = sandbox.stub(console, 'log')
643
+
644
+ try {
645
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
646
+ process.env.REDUCED_MOTION = '1'
647
+
648
+ const rep = new MockedReporter()
649
+ rep.start('Step 1...')
650
+ rep.update('Step 2...')
651
+
652
+ t.is(consoleLog.callCount, 2, 'should print two separate lines')
653
+ t.true(consoleLog.firstCall.args[0].includes('Step 1...'))
654
+ t.true(consoleLog.secondCall.args[0].includes('Step 2...'))
655
+ } finally {
656
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
657
+ if (originalReduceMotion === undefined) {
658
+ delete process.env.REDUCED_MOTION
659
+ } else {
660
+ process.env.REDUCED_MOTION = originalReduceMotion
661
+ }
662
+ }
663
+ })
664
+
665
+ test.serial('reduced motion: succeed() prints static success line', async (t) => {
666
+ const { sandbox } = t.context
667
+ const mockOra = sandbox.stub()
668
+
669
+ const { Reporter: MockedReporter } = await esmock('./index.js', {
670
+ ora: { default: mockOra },
671
+ })
672
+
673
+ const originalIsTTY = process.stdout.isTTY
674
+ const originalReduceMotion = process.env.REDUCED_MOTION
675
+ const consoleLog = sandbox.stub(console, 'log')
676
+
677
+ try {
678
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
679
+ process.env.REDUCED_MOTION = '1'
680
+
681
+ const rep = new MockedReporter()
682
+ rep.start('Building...')
683
+ rep.succeed('Build complete')
684
+
685
+ t.is(consoleLog.callCount, 2)
686
+ // Second call is succeed
687
+ t.true(consoleLog.secondCall.args[0].includes('✔'))
688
+ t.true(consoleLog.secondCall.args[0].includes('Build complete'))
689
+ } finally {
690
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
691
+ if (originalReduceMotion === undefined) {
692
+ delete process.env.REDUCED_MOTION
693
+ } else {
694
+ process.env.REDUCED_MOTION = originalReduceMotion
695
+ }
696
+ }
697
+ })
698
+
699
+ test.serial('reduced motion: fail() prints static failure line', async (t) => {
700
+ const { sandbox } = t.context
701
+ const mockOra = sandbox.stub()
702
+
703
+ const { Reporter: MockedReporter } = await esmock('./index.js', {
704
+ ora: { default: mockOra },
705
+ })
706
+
707
+ const originalIsTTY = process.stdout.isTTY
708
+ const originalReduceMotion = process.env.REDUCED_MOTION
709
+ const consoleLog = sandbox.stub(console, 'log')
710
+
711
+ try {
712
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
713
+ process.env.REDUCED_MOTION = '1'
714
+
715
+ const rep = new MockedReporter()
716
+ rep.start('Generating PDF...')
717
+ rep.fail('PDF generation failed')
718
+
719
+ t.is(consoleLog.callCount, 2)
720
+ t.true(consoleLog.secondCall.args[0].includes('✖'))
721
+ t.true(consoleLog.secondCall.args[0].includes('PDF generation failed'))
722
+ } finally {
723
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
724
+ if (originalReduceMotion === undefined) {
725
+ delete process.env.REDUCED_MOTION
726
+ } else {
727
+ process.env.REDUCED_MOTION = originalReduceMotion
728
+ }
729
+ }
730
+ })
731
+
732
+ test.serial('reduced motion: warn() prints static warning line', async (t) => {
733
+ const { sandbox } = t.context
734
+ const mockOra = sandbox.stub()
735
+
736
+ const { Reporter: MockedReporter } = await esmock('./index.js', {
737
+ ora: { default: mockOra },
738
+ })
739
+
740
+ const originalIsTTY = process.stdout.isTTY
741
+ const originalReduceMotion = process.env.REDUCED_MOTION
742
+ const consoleLog = sandbox.stub(console, 'log')
743
+
744
+ try {
745
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
746
+ process.env.REDUCED_MOTION = '1'
747
+
748
+ const rep = new MockedReporter()
749
+ rep.start('Checking...')
750
+ rep.warn('Dependencies outdated')
751
+
752
+ t.is(consoleLog.callCount, 2)
753
+ t.true(consoleLog.secondCall.args[0].includes('⚠'))
754
+ t.true(consoleLog.secondCall.args[0].includes('Dependencies outdated'))
755
+ } finally {
756
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
757
+ if (originalReduceMotion === undefined) {
758
+ delete process.env.REDUCED_MOTION
759
+ } else {
760
+ process.env.REDUCED_MOTION = originalReduceMotion
761
+ }
762
+ }
763
+ })
764
+
765
+ test.serial('reduced motion: info() prints static info line', async (t) => {
766
+ const { sandbox } = t.context
767
+ const mockOra = sandbox.stub()
768
+
769
+ const { Reporter: MockedReporter } = await esmock('./index.js', {
770
+ ora: { default: mockOra },
771
+ })
772
+
773
+ const originalIsTTY = process.stdout.isTTY
774
+ const originalReduceMotion = process.env.REDUCED_MOTION
775
+ const consoleLog = sandbox.stub(console, 'log')
776
+
777
+ try {
778
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
779
+ process.env.REDUCED_MOTION = '1'
780
+
781
+ const rep = new MockedReporter()
782
+ rep.info('Using cached dependencies')
783
+
784
+ t.true(consoleLog.calledOnce)
785
+ t.true(consoleLog.firstCall.args[0].includes('ℹ'))
786
+ t.true(consoleLog.firstCall.args[0].includes('Using cached dependencies'))
787
+ } finally {
788
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
789
+ if (originalReduceMotion === undefined) {
790
+ delete process.env.REDUCED_MOTION
791
+ } else {
792
+ process.env.REDUCED_MOTION = originalReduceMotion
793
+ }
794
+ }
795
+ })
796
+
797
+ test.serial('reduced motion: multi-phase workflow outputs each phase on new line', async (t) => {
798
+ const { sandbox } = t.context
799
+ const mockOra = sandbox.stub()
800
+
801
+ const { Reporter: MockedReporter } = await esmock('./index.js', {
802
+ ora: { default: mockOra },
803
+ })
804
+
805
+ const originalIsTTY = process.stdout.isTTY
806
+ const originalReduceMotion = process.env.REDUCED_MOTION
807
+ const consoleLog = sandbox.stub(console, 'log')
808
+
809
+ try {
810
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true })
811
+ process.env.REDUCED_MOTION = '1'
812
+
813
+ const rep = new MockedReporter()
814
+ rep.start('Cloning starter...')
815
+ rep.update('Installing dependencies...')
816
+ rep.succeed('Project created')
817
+
818
+ t.is(consoleLog.callCount, 3, 'each stage should print on a new line')
819
+ t.true(consoleLog.getCall(0).args[0].includes('Cloning starter...'))
820
+ t.true(consoleLog.getCall(1).args[0].includes('Installing dependencies...'))
821
+ t.true(consoleLog.getCall(2).args[0].includes('✔'))
822
+ t.true(consoleLog.getCall(2).args[0].includes('Project created'))
823
+ t.false(mockOra.called, 'ora should never be called')
824
+ } finally {
825
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true })
826
+ if (originalReduceMotion === undefined) {
827
+ delete process.env.REDUCED_MOTION
828
+ } else {
829
+ process.env.REDUCED_MOTION = originalReduceMotion
830
+ }
831
+ }
832
+ })
833
+
834
+ test.serial('reduced motion: quiet mode takes precedence over reduced motion', async (t) => {
835
+ const { sandbox } = t.context
836
+ const mockOra = sandbox.stub()
837
+
838
+ const { Reporter: MockedReporter } = await esmock('./index.js', {
839
+ ora: { default: mockOra },
840
+ })
841
+
842
+ const originalReduceMotion = process.env.REDUCED_MOTION
843
+ const consoleLog = sandbox.stub(console, 'log')
844
+
845
+ try {
846
+ process.env.REDUCED_MOTION = '1'
847
+
848
+ const rep = new MockedReporter()
849
+ rep.configure({ quiet: true })
850
+ rep.start('Building...')
851
+ rep.succeed('Build complete')
852
+
853
+ t.false(consoleLog.called, 'quiet mode should suppress all output')
854
+ t.false(mockOra.called, 'ora should not be called')
855
+ } finally {
856
+ if (originalReduceMotion === undefined) {
857
+ delete process.env.REDUCED_MOTION
858
+ } else {
859
+ process.env.REDUCED_MOTION = originalReduceMotion
860
+ }
861
+ }
862
+ })
package/src/main.js CHANGED
@@ -6,7 +6,8 @@ import {
6
6
  noColorOption,
7
7
  quietOption,
8
8
  verboseOption,
9
- debugOption
9
+ debugOption,
10
+ reducedMotionOption,
10
11
  } from '#lib/commander/index.js'
11
12
  import commands from '#src/commands/index.js'
12
13
  import config from '#lib/conf/config.js'
@@ -30,12 +31,20 @@ Common Workflows:
30
31
  Run 'quire help workflows' for detailed workflow documentation.
31
32
 
32
33
  Output Modes:
33
- -q, --quiet Suppress progress output (for CI/scripts)
34
- -v, --verbose Show detailed progress (paths, timing, steps)
35
- --debug Enable debug output for developers/troubleshooting
34
+ -q, --quiet Suppress progress output (for CI/scripts)
35
+ -v, --verbose Show detailed progress (paths, timing, steps)
36
+ --debug Enable debug output for developers/troubleshooting
37
+ --reduced-motion Disable spinner animation and line overwriting
36
38
 
37
39
  Set defaults: quire settings set verbose true
38
40
 
41
+ Accessibility:
42
+ --reduced-motion disables animated spinners and line overwriting.
43
+ Each stage prints on a new line as static text, making output
44
+ compatible with screen readers and reduced-motion preferences.
45
+
46
+ Set default: quire settings set reducedMotion true
47
+
39
48
  Color Output:
40
49
  --no-color Disable colored output
41
50
  --color Force colored output (overrides NO_COLOR env var)
@@ -49,6 +58,7 @@ Paging:
49
58
  PAGER=cat Traditional Unix alternative (passes output through)
50
59
 
51
60
  Environment Variables:
61
+ REDUCED_MOTION Disable spinner animation and line overwriting
52
62
  NO_COLOR Disable colored output (https://no-color.org/)
53
63
  NO_PAGER=1 Disable paging for long output
54
64
  PAGER=<program> Set pager program (default: less). Use PAGER=cat to disable
@@ -60,6 +70,8 @@ Examples:
60
70
  $ quire build Build the publication
61
71
  $ quire build --verbose Build with detailed progress
62
72
  $ quire build --debug Build with debug output
73
+ $ quire build --reduced-motion Build without animated spinners
74
+ $ REDUCED_MOTION=1 quire build Build without animated spinners
63
75
  $ quire build --no-color Build without colored output
64
76
  $ NO_COLOR=1 quire build Build without colored output
65
77
  $ DEBUG=quire:* quire pdf Generate PDF with debug output
@@ -83,6 +95,7 @@ program
83
95
  .addOption(arrayToOption(quietOption))
84
96
  .addOption(arrayToOption(verboseOption))
85
97
  .addOption(arrayToOption(debugOption))
98
+ .addOption(arrayToOption(reducedMotionOption))
86
99
  .option('--no-pager', 'disable paging for long output')
87
100
  .addHelpText('after', mainHelpText)
88
101
  .configureHelp({
@@ -102,6 +115,7 @@ program
102
115
  * - --quiet: Suppress progress spinners (for CI/scripts)
103
116
  * - --verbose: Show detailed progress (paths, timing, steps)
104
117
  * - --debug: Enable DEBUG namespace + tool debug modes (for developers)
118
+ * - --reduced-motion: Disable animated spinners, use static text on new lines
105
119
  * - --no-color: Disable colored output (sets NO_COLOR env var)
106
120
  * - --color: Force colored output (overrides NO_COLOR env var)
107
121
  *
@@ -127,6 +141,12 @@ program.hook('preAction', (thisCommand) => {
127
141
  enableDebug('quire:*')
128
142
  }
129
143
 
144
+ // --reduced-motion sets REDUCED_MOTION env var for reporter to read
145
+ // CLI flag takes precedence over env var and config setting
146
+ if (opts.reducedMotion) {
147
+ process.env.REDUCED_MOTION = '1'
148
+ }
149
+
130
150
  // --no-pager sets pager to false; propagate via env var for pager utility
131
151
  if (opts.pager === false) {
132
152
  process.env.NO_PAGER = '1'