@alexkroman1/aai-ui 13.3.0 → 14.0.0

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.
Files changed (73) hide show
  1. package/README.md +159 -69
  2. package/dist/{_colors-CZ6OlPbL.js → _colors-CpZO-88A.js} +24 -1
  3. package/dist/_recover-run.d.ts +2 -2
  4. package/dist/_submission-state.d.ts +92 -0
  5. package/dist/_upload-files.d.ts +2 -2
  6. package/dist/_upload-report.d.ts +26 -0
  7. package/dist/{_utils-CyzjK0gW.js → _utils-DnQDM9Uy.js} +3 -3
  8. package/dist/_utils.d.ts +3 -3
  9. package/dist/_web-storage.d.ts +43 -0
  10. package/dist/_workflow-files.d.ts +1 -1
  11. package/dist/agent-state-labels.d.ts +60 -0
  12. package/dist/audio.js +20 -14
  13. package/dist/{chat-view-Bv5VFJIE.js → chat-view-C_3T7Ln8.js} +96 -29
  14. package/dist/{client-config-DD820zHn.js → client-config-DJQHnYjm.js} +4 -4
  15. package/dist/client-config.d.ts +4 -4
  16. package/dist/client-dir.d.ts +1 -1
  17. package/dist/client-dir.js +1 -1
  18. package/dist/components/_colors.d.ts +23 -0
  19. package/dist/components/_form-readiness.d.ts +1 -1
  20. package/dist/components/bullet-list.d.ts +74 -0
  21. package/dist/components/button.js +3 -3
  22. package/dist/components/chat-view.js +1 -1
  23. package/dist/components/console-shell.d.ts +16 -20
  24. package/dist/components/controls.js +3 -3
  25. package/dist/components/facts.d.ts +81 -0
  26. package/dist/components/form-fields.d.ts +6 -6
  27. package/dist/components/form-types.d.ts +1 -1
  28. package/dist/components/form.d.ts +1 -1
  29. package/dist/components/message-list.js +1 -1
  30. package/dist/components/session-error-banner.d.ts +69 -0
  31. package/dist/components/start-screen.js +1 -1
  32. package/dist/components/tool-call-block.js +1 -1
  33. package/dist/components/tool-config-context.d.ts +1 -1
  34. package/dist/components/workflow-progress.d.ts +11 -4
  35. package/dist/context.d.ts +142 -19
  36. package/dist/context.js +155 -17
  37. package/dist/default-client/assets/{audio-BuDICbPf.js → audio-9zQsNc1w.js} +1 -1
  38. package/dist/default-client/assets/index-BTv30Z4F.css +2 -0
  39. package/dist/default-client/assets/index-RAZ-29Sz.js +284 -0
  40. package/dist/default-client/index.html +2 -2
  41. package/dist/define-client.d.ts +19 -19
  42. package/dist/define-client.js +19 -19
  43. package/dist/hooks.d.ts +44 -8
  44. package/dist/hooks.js +19 -13
  45. package/dist/index.d.ts +11 -7
  46. package/dist/index.js +418 -185
  47. package/dist/internal.d.ts +2 -2
  48. package/dist/internal.js +5 -5
  49. package/dist/{message-list-C0pL7x41.js → message-list-CdOnSh5m.js} +19 -12
  50. package/dist/page.d.ts +11 -11
  51. package/dist/session-core-audio-setup.d.ts +1 -1
  52. package/dist/session-core-dial.d.ts +0 -2
  53. package/dist/{session-core-C9elBIdu.js → session-core-gwePM95B.js} +125 -52
  54. package/dist/session-core-messages.d.ts +2 -2
  55. package/dist/session-core-types.d.ts +58 -1
  56. package/dist/session-core.d.ts +6 -6
  57. package/dist/session-core.js +2 -2
  58. package/dist/session-resume-store.d.ts +3 -3
  59. package/dist/{tool-call-block-Bunc6rCw.js → tool-call-block-C2t_5fpp.js} +27 -9
  60. package/dist/{tool-config-context-Bh8p3DtG.js → tool-config-context-Es4YUzV2.js} +1 -1
  61. package/dist/types.d.ts +19 -4
  62. package/dist/types.js +2 -2
  63. package/dist/{url-chips-C2u7QPv8.js → url-chips-BxhzZgk2.js} +4 -4
  64. package/dist/use-conversation.d.ts +1 -1
  65. package/dist/{use-user-transcript-DFTSEuZN.js → use-user-transcript-uyHhzy4d.js} +3 -2
  66. package/dist/use-workflow-form.d.ts +34 -2
  67. package/dist/{use-workflow-run-CXGEcM0l.js → use-workflow-run-CP2ekKPV.js} +3 -6
  68. package/dist/use-workflow-stream.d.ts +1 -1
  69. package/dist/workflow-client.d.ts +1 -1
  70. package/package.json +2 -2
  71. package/styles.css +78 -0
  72. package/dist/default-client/assets/index-B1_ROnTJ.js +0 -284
  73. package/dist/default-client/assets/index-S5fkKi6B.css +0 -2
@@ -1,8 +1,8 @@
1
1
  import { useTheme } from "./context.js";
2
- import { r as inkTint } from "./_colors-CZ6OlPbL.js";
2
+ import { a as inkTint } from "./_colors-CpZO-88A.js";
3
3
  import { t as Eyebrow } from "./eyebrow-UfmSz9yy.js";
4
- import { i as tryParseJSON, r as truncate } from "./_utils-CyzjK0gW.js";
5
- import { n as useToolConfig } from "./tool-config-context-Bh8p3DtG.js";
4
+ import { i as tryParseJSON, r as truncate } from "./_utils-DnQDM9Uy.js";
5
+ import { n as useToolConfig } from "./tool-config-context-Es4YUzV2.js";
6
6
  import clsx from "clsx";
7
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
8
  import { memo, useMemo, useState } from "react";
@@ -123,6 +123,29 @@ function formatResult(result) {
123
123
  return parsed === result ? result : JSON.stringify(parsed, null, 2);
124
124
  }
125
125
  /**
126
+ * The expanded result pane.
127
+ *
128
+ * Its own component so the parse-and-pretty-print happens only when the row is
129
+ * actually OPEN. `ToolCallRow` renders `children` under `isOpen && canExpand`,
130
+ * and a row starts closed — so computing this in the parent meant every row
131
+ * paid `JSON.parse` + `JSON.stringify(_, null, 2)` over its whole result, and
132
+ * then RETAINED the pretty string, for a panel nobody clicked. The burst case
133
+ * is `history.restored`, which mounts up to `DEFAULT_MAX_HISTORY` rows in one
134
+ * commit.
135
+ *
136
+ * Creating this element is free; React only runs the body once it is mounted.
137
+ */
138
+ function ToolCallResult({ result }) {
139
+ const theme = useTheme();
140
+ const formatted = useMemo(() => formatResult(result), [result]);
141
+ if (!formatted) return null;
142
+ return /* @__PURE__ */ jsx("pre", {
143
+ className: "font-aai-mono text-xs px-3.5 py-3 m-0 whitespace-pre-wrap wrap-break-word",
144
+ style: { color: inkTint(theme.text, theme.surface, 75) },
145
+ children: formatted
146
+ });
147
+ }
148
+ /**
126
149
  * Renders a tool invocation as the design system's console row (see
127
150
  * `ToolCallRow`): a small outlined "TOOL" chip (or the tool's configured
128
151
  * icon), the tool name in mono, a truncated args preview, and a rotating
@@ -151,7 +174,6 @@ const ToolCallBlock = memo(function ToolCallBlock({ toolCall, className }) {
151
174
  const title = config?.label || toolCall.name;
152
175
  const icon = config?.icon;
153
176
  const canExpand = !isPending && Boolean(toolCall.result);
154
- const formatted = useMemo(() => toolCall.result ? formatResult(toolCall.result) : "", [toolCall.result]);
155
177
  const subtitle = useMemo(() => {
156
178
  const args = toolCall.args;
157
179
  if (toolCall.name === "run_code" && args.code) return truncate(String(args.code).split("\n")[0] ?? "");
@@ -175,11 +197,7 @@ const ToolCallBlock = memo(function ToolCallBlock({ toolCall, className }) {
175
197
  borderColor: theme.border
176
198
  },
177
199
  children: String(toolCall.args.code)
178
- }), formatted && /* @__PURE__ */ jsx("pre", {
179
- className: "font-aai-mono text-xs px-3.5 py-3 m-0 whitespace-pre-wrap wrap-break-word",
180
- style: { color: inkTint(theme.text, theme.surface, 75) },
181
- children: formatted
182
- })] }) : void 0
200
+ }), toolCall.result && /* @__PURE__ */ jsx(ToolCallResult, { result: toolCall.result })] }) : void 0
183
201
  });
184
202
  });
185
203
  //#endregion
@@ -1,7 +1,7 @@
1
1
  import { createContext, useContext } from "react";
2
2
  //#region src/components/tool-config-context.ts
3
3
  /**
4
- * Context for tool display configuration. Installed by `client()` from
4
+ * Context for tool display configuration. Installed by `mountClient()` from
5
5
  * `ClientConfig.tools`; the built-in components read it via `useToolConfig`.
6
6
  *
7
7
  * @internal
package/dist/types.d.ts CHANGED
@@ -23,8 +23,8 @@ export { CAPTURE_STOP_ACK_TIMEOUT_MS, CLIENT_AUDIO_LEAD_MS, HEARD_AUDIO_LAG_MS,
23
23
  *
24
24
  * On `@alexkroman1/aai-ui/internal` rather than the root barrel, for the same
25
25
  * reason as the audio budgets above: it is a framework decision with no
26
- * `client()` field to set, and the root is the authoring surface. A custom
27
- * chrome that bypasses `client()` and opens its own microphone reaches it there
26
+ * `mountClient()` field to set, and the root is the authoring surface. A custom
27
+ * chrome that bypasses `mountClient()` and opens its own microphone reaches it there
28
28
  * alongside the providers it also needs.
29
29
  */
30
30
  export declare const VOICE_CAPTURE_CONSTRAINTS: MediaTrackConstraints;
@@ -135,11 +135,26 @@ export type SessionError = {
135
135
  readonly code: SessionErrorCode;
136
136
  /** A human-readable description of the error. */
137
137
  readonly message: string;
138
+ /**
139
+ * Whether the session is OVER.
140
+ *
141
+ * `false` means surface the message and keep the session interactive — a
142
+ * turn-level failure over a server that kept running. `true` means the call
143
+ * is dead and the microphone has been released.
144
+ *
145
+ * Required rather than optional, because the wire always carries it
146
+ * (`error.reported` declares `fatal: z.boolean()`) and a client that cannot
147
+ * tell the two apart has to guess which banner to render. It was dropped one
148
+ * line before reaching here for long enough that this type's own doc, and
149
+ * the reference page generated from it, described a field that did not
150
+ * exist.
151
+ */
152
+ readonly fatal: boolean;
138
153
  };
139
154
  /**
140
155
  * Options for creating a voice session — the shared field set accepted by
141
- * both `client()` and `createSessionCore`. The one difference: `client()`
142
- * defaults `platformUrl` from `location.href`, while `createSessionCore`
156
+ * both `mountClient()` and `createBrowserSession`. The one difference: `mountClient()`
157
+ * defaults `platformUrl` from `location.href`, while `createBrowserSession`
143
158
  * requires it.
144
159
  *
145
160
  * @public
package/dist/types.js CHANGED
@@ -22,8 +22,8 @@ import { CAPTURE_STOP_ACK_TIMEOUT_MS, CLIENT_AUDIO_LEAD_MS, HEARD_AUDIO_LAG_MS,
22
22
  *
23
23
  * On `@alexkroman1/aai-ui/internal` rather than the root barrel, for the same
24
24
  * reason as the audio budgets above: it is a framework decision with no
25
- * `client()` field to set, and the root is the authoring surface. A custom
26
- * chrome that bypasses `client()` and opens its own microphone reaches it there
25
+ * `mountClient()` field to set, and the root is the authoring surface. A custom
26
+ * chrome that bypasses `mountClient()` and opens its own microphone reaches it there
27
27
  * alongside the providers it also needs.
28
28
  */
29
29
  const VOICE_CAPTURE_CONSTRAINTS = {
@@ -1,6 +1,6 @@
1
1
  import { useSessionSelector, useTheme } from "./context.js";
2
- import { r as inkTint } from "./_colors-CZ6OlPbL.js";
3
- import { t as pageBaseUrl } from "./_utils-CyzjK0gW.js";
2
+ import { a as inkTint, i as focusRingStyle, n as FOCUS_RING } from "./_colors-CpZO-88A.js";
3
+ import { t as pageBaseUrl } from "./_utils-DnQDM9Uy.js";
4
4
  import clsx from "clsx";
5
5
  import { jsx, jsxs } from "react/jsx-runtime";
6
6
  import { useEffect, useRef, useState } from "react";
@@ -32,12 +32,12 @@ function UrlChip({ label, url, hint, testId, className }) {
32
32
  type: "button",
33
33
  onClick: copy,
34
34
  title: `${hint} (click to copy)\n${url}`,
35
- className: clsx("flex items-center gap-1.5 min-w-0 appearance-none m-0 px-2 py-1 rounded-aai border cursor-pointer text-[11px] leading-none font-aai-mono", "outline-none focus-visible:[outline:2px_solid] focus-visible:[outline-offset:2px]", className),
35
+ className: clsx("flex items-center gap-1.5 min-w-0 appearance-none m-0 px-2 py-1 rounded-aai border cursor-pointer text-[11px] leading-none font-aai-mono", FOCUS_RING, className),
36
36
  style: {
37
37
  background: inkTint(theme.text, theme.surface, 3),
38
38
  borderColor: theme.border,
39
39
  color: inkTint(theme.text, theme.surface, 65),
40
- outlineColor: theme.primary
40
+ ...focusRingStyle(theme.primary)
41
41
  },
42
42
  "data-testid": testId,
43
43
  children: [/* @__PURE__ */ jsx("span", {
@@ -88,7 +88,7 @@ export type UseConversationResult = {
88
88
  * Subscribe to the conversation: the interleaved exchange, the streaming
89
89
  * utterance, the live transcript and the thinking rule — with no markup.
90
90
  *
91
- * Must be used inside the provider `client()` installs.
91
+ * Must be used inside the provider `mountClient()` installs.
92
92
  *
93
93
  * @example A custom bubble, keeping every rule `<MessageList>` knows
94
94
  * ```tsx
@@ -1,4 +1,5 @@
1
1
  import { useSessionSelector } from "./context.js";
2
+ import { useMemo } from "react";
2
3
  //#region src/use-user-transcript.ts
3
4
  /**
4
5
  * `useUserTranscript` — what the caller is saying RIGHT NOW, read correctly.
@@ -57,11 +58,11 @@ const TRANSCRIBING_PLACEHOLDER = "…";
57
58
  */
58
59
  function useUserTranscript() {
59
60
  const partial = useSessionSelector((snapshot) => snapshot.userTranscript);
60
- return {
61
+ return useMemo(() => ({
61
62
  speaking: partial !== null,
62
63
  text: displayText(partial),
63
64
  partial
64
- };
65
+ }), [partial]);
65
66
  }
66
67
  /** The three cases, spelled out: silent, detected-but-wordless, and words. */
67
68
  function displayText(partial) {
@@ -31,7 +31,7 @@
31
31
  * `wait` here when the page really does want one request, and the run is
32
32
  * followed from the same id either way.
33
33
  */
34
- import type { AnyWorkflowDef, UploadParallel, UploadProgress, WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
34
+ import type { AnyWorkflowDef, UploadParallelOption, UploadProgress, WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
35
35
  import type { FormValues } from "./components/form-types.ts";
36
36
  import type { WorkflowApi, WorkflowRun } from "./workflow-client.ts";
37
37
  import type { SubmitInputOf } from "./workflow-def-types.ts";
@@ -119,6 +119,38 @@ export type WorkflowSubmission<R = unknown, I = unknown> = {
119
119
  cancel: () => Promise<boolean>;
120
120
  /** The run, once started, followed to completion. */
121
121
  run: WorkflowRun<R> | undefined;
122
+ /**
123
+ * True from `submit()` on this mount until `reset()` — did THIS page start
124
+ * the run it is showing?
125
+ *
126
+ * A page needs it to say the right sentence and cannot derive it: a run
127
+ * ADOPTED by the mount-time lookup after a reload looks exactly like one this
128
+ * page started. Six templates kept a `useState(false)` next to this hook, set
129
+ * it in their `onSubmit` and mirrored it in their `onClear` — shadow state
130
+ * for a fact only this hook can know, since it is the thing that decides
131
+ * between `submit()` and the recovery lookup. One of the six grew a fourth
132
+ * branch and had to move the whole note into its own module with its own
133
+ * spec, which is what a seam missing one layer down looks like.
134
+ *
135
+ * The RAW fact rather than a derived "recovered", deliberately: with `run`
136
+ * these are three states, not two, and the third is the one a page most needs
137
+ * to explain. `startedHere` is "you pressed the button"; `!startedHere &&
138
+ * !run` is the mount-time lookup still going; `!startedHere && run` is a run
139
+ * this browser started earlier, now in front of somebody who did not press
140
+ * anything. A boolean meaning only the last of those cannot express the
141
+ * middle one.
142
+ *
143
+ * @example
144
+ * ```ts
145
+ * declare const submission: import("@alexkroman1/aai-ui").WorkflowSubmission;
146
+ * const note = submission.startedHere
147
+ * ? "You can close this tab or reload it — this page will find the run again."
148
+ * : submission.run === undefined
149
+ * ? "Looking for a run this tab started earlier…"
150
+ * : "Still working on the run this tab started earlier. Reloading is safe.";
151
+ * ```
152
+ */
153
+ startedHere: boolean;
122
154
  /**
123
155
  * True from `submit()` until the run reaches a terminal status.
124
156
  *
@@ -214,7 +246,7 @@ export type UseWorkflowSubmitOptions = {
214
246
  * the default costs nothing where it would not have paid. See
215
247
  * `UploadOptions.parallel`.
216
248
  */
217
- parallel?: UploadParallel;
249
+ parallel?: UploadParallelOption;
218
250
  };
219
251
  /**
220
252
  * Start a workflow from a form, and follow the run it creates.
@@ -1,4 +1,4 @@
1
- import { t as pageBaseUrl } from "./_utils-CyzjK0gW.js";
1
+ import { t as pageBaseUrl } from "./_utils-DnQDM9Uy.js";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { errorMessage } from "@alexkroman1/aai";
4
4
  import { omitUndefined } from "@alexkroman1/aai/utils";
@@ -10,7 +10,7 @@ import { createWorkflowApiClient, isTerminal, isTerminal as isTerminal$1, readEv
10
10
  * This is the whole client half of a WORKFLOW APP: an agent whose front door is
11
11
  * a form rather than a microphone (`workflowApp()`) starts runs
12
12
  * here and watches them for the answer. It deliberately does NOT go through
13
- * `SessionCore` — there is no socket, no audio graph, and no session to resume.
13
+ * `BrowserSession` — there is no socket, no audio graph, and no session to resume.
14
14
  *
15
15
  * **The requests themselves are the SDK's now**
16
16
  * (`createWorkflowApiClient`, `@alexkroman1/aai/workflow-api`), and what is left
@@ -466,11 +466,8 @@ function useWorkflowProgress(runId, opts = {}) {
466
466
  */
467
467
  function watchRunEvents(getClient, runId, onRun, onSettled, onFallback) {
468
468
  const controller = new AbortController();
469
- let handedOver = false;
470
469
  const handOver = () => {
471
- if (handedOver || controller.signal.aborted) return;
472
- handedOver = true;
473
- onFallback();
470
+ if (!controller.signal.aborted) onFallback();
474
471
  };
475
472
  /**
476
473
  * Consume the stream. Resolves `"settled"` when the run reached a state
@@ -25,7 +25,7 @@
25
25
  * earlier version of this hook took a `cut` callback and uploaded N parts into a
26
26
  * "group" that a separate call had to seal; it worked, and every piece of it was
27
27
  * something the caller had to get right. The whole of that is replaced by the store
28
- * publishing `size` as bytes land — which `readUpload` already clamped to — so the
28
+ * publishing `size` as bytes land — which `stepReadUpload` already clamped to — so the
29
29
  * run does exactly what it does over a finished file and simply waits for windows to
30
30
  * become present.
31
31
  *
@@ -4,7 +4,7 @@
4
4
  * This is the whole client half of a WORKFLOW APP: an agent whose front door is
5
5
  * a form rather than a microphone (`workflowApp()`) starts runs
6
6
  * here and watches them for the answer. It deliberately does NOT go through
7
- * `SessionCore` — there is no socket, no audio graph, and no session to resume.
7
+ * `BrowserSession` — there is no socket, no audio graph, and no session to resume.
8
8
  *
9
9
  * **The requests themselves are the SDK's now**
10
10
  * (`createWorkflowApiClient`, `@alexkroman1/aai/workflow-api`), and what is left
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "13.3.0",
3
+ "version": "14.0.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,7 +46,7 @@
46
46
  "use-stick-to-bottom": "^1.1.6",
47
47
  "use-sync-external-store": "^1.6.0",
48
48
  "xstate": "^5.32.6",
49
- "@alexkroman1/aai": "13.3.0"
49
+ "@alexkroman1/aai": "14.0.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.3",
package/styles.css CHANGED
@@ -85,3 +85,81 @@
85
85
  -webkit-text-fill-color: transparent;
86
86
  animation: aai-shimmer 2s infinite;
87
87
  }
88
+
89
+ /*
90
+ * Reduced motion is honoured HERE, once, for everything this stylesheet reaches.
91
+ *
92
+ * Roughly nineteen INFINITE animations ship in an aai app and, until this block,
93
+ * not one of them asked: `prefers-reduced-motion` appeared zero times in the
94
+ * repository. `aai-pulse` on the console shell's live dot, `aai-bounce` on the
95
+ * thinking dots, `aai-shimmer` on every streaming tool row, Tailwind's
96
+ * `animate-pulse` on an indeterminate upload bar — all of them running for as
97
+ * long as the page is open. For a reader with a vestibular disorder that is not
98
+ * decoration, it is the reason they close the tab.
99
+ *
100
+ * **The universal selector is the leverage point, and it is the only thing that
101
+ * reaches the TEMPLATES.** Every template's `client.tsx` opens with
102
+ * `import "@alexkroman1/aai-ui/styles.css"`, and several then declare keyframes
103
+ * of their own in an inline `<style>` block: `dc-pulse`/`dc-slide-in`
104
+ * (dispatch-center), `rt-pulse`/`rt-slide-in` (retail), `crisisPulse`
105
+ * (solo-rpg), and `ic-flicker`/`ic-scanline`/`ic-pulse` (infocom-adventure,
106
+ * which flickers the whole viewport's opacity and sweeps a scanline across the
107
+ * page — the worst thing in the repo for a motion-sensitive reader). Nothing in
108
+ * this package knows those names, and no per-component fix could: it would have
109
+ * to be written again in every template, by every author who copies one, and
110
+ * the author who forgets leaves no trace in a diff. A universal selector does
111
+ * not need to know the name of the animation it is stopping. That is also what
112
+ * makes this the right FILE rather than merely a convenient one — the published
113
+ * sheet is the single artifact every client already imports, so a template opts
114
+ * in by existing, and one written next year opts in before it is written.
115
+ *
116
+ * Four properties, because motion arrives four ways:
117
+ *
118
+ * - `animation-duration` and `animation-iteration-count` — the keyframe half.
119
+ * The count carries its own weight: cut the duration alone and an `infinite`
120
+ * animation is still infinite, merely very fast, which is worse than what it
121
+ * replaced.
122
+ * - `transition-duration` — the property-change half, which no search for
123
+ * `@keyframes` would ever turn up.
124
+ * - `scroll-behavior` — the CSS half of scrolling: a host page or template
125
+ * declaring `scroll-behavior: smooth`, and any `scrollIntoView()` /
126
+ * `scrollTo()` that left the behaviour to CSS. Note what it does NOT reach.
127
+ * `AutoScroll`'s `resize: "smooth"` default is a requestAnimationFrame SPRING
128
+ * inside `use-stick-to-bottom` (which reads no media query of its own, and
129
+ * sets `scroll-behavior: auto` inline while it writes `scrollTop` anyway), so
130
+ * silencing that one is a change in `components/auto-scroll.tsx` and is still
131
+ * owed. Recording the gap beats letting the next reader assume it is covered.
132
+ *
133
+ * `0.01ms` rather than `0s` on both durations is deliberate: a zero-duration
134
+ * animation or transition fires no `animationend`/`transitionend`, so anything
135
+ * awaiting one — ours or a consumer's — waits forever. Effectively instant, and
136
+ * still an event.
137
+ *
138
+ * `!important`, and no `@layer`, is what lets this outrank the three ways motion
139
+ * is actually declared here: a Tailwind utility (`animate-pulse`), a template's
140
+ * own `<style>` block, and the inline `style={{ animation: … }}` that
141
+ * `ConsoleShell` and `MessageList` write — an author `!important` beats a normal
142
+ * inline declaration, which is the only reason those last two are covered. A
143
+ * reader who asked their OS for less motion outranks all three.
144
+ *
145
+ * Biome's `noImportantStyles` warns on all four declarations and the warnings
146
+ * STAY. The rule's argument — that `!important` reverses the cascade — is the
147
+ * property being used on purpose here, this block existing precisely to outrank
148
+ * a declaration written closer to the element. It is not suppressed either: a
149
+ * lint suppression is a counted escape hatch (`pnpm check:hatches`), so
150
+ * silencing a warning that is really a design note would spend four of them, and
151
+ * `biome check .` exits 0 on warnings, so nothing is gated on it.
152
+ *
153
+ * `src/styles-motion.test.ts` asserts this block against the stylesheet SOURCE,
154
+ * a spec having no way to render CSS.
155
+ */
156
+ @media (prefers-reduced-motion: reduce) {
157
+ *,
158
+ *::before,
159
+ *::after {
160
+ animation-duration: 0.01ms !important;
161
+ animation-iteration-count: 1 !important;
162
+ transition-duration: 0.01ms !important;
163
+ scroll-behavior: auto !important;
164
+ }
165
+ }