inline-chat-kit 0.49.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 (62) hide show
  1. package/CHANGELOG.md +2111 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1430 -0
  4. package/dist/AnswerActions/AnswerActions.d.ts +35 -0
  5. package/dist/Approval/Approval.d.ts +42 -0
  6. package/dist/Artifact/ArtifactCard.d.ts +45 -0
  7. package/dist/Artifact/ArtifactPane.d.ts +50 -0
  8. package/dist/Artifact/ChatLayout.d.ts +35 -0
  9. package/dist/Artifact/useArtifacts.d.ts +21 -0
  10. package/dist/Attachments/Attachments.d.ts +50 -0
  11. package/dist/Branch/Branch.d.ts +28 -0
  12. package/dist/Button/Button.d.ts +23 -0
  13. package/dist/ChainOfThought/ChainOfThought.d.ts +49 -0
  14. package/dist/ChatHeader/ChatHeader.d.ts +89 -0
  15. package/dist/ChatInput/AddCardsOverlay.d.ts +11 -0
  16. package/dist/ChatInput/ChatInput.d.ts +118 -0
  17. package/dist/ChatInput/HoverActionsRow.d.ts +13 -0
  18. package/dist/ChatInput/MorphGlyph.d.ts +15 -0
  19. package/dist/ChatTurnRow/ChatTurnRow.d.ts +115 -0
  20. package/dist/Chip/Chip.d.ts +7 -0
  21. package/dist/CodeBlock/CodeBlock.d.ts +24 -0
  22. package/dist/CodeBlock/grammars.d.ts +16 -0
  23. package/dist/CodeBlock/highlight.d.ts +38 -0
  24. package/dist/Context/Context.d.ts +36 -0
  25. package/dist/Conversation/Conversation.d.ts +64 -0
  26. package/dist/CustomCursor/CustomCursor.d.ts +1 -0
  27. package/dist/EmptyState/EmptyState.d.ts +24 -0
  28. package/dist/GlassButton/GlassButton.d.ts +19 -0
  29. package/dist/InlineCitation/InlineCitation.d.ts +30 -0
  30. package/dist/Loader/Loader.d.ts +23 -0
  31. package/dist/QuestionCard/QuestionCard.d.ts +32 -0
  32. package/dist/QuestionCard/parts.d.ts +84 -0
  33. package/dist/QuestionCard/types.d.ts +54 -0
  34. package/dist/QuestionGroup/QuestionGroup.d.ts +87 -0
  35. package/dist/Reasoning/Reasoning.d.ts +37 -0
  36. package/dist/ReplyThreadPopup/ReplyThreadPopup.d.ts +18 -0
  37. package/dist/Sources/Sources.d.ts +46 -0
  38. package/dist/SystemMessage/SystemMessage.d.ts +39 -0
  39. package/dist/TaskList/TaskList.d.ts +42 -0
  40. package/dist/TextHighlighter/TextHighlighter.d.ts +17 -0
  41. package/dist/Tool/Tool.d.ts +41 -0
  42. package/dist/announce/announce.d.ts +27 -0
  43. package/dist/disclosure/DisclosureBody.d.ts +22 -0
  44. package/dist/disclosure/DisclosureHeader.d.ts +42 -0
  45. package/dist/disclosure/useDisclosure.d.ts +30 -0
  46. package/dist/duration/formatDuration.d.ts +9 -0
  47. package/dist/grammars-B19jp7qm.js +3181 -0
  48. package/dist/grammars-B19jp7qm.js.map +1 -0
  49. package/dist/index.d.ts +80 -0
  50. package/dist/inline-chat-kit.css +2 -0
  51. package/dist/inline-chat-kit.js +5314 -0
  52. package/dist/inline-chat-kit.js.map +1 -0
  53. package/dist/markdown/parse.d.ts +105 -0
  54. package/dist/markdown/parseMarkdown.d.ts +47 -0
  55. package/dist/radiusCorrection/useCorrectedRadius.d.ts +24 -0
  56. package/dist/reducedMotion/reducedMotion.d.ts +3 -0
  57. package/dist/stateGlyph/StateGlyph.d.ts +23 -0
  58. package/dist/turnParts/turnParts.d.ts +156 -0
  59. package/dist/useChatTurns/useChatTurns.d.ts +127 -0
  60. package/dist/voice/useVoiceInput.d.ts +79 -0
  61. package/package.json +95 -0
  62. package/theming.md +234 -0
package/README.md ADDED
@@ -0,0 +1,1430 @@
1
+ # inline-chat-kit
2
+
3
+ An inline AI chat experience for React. The input **is** the message: when you
4
+ send, the pill you typed into morphs into the bubble that holds your text, and
5
+ the answer streams in below it. No separate composer, no jump cut.
6
+
7
+ Ships the surrounding pieces too — a turn row, a header for the conversation,
8
+ hover actions on each bubble, copy and feedback under every answer, markdown
9
+ answers you can draw on with a marker,
10
+ syntax-highlighted code blocks, a scroll container that keeps up with an
11
+ answer, and reply-in-thread popups.
12
+
13
+ ## Install
14
+
15
+ **Not on npm yet.** Build the tarball out of the repo and install that:
16
+
17
+ ```bash
18
+ git clone git@github.com:bogstebole/chat-experience.git
19
+ cd chat-experience && npm install && npm run pack:kit
20
+ ```
21
+
22
+ ```bash
23
+ npm install /path/to/chat-experience/packages/inline-chat-kit/inline-chat-kit.tgz motion lucide-react
24
+ ```
25
+
26
+ Once it is published the first command goes away and the second becomes
27
+ `npm install inline-chat-kit motion lucide-react`. Nothing else changes.
28
+
29
+ `react`, `react-dom`, `motion` and `lucide-react` are peer dependencies — the
30
+ kit uses whatever copy your app already has.
31
+
32
+ ## Use
33
+
34
+ ```tsx
35
+ import { useState } from "react";
36
+ import { ChatInput, type ChatInputState } from "inline-chat-kit";
37
+ import "inline-chat-kit/styles.css";
38
+
39
+ function Composer() {
40
+ const [value, setValue] = useState("");
41
+ const [state, setState] = useState<ChatInputState>("idle");
42
+
43
+ return (
44
+ <ChatInput
45
+ state={state}
46
+ value={value}
47
+ onChange={(v) => {
48
+ setValue(v);
49
+ setState(v ? "typing" : "idle");
50
+ }}
51
+ onSubmit={async (v) => {
52
+ setState("responding");
53
+ await send(v);
54
+ setState("resting");
55
+ }}
56
+ onStop={() => setState("resting")}
57
+ placeholder="Ask me anything…"
58
+ />
59
+ );
60
+ }
61
+ ```
62
+
63
+ The stylesheet import is required — the components are CSS Modules and the
64
+ bundled sheet carries every class they reference.
65
+
66
+ ## Wiring it to your model
67
+
68
+ `ChatInput` renders one turn. `useChatTurns` owns the conversation — the turn
69
+ list, the request in flight, and the reveal — and asks your app for the answers.
70
+
71
+ ```tsx
72
+ import { useChatTurns } from "inline-chat-kit";
73
+
74
+ const { turns, setDraft, submit, stop } = useChatTurns({
75
+ onSend: async function* (message, { signal }) {
76
+ const response = await fetch("/api/chat", {
77
+ method: "POST",
78
+ body: JSON.stringify({ message }),
79
+ signal,
80
+ });
81
+ for await (const chunk of response.body!.pipeThrough(new TextDecoderStream())) {
82
+ yield chunk;
83
+ }
84
+ },
85
+ });
86
+ ```
87
+
88
+ `onSend` may return a string, a promise of one, or an async iterable of deltas.
89
+ Return a string and the kit reveals it at a readable pace; return deltas and it
90
+ shows them as they land. `signal` aborts when the reader presses stop.
91
+
92
+ The kit never invents an answer. There is no canned fallback anywhere in the
93
+ package — if your handler returns nothing, nothing is what appears.
94
+
95
+ ### Rendering the turns
96
+
97
+ ```tsx
98
+ {turns.map((turn, i) => {
99
+ const isActive = i === turns.length - 1 && (turn.state === "idle" || turn.state === "typing");
100
+ return (
101
+ <ChatTurnRow
102
+ key={turn.id}
103
+ turn={turn}
104
+ isActiveInput={isActive}
105
+ onDraft={setDraft}
106
+ onSubmit={submit}
107
+ onStop={stop}
108
+ onEdit={beginEdit}
109
+ onCancelEdit={cancelEdit}
110
+ />
111
+ );
112
+ })}
113
+ ```
114
+
115
+ `ChatTurnRow` is already memoised, and that memo is load-bearing. The hook
116
+ leaves untouched turns referentially identical when it rewrites one of them,
117
+ but that only pays off if the rows act on it — otherwise every turn re-renders
118
+ on every frame of every answer, and the cost grows with the conversation.
119
+ Measured before the memo existed: streaming one answer produced 366 DOM
120
+ mutations inside an unrelated, already-finished turn. With it, zero.
121
+
122
+ Which is why the callbacks take the turn's id rather than being closed over per
123
+ row. Pass the hook's own functions straight through — they are stable. An arrow
124
+ created during render is not, and hands the memo a new prop every time.
125
+
126
+ If you write your own row instead, wrap it in `React.memo` and do the same.
127
+
128
+ ## The four states
129
+
130
+ `ChatInput` is fully controlled. You own `state` and drive the whole
131
+ choreography by moving between these values:
132
+
133
+ | `state` | What the input looks like |
134
+ | --- | --- |
135
+ | `idle` | Empty pill, placeholder showing |
136
+ | `typing` | Text in the pill, send glyph revealed |
137
+ | `responding` | Pill has become a glass bubble, glyph is a stop square |
138
+ | `resting` | Settled bubble, hover actions available |
139
+
140
+ ## API
141
+
142
+ ### `<ChatInput>`
143
+
144
+ | Prop | Type | Notes |
145
+ | --- | --- | --- |
146
+ | `state` | `ChatInputState` | Required. See table above |
147
+ | `value` | `string` | Required. Controlled value |
148
+ | `onChange` | `(value: string) => void` | Required |
149
+ | `onSubmit` | `(value: string) => void` | Required. Fires on Enter (Shift+Enter inserts a newline) |
150
+ | `onStop` | `() => void` | Stop button while `responding` |
151
+ | `onAdd` | `() => void` | The `+` button; opens the radial attachment fan |
152
+ | `onCopy` | `(value: string) => void` | Hover action on a resting bubble |
153
+ | `onEdit` | `(value: string) => void` | Hover action; put the turn back into `typing` |
154
+ | `onCancelEdit` | `() => void` | Paired with `isEditing` |
155
+ | `isEditing` | `boolean` | Shows save/cancel instead of send |
156
+ | `placeholder` | `string` | |
157
+ | `animationConfig` | `InlineAnimConfig` | Spring and stagger overrides — see below |
158
+ | `style` | `React.CSSProperties` | |
159
+
160
+ `ref` exposes `focus()`, `setValue(v)` and `getValue()` via `ChatInputHandle`.
161
+
162
+ ### `<ChatTurnRow>`
163
+
164
+ One turn: the question as a composer that has become a bubble, and the answer
165
+ beneath it. Not `Message`, because it is not one — the user half is a live
166
+ input that morphs into its own bubble rather than a record of what was typed.
167
+
168
+ | Prop | Type | Default | Notes |
169
+ | --- | --- | --- | --- |
170
+ | `turn` | `ChatTurn` | | Required. Straight from `useChatTurns` |
171
+ | `isActiveInput` | `boolean` | `false` | This row owns the live composer |
172
+ | `inputRef` | `Ref<ChatInputHandle>` | `null` | For `focus()` |
173
+ | `placeholder` | `string` | | |
174
+ | `animationConfig` | `InlineAnimConfig` | | Passed to the input |
175
+ | `entranceDelay` | `number` | `0` | Stagger, in seconds |
176
+ | `selectionMode` | `"marker" \| "precise"` | `"marker"` | Passed to the highlighter |
177
+ | `questionAlign` | `"end" \| "stretch"` | `"end"` | Where the composer sits — see below |
178
+ | `onDraft` | `(id, value) => void` | | |
179
+ | `onSubmit` | `(id, value) => void` | | |
180
+ | `onStop` | `() => void` | | |
181
+ | `onEdit` | `(id) => void` | | |
182
+ | `onCancelEdit` | `(id) => void` | | |
183
+ | `onCopy` | `(value) => void` | writes to the clipboard | |
184
+ | `onHighlight` | `(turnId, text) => void` | | A passage was marked |
185
+ | `onReplyInThread` | `(text, rect) => void` | | Open a thread on the marked passage |
186
+ | `onRegenerate` | `(id) => void` | | Draws the regenerate button |
187
+ | `onFeedback` | `(id, verdict) => void` | | Draws the thumbs |
188
+ | `feedback` | `"up" \| "down" \| null` | `null` | Which one is lit |
189
+ | `answerActions` | `boolean` | `true` | Leave the row out |
190
+
191
+ The live row carries **`data-active-input`**, so a page can style the composer
192
+ without knowing which turn it is. The case it exists for: a gradient fading the
193
+ conversation off the bottom edge cannot tell the answer from the box you type
194
+ in, and a washed-out composer reads as one you are not allowed to use. Lift it
195
+ over the fade:
196
+
197
+ ```css
198
+ .feed [data-active-input] { position: relative; z-index: 6; }
199
+ ```
200
+
201
+
202
+ `questionAlign` is `end` by default, because the composer is about to become
203
+ the reader's own bubble and those sit right. `stretch` fills the row instead,
204
+ which is what an *opening* composer wants: on an empty conversation it is not a
205
+ message on its way, it is the box under the openers — and a pill floating at
206
+ the right edge of a centred block reads as unrelated to the block.
207
+
208
+ Every callback is optional; a row with none of them renders and can be marked.
209
+ The row carries `id="turn-<id>"` so a host can scroll to one, and `aria-busy`
210
+ while its answer is arriving.
211
+
212
+ ### Two rules everything follows
213
+
214
+ **Surfaces nest in three steps.** A **ground** is what a group of things sits
215
+ on, a **card** is an opaque panel raised on it, and an **inset** is a row set
216
+ into the card. `--ick-ground`, `--ick-card`, `--ick-inset` — a question group,
217
+ a tool call and an approval all use the same three.
218
+
219
+ The card is paper and **opaque**, which matters more than it sounds: a
220
+ translucent panel picks up whatever it is sitting on, which is how a tool call
221
+ inside an approval came out pale green, back when the approval was tinted. In the dark the card is a lifted grey,
222
+ because `--ick-surface` there is the page itself and a card painted with it
223
+ would sink into the ground rather than sit on it — and that grey is **mixed,
224
+ not washed**. It was nine percent white for a while, which looks identical on
225
+ the page and olive over a tint. The rule held in the light,
226
+ where the card is `#fff`, and was quietly false in the dark. An inset is mixed
227
+ from the card for the same reason. A test now reads the values rather than the
228
+ wiring: an alpha channel on either is the fault, whatever the number.
229
+
230
+ The **ground** stays a wash, deliberately. It is the bottom of the stack, so
231
+ there is nothing under it to pick up but the page.
232
+
233
+ **Stacked boxes are separated by surface and gap, not by a rule.** A row is an
234
+ inset panel with space around it, and nothing is underlined. A tool call used
235
+ to draw a line under its header and a code block one under its label, which
236
+ with two sections open made three stacked rules in a component the size of a
237
+ paragraph — each saying again what the surface had already said.
238
+
239
+ **All three steps, every time.** A question is a group (ground) holding a card
240
+ holding rows; a tool call is a ground holding a card holding its input and
241
+ output; an approval is a ground holding a card holding what it is asking about. The tool used to be one box, and the box was the grey one — the
242
+ same three surfaces stacked backwards, which on its own reads fine and beside a
243
+ question card reads as a different system.
244
+
245
+ The approval had the same shape of fault from the other end: it was a tinted
246
+ box and the only thing on paper was the tool call, so the title and the buttons
247
+ sat straight on the ground. A question card puts its header *and* its Next
248
+ button on the card, and this now does the same.
249
+
250
+ **And the tint is gone**, which is worth its own paragraph. An approval *is* a
251
+ question — "may I run this" is the same species as "who else lives in the
252
+ household": the assistant asking, blocking, with an answer. It should look like
253
+ one. The tint was there on the reasoning that this is the one thing in an
254
+ answer allowed to look like a box that stops you; the reasoning is real and
255
+ colour was the wrong instrument. Everywhere else the marker is a small mark
256
+ pointed at one thing and means **this one** — the badge on the option you
257
+ picked, the stroke you drew, a citation, a source's number. A wash across a
258
+ whole box meaning "this kind of box" is a second job for the one accent, and at
259
+ 8% it was too weak to be an alarm and too strong to be nothing. In the dark it
260
+ came out olive, which is not in the palette at all.
261
+
262
+ What says "this is unanswered" is that the box has **buttons in it**, and
263
+ nothing else in a turn does. They leave when it settles, the way a question
264
+ card's rows leave when it folds — and like a folded question, the approval
265
+ keeps its paper and its shadow rather than changing into something else.
266
+
267
+ A component that is *given* a surface takes the next step down rather than
268
+ repeating one. Inside an `<Approval>` the tool call is a **row on its card** —
269
+ inset, at the row corner, no ground of its own and no shadow. Two grounds is
270
+ one more than there is depth for, and so is two cards.
271
+
272
+ A rule down the **side** is a different device and stays: `<Reasoning>` uses one
273
+ to mark an aside, and `<ChainOfThought>`'s says each step follows from the one
274
+ above it.
275
+
276
+ **Corners are concentric, on one chain.** A box's corner is the corner of the
277
+ thing inside it plus the gap between them, and there are four steps for the
278
+ whole kit:
279
+
280
+ ```
281
+ --ick-nest-inner 8 a badge, a chip, a fenced value
282
+ --ick-nest-row 16 an inset panel: an option, a field, a block of output
283
+ --ick-nest-card 24 paper, lifted off the ground
284
+ --ick-nest-ground 40 what the paper stands on
285
+
286
+ gaps: --ick-nest-pad 8 inside a card and a row, --ick-nest-ground-pad 16 around
287
+ a card on its ground
288
+ ```
289
+
290
+ Get it wrong and the two curves sit at different insets with the same radius,
291
+ leaving a crescent between them — the thing that reads as "not quite fitting"
292
+ without anybody being able to name it. It is written as `calc()` off a seed
293
+ rather than as a list of numbers, so changing a padding moves the corners with
294
+ it, and a test does the same sum for every nesting in the kit.
295
+
296
+ A component **picks a step** rather than deriving its own. Each used to derive:
297
+ a tool call was a 6px block in a 14px card on a 22px ground, beside a question's
298
+ 8 / 16 / 24 / 40. Both were internally concentric, and the two were nothing like
299
+ each other — the same three surfaces at two scales, which reads as two systems
300
+ rather than as one object holding different things. So a tool call and a
301
+ question folded shut are now the same row, to the pixel: 40 tall, 16 in from the
302
+ left, the glyph riding in the box a badge sits in. There is a story that stacks
303
+ the two on one ground, and a test that compares every step of the two chains.
304
+
305
+ A short box takes the corner it can. A row folded shut is 40px tall and a 24px
306
+ corner on it clamps to a pill — both of them, equally, which is the point.
307
+
308
+ A `<CodeBlock>` reads `--ick-code-radius`, so a box that nests one repoints it
309
+ at the step its own corner implies — the way `<Tool>` hands it the row's. It
310
+ reads `--ick-code-pad` the same way, for its inner padding: `<Tool>` takes that
311
+ down to 8 so a panel's label lands on the column its header's glyph is in, and
312
+ every section of a tool call — code or text — starts on that one column.
313
+
314
+ ### `<Tool>`
315
+
316
+ One tool call on the way to an answer: what was run, what with, what came
317
+ back.
318
+
319
+ ```tsx
320
+ <Tool
321
+ name="search_web"
322
+ state="done"
323
+ summary="3 results"
324
+ duration={412}
325
+ input={{ query: "weather in Belgrade", limit: 3 }}
326
+ output={results}
327
+ />
328
+ ```
329
+
330
+ | Prop | Type | Notes |
331
+ | --- | --- | --- |
332
+ | `name` | `string` | Set in mono: it is an identifier, not prose |
333
+ | `state` | `"pending" \| "running" \| "done" \| "error"` | Default `"done"` |
334
+ | `summary` | `ReactNode` | A sentence for what it did |
335
+ | `input` / `output` | `unknown` | See below |
336
+ | `error` | `ReactNode` | Drawn instead of the output |
337
+ | `duration` | `number` | In ms. Shown once it has finished |
338
+ | `open` / `defaultOpen` / `onOpenChange` | | Controlled or not |
339
+ | `labels` | `Partial<Record<…, string>>` | `input`, `output`, `error`, and a word per state |
340
+
341
+ **Shut by default**, because most of the time nobody cares — and **open when it
342
+ failed**, because an error nobody can see has not been reported. That is
343
+ derived from the state rather than forced by an effect, so a call that fails
344
+ later opens itself, while one somebody deliberately shut stays shut.
345
+
346
+ **What you give it decides how it is drawn.** A string is text — wrapping
347
+ `"Belgrade, 24°C"` in a fence puts it in quotes with its newlines spelled out,
348
+ which is worse than reading it. An object is JSON, in a `CodeBlock` with its
349
+ copy button. An element is left alone, so anything you want drawn some other
350
+ way you draw yourself.
351
+
352
+ The state is never carried by colour alone: the glyph changes shape, and the
353
+ row says which state it is in in words only a screen reader hears.
354
+
355
+ ### What a turn carries: `TurnPart`
356
+
357
+ An answer used to be one string, and everything the agent tier draws had
358
+ nowhere to live. A turn now carries `parts` alongside `ai`, and a `SendHandler`
359
+ streams them in among the prose:
360
+
361
+ ```tsx
362
+ const send: SendHandler = async function* (message) {
363
+ yield { kind: "reasoning", id: "r", text: "Two numbers matter here.", state: "thinking" };
364
+ yield { kind: "tool", id: "t", name: "search_web", state: "running", input: { query } };
365
+ yield { kind: "tool", id: "t", name: "search_web", state: "done", output, duration: 412 };
366
+ yield { kind: "reasoning", id: "r", state: "done" };
367
+ yield "The Higgs weighs about 125 GeV."; // a delta of the answer's prose
368
+ };
369
+ ```
370
+
371
+ A streamed item is either a **string** — appended to `ai`, as before — or a
372
+ **`TurnPart`**, merged into `turn.parts` **by its `id`**. The merge is shallow
373
+ and that is the point: send the state change on its own and the text that
374
+ arrived before it is still there. `<ChatTurnRow>` draws each kind with the
375
+ component that owns it.
376
+
377
+ | `kind` | Drawn as | Carries |
378
+ | --- | --- | --- |
379
+ | `reasoning` | `<Reasoning>` | `text`, `state`, `duration` |
380
+ | `tool` | `<Tool>` | `name`, `state`, `summary`, `input`, `output`, `error`, `duration` |
381
+ | `tasks` | `<TaskList>` | `title`, `tasks`, `collapsible` |
382
+ | `chain` | `<ChainOfThought>` | `steps`, `state`, `duration` |
383
+ | `sources` | `<Sources>` | `sources`, `title`, `collapsible` |
384
+ | `approval` | `<Approval>` | `title`, `description`, `tool`, `decision` |
385
+ | `question` | `<QuestionGroup>` | `title`, `questions`, `answers`, `activeIndex`, `collapsible` |
386
+ | `notice` | `<SystemMessage>` | `text`, `tone` |
387
+ | `artifact` | `<ArtifactCard>` | `title`, `meta`, `preview`, `lang`, `content`, `state` |
388
+
389
+ `reasoning` and `chain` are the same job at two grains — a block of prose, or
390
+ steps that follow from one another. Sending both for one stretch of thinking
391
+ says it twice.
392
+
393
+ #### Citing a source from the prose
394
+
395
+ `[^1]` in the answer is a **citation marker**, and the number is a position in
396
+ the turn's `sources` part:
397
+
398
+ ```tsx
399
+ yield { kind: "sources", id: "s", sources: [atlas, pdg] };
400
+ yield "The combined figure is 125.25 GeV[^1], to a fifth of a percent[^2].";
401
+ ```
402
+
403
+ It draws as an `<InlineCitation>` carrying the source's title, and the marker
404
+ is left out of the tokens — so a highlight drawn across the sentence, and the
405
+ text a thread quotes back, do not contain a stray `[1]`.
406
+
407
+ This is the kit's one extension to the markdown grammar. GFM spells footnotes
408
+ the same way but wants a `[^1]: …` definition in the document; a model streams
409
+ the marker and sends the list beside the text, never below it. Without it a
410
+ citation could only be written by hand in JSX — which a stream cannot do, so
411
+ the component existed and no real conversation could reach it.
412
+
413
+ Send the list **before** the prose that cites it. Sent after, the markers draw
414
+ bare and fill in later, which works and looks like a bug.
415
+
416
+ A question is answered by the person reading it, not by the stream — so
417
+ `useChatTurns` also returns **`updatePart(turnId, part)`**, and `ChatTurnRow`
418
+ reports through `onAnswerQuestion` / `onEditQuestion`. The row never keeps the
419
+ answer; the parts are yours.
420
+
421
+ Parts are cleared when a turn is answered again: the tool calls that produced
422
+ the old answer are not evidence for the new one.
423
+
424
+ ### One header for everything that folds
425
+
426
+ `<Tool>`, `<Reasoning>`, `<ChainOfThought>`, `<TaskList>` and `<Sources>` all
427
+ open the same way, so they share **`DisclosureHeader`** and **`DisclosureBody`**
428
+ — internals, not exports. The header owns the button-or-heading switch, the
429
+ `aria-expanded` / `aria-controls` wiring, the chevron, the shimmer under a
430
+ pending label, and the guard that stops a click on the row from starting a
431
+ marker on the highlighter underneath. The body owns the always-present
432
+ container and the height reveal.
433
+
434
+ Two **fits**, and the difference is real rather than decorative:
435
+
436
+ | | Fit | Why |
437
+ | --- | --- | --- |
438
+ | `Tool`, `TaskList`, `Sources` | `band` | A full-width row. It has a right edge, so the meta and the chevron are pushed to it. |
439
+ | `Reasoning`, `ChainOfThought` | `inline` | A label that hugs its own words. These sit in the flow of an answer as asides; a chevron pushed to a right edge 500px away floats alone in white space. |
440
+
441
+ A component that means something different by its label **repoints a token**
442
+ from its own root rather than passing a class in — two `.label` rules from two
443
+ stylesheets have equal specificity, and which one wins is import order.
444
+ `--ick-disclosure-label`, `-font`, `-weight`, `-size`, `-glyph`, `-meta`.
445
+
446
+ `<QuestionGroup>` folds too and does not use this, for a reason worth stating:
447
+ its shut state is a *card*, and opening it is a FLIP morph between two layouts
448
+ rather than a box growing from zero height under a row. A test holds that
449
+ exemption, and the exemption stays honest — the day it stops being a morph, it
450
+ owes an answer.
451
+
452
+ ### `<Reasoning>`
453
+
454
+ What the model worked through before it answered.
455
+
456
+ ```tsx
457
+ <Reasoning state={thinking ? "thinking" : "done"}>{thoughts}</Reasoning>
458
+ ```
459
+
460
+ | Prop | Type | Notes |
461
+ | --- | --- | --- |
462
+ | `children` | `ReactNode` | The thinking. Prose — see `Tool` for structure |
463
+ | `state` | `"thinking" \| "done"` | Default `"done"` |
464
+ | `duration` | `number` | In ms. Left out, the block times itself |
465
+ | `open` / `defaultOpen` / `onOpenChange` | | Controlled or not |
466
+ | `labels` | `Partial<Record<…, string>>` | `thinking`, `thought`, `thoughtFor` |
467
+
468
+ **Open while it thinks, folded away once the answer starts.** That is the one
469
+ detail every kit shipping this has converged on, and it is right: thinking is
470
+ worth watching while it happens and worth almost nothing afterwards — but it
471
+ has to stay reachable, because the times it matters are exactly the times the
472
+ answer looks wrong.
473
+
474
+ Folding is the block's *preference*, not something done to the reader. Open it
475
+ and it stays open, however many times the state changes underneath.
476
+
477
+ Without a `duration` it times itself, from the moment it starts thinking to the
478
+ moment it stops. Pass one when you already know — replaying a transcript, where
479
+ the thinking did not happen just now.
480
+
481
+ While it thinks the word shimmers, which is how this kit says "provisional"
482
+ everywhere. It is a real word in the button rather than a `<Loader>`: the loader
483
+ is decorative and marks itself `aria-hidden`, and hiding this one would leave a
484
+ control with nothing to call it.
485
+
486
+ ### `<Context>`
487
+
488
+ How full the context window is.
489
+
490
+ ```tsx
491
+ <ChatHeader title="Higgs boson" subtitle="14 turns">
492
+ <Context used={840_000} total={1_000_000} label={false} />
493
+ </ChatHeader>
494
+ ```
495
+
496
+ | Prop | Type | Notes |
497
+ | --- | --- | --- |
498
+ | `used` / `total` | `number` | In tokens, or whatever unit you count in |
499
+ | `warnAt` | `number` | Fraction. Default `0.8` |
500
+ | `label` | `ReactNode \| false` | The percentage by default; `false` for the ring alone |
501
+ | `labels` | `Partial<Record<…, string>>` | `name`, `of`, `tokens`, `nearlyFull` |
502
+
503
+ Small on purpose. It is a gauge, not a feature, and it earns its place for one
504
+ reason: **it is the only honest way to explain why a long conversation starts
505
+ forgetting.** Without it the forgetting looks like the model being stupid
506
+ rather than the window being full.
507
+
508
+ Which is why the warning says *what happens next* rather than only that a
509
+ number is high — `"82%"` tells somebody nothing they can act on. The whole
510
+ sentence is the meter's accessible name and its `title`, so it reaches a
511
+ pointer and a screen reader alike.
512
+
513
+ Two colours, not three: quiet until `warnAt`, then the danger colour. A gauge
514
+ with an amber in the middle makes somebody learn a scale to read a number they
515
+ can already see.
516
+
517
+ ### `<Approval>`
518
+
519
+ "It wants to do this. Is that all right?"
520
+
521
+ ```tsx
522
+ <Approval
523
+ title="Run a command in your shell"
524
+ description="It removes the generated screenshots. Nothing else is touched."
525
+ decision={decision}
526
+ onDecide={setDecision}
527
+ >
528
+ <Tool name="bash" state="pending" input={{ command: "rm -rf Shots/" }} defaultOpen />
529
+ </Approval>
530
+ ```
531
+
532
+ | Prop | Type | Notes |
533
+ | --- | --- | --- |
534
+ | `title` | `ReactNode` | What is being asked. Names the region |
535
+ | `description` | `ReactNode` | Why, or what it will touch |
536
+ | `children` | `ReactNode` | The thing itself — usually a `<Tool>` or a `<CodeBlock>` |
537
+ | `decision` | `"once" \| "always" \| "denied" \| null` | `null` while it is still asking |
538
+ | `onDecide` | `(decision) => void` | |
539
+ | `readOnly` | `boolean` | A record of a decision made elsewhere |
540
+
541
+ **Three answers, not two.** "Yes" and "yes forever" are not the same answer,
542
+ and a UI offering one button for both collects the wrong one. **Allow once is
543
+ the primary**: the narrow permission is the one that should be easiest to give,
544
+ and the standing one should cost a moment's thought. Deny sits alone on the left with
545
+ the two that say yes pushed right — a destructive choice flush against an
546
+ affirmative one is a mis-click waiting to happen — and it is first in the DOM
547
+ too, so a keyboard reaches the safe answer without tabbing past the other two.
548
+ It turns red only under the pointer, because a permanently red button is the
549
+ first thing the eye lands on.
550
+
551
+ Give it something to show. An approval with nothing under it is asking for a
552
+ signature on a blank page.
553
+
554
+ Decided, it stops being a set of buttons and becomes a record of what was
555
+ decided. Live controls under a decision already made invite a second one that
556
+ contradicts the first.
557
+
558
+ As a `TurnPart` it is `{ kind: "approval", id, title, description?, tool?,
559
+ decision? }` — data, like every part, so the tool it names is drawn for it
560
+ rather than passed in as an element. `<ChatTurnRow>` reports through
561
+ `onDecideApproval`.
562
+
563
+ ### `<Sources>` and `<InlineCitation>`
564
+
565
+ A numbered marker in the text, and the list underneath.
566
+
567
+ ```tsx
568
+ <p>
569
+ The Higgs is a point particle, so{" "}
570
+ <InlineCitation index={1} source={sources[0]} onSelect={(_, s) => setActive(s.id)}>
571
+ it has no measurable spatial extent
572
+ </InlineCitation>.
573
+ </p>
574
+ <Sources sources={sources} activeId={active} />
575
+ ```
576
+
577
+ | `Sources` | Type | Notes |
578
+ | --- | --- | --- |
579
+ | `sources` | `Source[]` | `{ id, title, url?, origin?, quote? }` |
580
+ | `title` | `ReactNode` | Defaults to `labels.title` |
581
+ | `collapsible` | `boolean` | `false` |
582
+ | `activeId` | `string \| null` | The one arrived at from a marker |
583
+ | `onSelect` | `(source, index) => void` | |
584
+
585
+ | `InlineCitation` | Type | Notes |
586
+ | --- | --- | --- |
587
+ | `index` | `number` | 1-based, and it has to match the entry's place |
588
+ | `source` | `Source` | Named in the marker's accessible name |
589
+ | `children` | `ReactNode` | The passage this citation speaks for — marked, if given |
590
+ | `onSelect` | `(index, source?) => void` | Without one the marker is not a control |
591
+
592
+ **The citation marker and the highlight marker are the same interaction seen
593
+ twice.** The kit already had a way of saying *this run of words is picked out*
594
+ — the marker somebody draws over an answer to ask about it. A citation is that
595
+ same statement made by the answer rather than by the reader, so it is drawn the
596
+ same way rather than in a second visual language nobody has learned. Give
597
+ `InlineCitation` the passage as `children` and the passage is marked; give it
598
+ none and the marker stands on its own after whatever precedes it.
599
+
600
+ Pressing a marker **marks** the entry it points at rather than scrolling to it.
601
+ The list is already under the answer, and moving the page under somebody who
602
+ clicked a marker in a sentence they were reading loses them the sentence.
603
+
604
+ The list is open by default. Sources are the difference between an answer
605
+ somebody can check and one they have to trust, and folding that away by default
606
+ says the opposite of what a citation is for.
607
+
608
+ ### `<ChainOfThought>`
609
+
610
+ How the answer was arrived at, step by step.
611
+
612
+ ```tsx
613
+ <ChainOfThought
614
+ state={thinking ? "thinking" : "done"}
615
+ duration={4200}
616
+ steps={[
617
+ { id: "a", label: "The question is about size, not mass", body: "A point particle has none." },
618
+ { id: "b", label: "Checked the measured value", body: <Tool name="search_web" … /> },
619
+ ]}
620
+ />
621
+ ```
622
+
623
+ | Prop | Type | Notes |
624
+ | --- | --- | --- |
625
+ | `steps` | `Thought[]` | `{ id, label, body?, state? }` |
626
+ | `state` | `"thinking" \| "done"` | `thinking` holds it open and narrates the running step |
627
+ | `duration` | `number` | In ms, for the whole chain |
628
+ | `open` / `defaultOpen` / `onOpenChange` | | Controlled or not |
629
+ | `labels` | `Partial<Record<…, string>>` | `through`, `step`, `steps`, `thinking` |
630
+
631
+ **Three components in this kit draw a sequence, and the line between them is
632
+ the only reason there are three:**
633
+
634
+ | | What it is | Shape |
635
+ | --- | --- | --- |
636
+ | `Reasoning` | The model talking to itself | Prose, unstructured |
637
+ | `TaskList` | A plan | Known up front, fixed order, items change state |
638
+ | `ChainOfThought` | A derivation | Grows; each step follows from the one above |
639
+
640
+ That last word is what the line down the glyph column draws. A task list has no
641
+ line between its items, because a plan's items do not follow from each other —
642
+ they are a set, in an order somebody chose.
643
+
644
+ A step's `body` is whatever you put there: prose, or a `<Tool>` if the step was
645
+ a tool call. While the chain is thinking its header carries the running step's
646
+ label rather than a count — that is the question somebody watching is asking,
647
+ and the reason to look at a folded chain at all.
648
+
649
+ ### `<TaskList>`
650
+
651
+ What the agent means to do, what it is doing, and what it has finished.
652
+
653
+ ```tsx
654
+ <TaskList
655
+ title="Plan"
656
+ collapsible
657
+ tasks={[
658
+ { id: "read", label: "Read the care plan", state: "done", detail: "42 lines" },
659
+ { id: "gaps", label: "Find the gaps in the weekly cover", state: "running" },
660
+ { id: "draft", label: "Draft the questions for the family" },
661
+ ]}
662
+ />
663
+ ```
664
+
665
+ | Prop | Type | Notes |
666
+ | --- | --- | --- |
667
+ | `tasks` | `Task[]` | `{ id, label, state?, detail? }` |
668
+ | `title` | `ReactNode` | Gives it a row to fold into. Without one there is no row |
669
+ | `collapsible` | `boolean` | `false` |
670
+ | `open` / `defaultOpen` / `onOpenChange` | | Controlled or not |
671
+ | `labels` | `Partial<Record<…, string>>` | The four states, and `progress` (`"{done} of {total}"`) |
672
+
673
+ A task is `pending`, `running`, `done` or `error` — the same four as a tool
674
+ call, and the same glyphs, because they are the same four states and naming
675
+ them differently in two places buys nothing.
676
+
677
+ **The order never changes.** A list that sorted itself as work progressed would
678
+ move the line somebody is reading out from under them, and the sequence is half
679
+ of what the list is saying — these steps, in this order. Only the glyphs change.
680
+
681
+ `collapsible` folds it away once every task is done: a plan is worth watching
682
+ while it runs and worth little afterwards. Anybody reading it can overrule
683
+ that, in either direction, for good.
684
+
685
+ The one being worked on carries `aria-current="step"`, so a screen reader can
686
+ jump to "where is it up to" rather than counting down the list.
687
+
688
+ ### `<QuestionCard>` and `<QuestionGroup>`
689
+
690
+ A structured question inside a conversation: the assistant asks something with
691
+ a shape to it, and the answer is picked or typed rather than written out.
692
+
693
+ ```tsx
694
+ <QuestionGroup
695
+ id="about-them"
696
+ title="Setting up the run"
697
+ questions={questions}
698
+ answers={answers}
699
+ activeIndex={activeIndex}
700
+ collapsible={done && questions.length >= FOLDABLE_FROM}
701
+ onCommit={(id, answer) => setAnswers((all) => ({ ...all, [id]: answer }))}
702
+ onEdit={setEditing}
703
+ />
704
+ ```
705
+
706
+ A `Question` is one of three shapes — `inputs` (type something), `single` (pick
707
+ one), `multi` (pick several, optionally with a "something else" field). Each
708
+ carries a `shortTitle`, which is what it is called once it folds into a row.
709
+
710
+ A card is in one of three states, and morphs between them:
711
+
712
+ | state | what it is |
713
+ | --- | --- |
714
+ | `upcoming` | one dim row, waiting its turn |
715
+ | `active` | the question, open, being answered |
716
+ | `collapsed` | one row: the short title, the answer as chips, and a way back in |
717
+
718
+ `QuestionGroup` holds a step's worth. **`title` names the step, at the top**,
719
+ and is also the fold control when there is one — which is the point of putting
720
+ it there. A control at the top does not move when the body under it changes, so
721
+ folding stops being two shapes swapping places and becomes one header with two
722
+ bodies. Left out on a foldable group, the count stands in so the control still
723
+ has a name; left out on one that cannot fold, there is no header at all.
724
+
725
+ Folded, the body is a single summary row — not a peek at the list, because a
726
+ peek costs more height than the answers it shows.
727
+
728
+ Two decisions carried over from the original, both worth keeping: `single`
729
+ waits a beat after a choice before committing, or the card is gone before
730
+ anyone sees what they picked; and the "something else" row is a `<label>`, not
731
+ a button, because an input inside a button is not reliably focusable.
732
+
733
+ Two changed. The collapsed row is a single `<button>` naming what it does
734
+ (`"Edit answer: Household"`) rather than a click handler on a `<div>` with
735
+ another button inside it — that could be clicked but not tabbed to. And the
736
+ letter badges are `aria-hidden`: the letter is a visual index, and left in the
737
+ tree it turns a field called "Their name" into one called "a Their name".
738
+
739
+ #### Composing a fourth kind of question
740
+
741
+ Three shapes are not the only three. The parts a card is built from are
742
+ exported, so a shape the kit does not ship is a composition rather than a fork
743
+ — and it arrives already wearing the same tokens, focus behaviour and ARIA as
744
+ the ones that do.
745
+
746
+ ```tsx
747
+ <QuestionShell
748
+ number={3}
749
+ title="How soon do they need this?"
750
+ subtitle="Roughly is fine"
751
+ footer={<Button variant="secondary" size="m" onClick={commit}>Next</Button>}
752
+ >
753
+ {levels.map((level, i) => (
754
+ <QuestionOptionRow
755
+ key={level.id}
756
+ letter={"abc"[i]}
757
+ title={level.title}
758
+ description={level.description}
759
+ selected={picked === level.id}
760
+ onClick={() => setPicked(level.id)}
761
+ />
762
+ ))}
763
+ <QuestionFieldRow
764
+ letter="d"
765
+ label="Anything we should know"
766
+ value={note}
767
+ onChange={setNote}
768
+ />
769
+ </QuestionShell>
770
+ ```
771
+
772
+ | Part | What it is |
773
+ | --- | --- |
774
+ | `QuestionShell` | The card: header, the column the rows sit in, a right-aligned footer |
775
+ | `QuestionOptionRow` | A row that picks. A `<button>` with `aria-pressed` |
776
+ | `QuestionFieldRow` | A row that is typed into. A `<label>`, so the whole row focuses the input |
777
+ | `QuestionOtherRow` | The "something else" row: reads as an option, is a text field |
778
+ | `QuestionBadge` | The 24px square with the letter or the number |
779
+
780
+ Each takes a `className` that is **added** to its own rather than replacing it,
781
+ spreads the rest of its props onto the element it ends in, and forwards its ref
782
+ to the thing worth having one for — the input, in the two rows that have one.
783
+ On the two rows that take one, `className` styles the row and everything else
784
+ goes to the input.
785
+
786
+ `QuestionShell` paints the card — background, radius, shadow — unless you pass
787
+ `card={false}`, which is what `QuestionCard` does: the box that morphs between
788
+ the three states is its own, and two would nest.
789
+
790
+ `letter` is optional on all three rows. Left out, the row starts at its title.
791
+ `onEnter` fires on Enter unless your own `onKeyDown` called `preventDefault`
792
+ first, which is how a handler says it has dealt with the key.
793
+
794
+ ### `<EmptyState>` and `<Loader>`
795
+
796
+ The two ends of a conversation that has not happened yet: what is on screen
797
+ before anybody asks, and the gap between sending and the first word.
798
+
799
+ | `EmptyState` | Type | Notes |
800
+ | --- | --- | --- |
801
+ | `icon` / `title` / `description` | `ReactNode` | Each optional; nothing is drawn in place of what you leave out |
802
+ | `suggestions` | `string[]` | Openers |
803
+ | `onSuggestion` | `(text: string) => void` | Without it, no openers are drawn |
804
+ | `suggestionsLabel` | `string` | Names the group. Default `"Suggestions"` |
805
+
806
+ `title` renders as text, not a heading. This sits inside a conversation the
807
+ host already owns, and claiming a level in their document is not ours to do —
808
+ pass `<h2>…</h2>` if it should be one.
809
+
810
+ | `Loader` | Type | Notes |
811
+ | --- | --- | --- |
812
+ | `variant` | `"dots" \| "shimmer"` | Default `"dots"` |
813
+ | `children` | `ReactNode` | The words the shimmer runs through |
814
+ | `label` | `string \| null` | Default `null` — see below |
815
+
816
+ The loader is **silent by default**. `useChatTurns` already announces that a
817
+ response is coming, and a second live region saying the same thing means
818
+ hearing it twice. Pass `label` only when nothing else is speaking for you.
819
+
820
+ `ChatTurnRow` shows it between the question being sent and the first word
821
+ landing, so a sent question is never a blank space.
822
+
823
+ ### `<AnswerActions>`
824
+
825
+ Copy, regenerate and a verdict, under a settled answer. `ChatTurnRow` renders
826
+ it for you; it is exported for anyone composing their own row.
827
+
828
+ | Prop | Type | Default | Notes |
829
+ | --- | --- | --- | --- |
830
+ | `text` | `string` | | Required. What copy takes |
831
+ | `onCopy` | `(text: string) => void` | writes to the clipboard | |
832
+ | `onRegenerate` | `() => void` | | Omit and the button is not drawn |
833
+ | `onFeedback` | `(verdict: "up" \| "down" \| null) => void` | | Omit and the thumbs are not drawn |
834
+ | `feedback` | `"up" \| "down" \| null` | `null` | Controlled |
835
+ | `busy` | `boolean` | `false` | While regenerating |
836
+ | `reveal` | `boolean` | `false` | Invisible until hovered or focused |
837
+ | `labels` | `Partial<Record<…, string>>` | | |
838
+ | `children` | `ReactNode` | | Your own controls, after the built-in ones |
839
+
840
+ Only what has somewhere to report is drawn: no `onRegenerate`, no regenerate
841
+ button. A control that calls nothing looks like a feature and behaves like a
842
+ dead end.
843
+
844
+ Pressing the verdict already given reports `null` — that is how somebody takes
845
+ it back.
846
+
847
+ Inside a turn they appear when the answer **settles**. Offering to copy a
848
+ half-written answer, or to rate one, is offering the wrong thing.
849
+
850
+ ### `<Conversation>`
851
+
852
+ The scroll container. It keeps up with an answer as it arrives and stops the
853
+ instant the reader scrolls away, with a button offering the way back.
854
+
855
+ ```tsx
856
+ <Conversation>
857
+ {turns.map((turn) => <ChatTurnRow key={turn.id} turn={turn} … />)}
858
+ </Conversation>
859
+ ```
860
+
861
+ | Prop | Type | Default | Notes |
862
+ | --- | --- | --- | --- |
863
+ | `anchorId` | `string` | | Hold this element at the top instead of following the end |
864
+ | `anchorOffset` | `number` | `0` | How far below the top edge it sits — leave room for a fixed header |
865
+ | `threshold` | `number` | `64` | How close still counts as following |
866
+ | `scrollButton` | `boolean` | `true` | The way back |
867
+ | `scrollButtonLabel` | `string` | `"Jump to the latest"` | |
868
+ | `follow` | `boolean` | `true` | `false` makes it a plain scroll container |
869
+ | `className` | `string` | | Goes on the root, which is the box you lay out |
870
+ | `viewportClassName` | `string` | | Goes on the element that scrolls — padding belongs here |
871
+
872
+ `ref` is forwarded to the **viewport**, not the root: anyone reaching for a ref
873
+ here wants to scroll something, and the root does not scroll.
874
+
875
+ Three things worth knowing.
876
+
877
+ **`anchorId` is the one that changes the feel.** Without it the view follows
878
+ the end of the content, which is what a chat that stacks downwards wants. With
879
+ it, the named element is brought to the top and *held* there while the answer
880
+ grows underneath — so a reader sees their question and its answer, and not the
881
+ whole conversation pushed up from below with the composer ending past the fold.
882
+ Point it at the turn that was just submitted:
883
+
884
+ ```tsx
885
+ const [anchor, setAnchor] = useState<string | null>(null);
886
+ const send = (id: string, value: string) => { setAnchor(id); submit(id, value); };
887
+
888
+ <Conversation anchorId={anchor ? `turn-${anchor}` : undefined} anchorOffset={100}>
889
+ ```
890
+
891
+ This needs room to scroll into — an element cannot be brought to the top of a
892
+ container that ends just below it. A large `padding-bottom` on the viewport is
893
+ what provides it.
894
+
895
+ Without an anchor it follows the **end of the content**, not the bottom of the
896
+ container, and those are only the same when nothing is padded below. A chat
897
+ with a screen-height pad beneath it would otherwise scroll the answer off the
898
+ screen to sit in front of a blank space.
899
+
900
+ And it reads the reader's intent from the **input**, not from the scroll event.
901
+ A component that watches scrolling cannot tell its own from theirs, and ends up
902
+ either dragging them back down mid-sentence or never following at all. A wheel
903
+ upwards, a page key, a drag away from the end: any of those and it lets go.
904
+
905
+ ### `<ArtifactCard>`, `<ArtifactPane>`, `<ChatLayout>`, `useArtifacts`
906
+
907
+ What the answer produced, when it is bigger than the answer — a plan, a
908
+ document, a file. Three pieces and one decision.
909
+
910
+ ```tsx
911
+ const artifacts = useArtifacts();
912
+
913
+ <ChatLayout
914
+ pane={({ narrow }) =>
915
+ artifacts.openId ? (
916
+ <ArtifactPane title="5k training plan" meta="8 weeks" modal={narrow} onClose={artifacts.close}>
917
+ <YourPlan />
918
+ </ArtifactPane>
919
+ ) : null
920
+ }
921
+ >
922
+ <Conversation>
923
+ <ChatTurnRow turn={turn} openArtifactId={artifacts.openId} onOpenArtifact={(_, id) => artifacts.toggle(id)} />
924
+ </Conversation>
925
+ </ChatLayout>
926
+ ```
927
+
928
+ **The kit decides where the pane goes.** On the right, with the conversation
929
+ making room; below `<ChatLayout>`'s width it covers the conversation instead. A
930
+ preview pane is one of the few patterns every AI chat now has, and the worth of
931
+ a pattern is that it is the same every time — ask for a plan, get a card, press
932
+ it, the plan opens on the right. A kit that let each host place it would be
933
+ shipping four chats that behave differently and calling it flexibility.
934
+
935
+ **The kit does not decide what is in it.** `<ArtifactPane>` takes children: a
936
+ plan, a table, a document, a diagram. That is the part that actually differs
937
+ between products, and the only part.
938
+
939
+ | Prop | Type | Notes |
940
+ | --- | --- | --- |
941
+ | `<ArtifactCard>` `id` | `string` | What the pane opens. The card and the pane share nothing else |
942
+ | `title`, `meta` | `ReactNode` | |
943
+ | `kind` | `"code" \| "text"` | How the preview is drawn |
944
+ | `content` | `string` | Absent while it is being written, which is how one usually arrives |
945
+ | `lines` | `number` | How much shows before the cut. Default 8 |
946
+ | `open` | `boolean` | True while its pane is the one on screen |
947
+ | `onOpen` | `(id: string) => void` | Without one the card is a record, not a control |
948
+ | `<ArtifactPane>` `modal` | `boolean` | See below |
949
+ | `<ChatLayout>` `pane` | `({ narrow }) => ReactNode` | |
950
+
951
+ **`modal` is the one prop that changes behaviour, and it is not about
952
+ position.** Covering the conversation changes what the pane *is*: focus has to
953
+ be held inside it and Escape has to close it, because there is nothing usable
954
+ behind it. Beside the conversation both would be wrong — trapping focus would
955
+ lock a reader out of the chat they are still reading. `<ChatLayout>` sets it
956
+ from its own width, so you only pass it yourself if you are placing the pane
957
+ without one.
958
+
959
+ That is the part worth having in a library rather than the box. On open, focus
960
+ moves to the pane's heading — not into its first control, which would skip what
961
+ the thing is — and it is **not** trapped unless the pane is covering the chat.
962
+
963
+ ### `<SystemMessage>`
964
+
965
+ The conversation saying something about itself — not the reader, not the agent.
966
+
967
+ ```tsx
968
+ <SystemMessage>The oldest messages are dropping out of the window.</SystemMessage>
969
+ <SystemMessage tone="danger">Lost the connection. Nothing since your last message was saved.</SystemMessage>
970
+ ```
971
+
972
+ | Prop | Type | Notes |
973
+ | --- | --- | --- |
974
+ | `children` | `ReactNode` | The sentence. Say what happens next, not that a state changed |
975
+ | `tone` | `"notice" \| "danger"` | Two, because `<Context>` already settled that three is one too many |
976
+
977
+ Reachable from a stream as well, as a part of kind `notice`, which is the only
978
+ way anything gets into a conversation without the host placing it by hand.
979
+
980
+ **It has no icon**, and that is the component rather than an omission from it.
981
+ Every picture in this kit carries a state the words beside it also carry —
982
+ queued, running, failed, allowed — so a reader the picture does not reach loses
983
+ nothing. An icon here would say "something is being announced" beside a
984
+ sentence announcing it.
985
+
986
+ **No dismiss and no action.** It is a line of the transcript, not a toast:
987
+ dismissing one would be editing what happened. A host that wants a button under
988
+ it composes one, which costs them a line and costs this component an API.
989
+
990
+ **No live region.** The kit has one — see `announce` — written to on a later
991
+ tick. A second one says everything twice, which is the fault that region exists
992
+ to have fixed. A host streaming a `notice` should announce it.
993
+
994
+ ### `<Branch>`
995
+
996
+ Which answer you are looking at, and how to reach the others.
997
+
998
+ ```tsx
999
+ <Branch total={turn.versions?.length ?? 0} index={turn.versionIndex ?? 0} onSelect={show} />
1000
+ ```
1001
+
1002
+ | Prop | Type | Notes |
1003
+ | --- | --- | --- |
1004
+ | `total` | `number` | How many answers this turn has had |
1005
+ | `index` | `number` | Which is on screen, from zero. Out of range is clamped |
1006
+ | `onSelect` | `(index: number) => void` | |
1007
+ | `labels` | `Partial<{ previous, next, position }>` | `position` fills in `{index}` and `{total}` |
1008
+
1009
+ **It draws nothing at all when `total` is under two.** A control reading "1 of
1010
+ 1" offers to take you nowhere, so a turn answered once looks exactly as it did
1011
+ before there were versions.
1012
+
1013
+ **Regenerating keeps the old answer.** It used to overwrite `ai` and `parts`,
1014
+ which threw away the answer being compared against — and comparing is the only
1015
+ reason to press regenerate. A turn now carries `versions: TurnVersion[]` and
1016
+ `versionIndex`, `useChatTurns` returns **`showVersion(id, index)`**, and
1017
+ `<ChatTurnRow>` draws the control beside the answer actions when you give it
1018
+ `onShowVersion`.
1019
+
1020
+ `ai` and `parts` stay what they were — the answer on screen. They are kept
1021
+ equal to `versions[versionIndex]` by one function that both writers (the
1022
+ batched stream flush and the turn patcher) go through; two writers for one fact
1023
+ is how they drift, and a test compares them after a stream.
1024
+
1025
+ Both fields are optional, so a turn a host built by hand still renders — the
1026
+ same tolerance `parts` has.
1027
+
1028
+ ### `<Attachments>`
1029
+
1030
+ What goes along with a message.
1031
+
1032
+ ```tsx
1033
+ <Attachments attachments={files} onRemove={(id) => drop(id)} />
1034
+ ```
1035
+
1036
+ | Prop | Type | Notes |
1037
+ | --- | --- | --- |
1038
+ | `attachments` | `Attachment[]` | Required. Nothing to show draws nothing at all |
1039
+ | `onRemove` | `(id: string) => void` | Leave it out and they are a record rather than a control |
1040
+ | `labels` | `Partial<{ remove }>` | |
1041
+
1042
+ An `Attachment` is `{ id, name, url?, type?, size? }`. An **image with a `url`
1043
+ shows itself**; everything else shows a glyph, its name and its size — a
1044
+ thumbnail of a PDF at 64px is a grey rectangle with a corner turned down and
1045
+ tells you less than the filename does.
1046
+
1047
+ The same row draws them in the composer and under the message once it has been
1048
+ sent. `onRemove` is the whole difference between a control and a record, and
1049
+ `<ChatInput>` stops passing one the moment the message goes.
1050
+
1051
+ **In the composer.** `<ChatInput>` opens the picker from its `+` and keeps what
1052
+ comes back, unless you take over:
1053
+
1054
+ | Prop | Type | Notes |
1055
+ | --- | --- | --- |
1056
+ | `attachments` | `Attachment[]` | Controlled. Left out, the composer keeps its own |
1057
+ | `onAttach` | `(files: File[]) => void` | Given, the composer hands the files over instead of attaching them — for uploading first and attaching the URL you get back |
1058
+ | `onRemoveAttachment` | `(id: string) => void` | |
1059
+ | `accept` | `string` | Passed to the picker. Defaults to `image/*` |
1060
+ | `multiple` | `boolean` | Off |
1061
+
1062
+ `onSubmit` is `(value, attachments)`. That second argument is the point: before
1063
+ it, a file could be picked, shown in the composer, and then quietly dropped on
1064
+ send — the message went and the picture did not, which is worse than not
1065
+ offering the button. A picture on its own is a message, so an empty box with
1066
+ something attached still sends.
1067
+
1068
+ The object URLs the composer makes are **revoked** — when one is removed, when
1069
+ one replaces another, and on unmount. `createObjectURL` pins the file in memory
1070
+ until it is; nothing was revoking, so attaching and removing ten times leaked
1071
+ ten of them.
1072
+
1073
+ They arrive at your `SendHandler` on the context: `(message, { signal, turnId,
1074
+ attachments })`. And they live on the turn as `turn.attachments`, which is what
1075
+ `<ChatTurnRow>` hands back to the composer so a sent message still shows what
1076
+ went with it.
1077
+
1078
+ ### `<CodeBlock>`
1079
+
1080
+ A fenced block: the language, a copy button, and code that scrolls sideways
1081
+ rather than widening the answer. The markdown renderer uses it for every
1082
+ fence, and it is exported for use on its own.
1083
+
1084
+ | Prop | Type | Default | Notes |
1085
+ | --- | --- | --- | --- |
1086
+ | `code` | `string` | | Required |
1087
+ | `lang` | `string` | | The fence's language. Unknown ones render unhighlighted |
1088
+ | `label` | `string \| false` | the language | `false` drops the caption |
1089
+ | `copyable` | `boolean` | `true` | `false` with `label={false}` removes the bar entirely |
1090
+ | `onCopy` | `(code: string) => void` | writes to the clipboard | |
1091
+ | `copiedFor` | `number` | `1600` | How long the button stays confirmed, in ms |
1092
+
1093
+ **Eleven languages** are registered: TypeScript, JavaScript, HTML/XML, CSS,
1094
+ JSON, YAML, Bash, Python, SQL, Markdown and diff — plus the aliases people
1095
+ actually type (`ts`, `tsx`, `js`, `sh`, `py`, `yml`, …). `lowlight/common` is
1096
+ 37 languages and 51.6 KB gzipped; these cost half of that and cover what a chat
1097
+ actually shows. A language outside the list renders unhighlighted rather than
1098
+ throwing.
1099
+
1100
+ **And they are not in your bundle until something needs them.** The grammars
1101
+ are a **25 kB gzip chunk behind a dynamic `import()`** — most conversations
1102
+ never show a fence, and 25 kB is 42% of what the package used to weigh. The
1103
+ first block on a page paints its code plain and colours in when the chunk
1104
+ lands; every block after that is coloured on its first paint, because the
1105
+ loaded highlighter is kept. There is no layout shift either way: the text is
1106
+ the same, only the colour arrives late.
1107
+
1108
+ If that trade is wrong for you — a docs tool where every answer is code —
1109
+ `import { loadHighlighter } from "inline-chat-kit"` and call it once at
1110
+ start-up. It is idempotent and concurrent callers share the one fetch.
1111
+ `canHighlight(lang)` answers whether a language is one of the eleven **without
1112
+ loading anything**, for a caller deciding what to draw.
1113
+
1114
+ The scheme is ink at four weights rather than a syntax palette — this kit is
1115
+ ink, paper and one acid yellow, and twelve colours dropped into it read as
1116
+ somebody else's component. Six tokens (`--ick-code-keyword`, `-string`,
1117
+ `-comment`, `-name`, `-number`, `-attr`) turn it into whatever palette you
1118
+ already own.
1119
+
1120
+ A block is **not markable**. Preformatted text split into word tokens stops
1121
+ being preformatted, so the highlighter skips it; copy is what people want from
1122
+ code anyway.
1123
+
1124
+ ### `<ChatHeader>`
1125
+
1126
+ The chrome above the conversation: who you are talking to, what about, and the
1127
+ handful of things you can do to the whole thread.
1128
+
1129
+ ```tsx
1130
+ <ChatHeader
1131
+ title={firstQuestion}
1132
+ subtitle="Claude Opus 5"
1133
+ backHref="/"
1134
+ actions={[
1135
+ { id: "bookmarks", label: "Saved highlights", icon: <Bookmark size={16} />, count: 3, pinned: true },
1136
+ { id: "share", label: "Share", icon: <Share2 size={16} />, onClick: share },
1137
+ ]}
1138
+ >
1139
+ <YourModelPicker />
1140
+ </ChatHeader>
1141
+ ```
1142
+
1143
+ | Prop | Type | Default | Notes |
1144
+ | --- | --- | --- | --- |
1145
+ | `title` | `ReactNode` | | What the conversation is about |
1146
+ | `subtitle` | `ReactNode` | | Second line — the model, a count, a state |
1147
+ | `avatar` | `ReactNode` | | Drawn before the title |
1148
+ | `headingLevel` | `1`–`6` \| `false` | `2` | The level belongs to your document |
1149
+ | `truncate` | `boolean` | `true` | Long titles get an ellipsis, not a second line |
1150
+ | `onBack` | `() => void` | | Renders a back button |
1151
+ | `backHref` | `string` | | Renders a back link instead |
1152
+ | `backLabel` | `string` | `"Back"` | |
1153
+ | `actions` | `ChatHeaderAction[]` | `[]` | The managed actions. These are what collapse |
1154
+ | `overflowLabel` | `string` | `"More actions"` | |
1155
+ | `variant` | `"plain" \| "glass" \| "bordered"` | `"plain"` | |
1156
+ | `size` | `"s" \| "m" \| "l"` | `"m"` | 40 / 48 / 56px |
1157
+ | `align` | `"start" \| "center"` | `"start"` | `center` is the native arrangement |
1158
+ | `sticky` | `boolean` | `false` | |
1159
+ | `elevateOnScroll` | `boolean` | `sticky` | Border and backdrop appear once content scrolls under |
1160
+ | `collapseActionsAt` | `number \| false` | `520` | Header width, not viewport width |
1161
+ | `landmark` | `boolean` | `true` | `false` inside a panel, where `banner` would be a lie |
1162
+ | `children` | `ReactNode` | | Anything the kit should not manage. Never collapses |
1163
+
1164
+ Each action is `{ id, label, icon, onClick?, href?, count?, active?, disabled?, pinned? }`.
1165
+ `label` is required because an icon has no name of its own, and `count` is
1166
+ folded into that name — the badge is decorative, so a reader who cannot see it
1167
+ still hears "Saved highlights, 3".
1168
+
1169
+ Actions are described rather than handed over as children for one reason:
1170
+ `collapseActionsAt` folds them into a menu when the header is narrow, and a
1171
+ header cannot summarise children it cannot read. Anything with no icon-and-label
1172
+ shape — a segmented control, a model picker — goes in as `children` instead and
1173
+ stays put.
1174
+
1175
+ ### `<TextHighlighter>`
1176
+
1177
+ Wraps streamed text and lets the reader mark it up.
1178
+
1179
+ **The text is markdown.** Headings, emphasis, links, lists, blockquotes, code,
1180
+ tables and strikethrough (GFM) all render. Raw HTML in the input is dropped
1181
+ rather than rendered — model output is untrusted, and there is no version of
1182
+ injecting it into the host's page that is worth the surface it opens.
1183
+
1184
+ The marker does not care about any of it. Internally the words stay a **flat
1185
+ array of tokens addressed by index**, and markdown only decides which element
1186
+ each token is drawn inside — so a stroke that starts in plain text and ends
1187
+ inside `**bold**` is one run of indices like any other. Fenced code blocks are
1188
+ the exception: they are preformatted, so they are not tokenised and cannot be
1189
+ marked.
1190
+
1191
+ Parsing costs about 0.9 ms per 1000 characters, and runs once per frame while
1192
+ an answer streams. Fine for an ordinary answer; see the roadmap for where it
1193
+ stops being fine.
1194
+
1195
+ | Prop | Type | Notes |
1196
+ | --- | --- | --- |
1197
+ | `text` | `string` | The text to render |
1198
+ | `selectionMode` | `"marker" \| "precise"` | Freeform drawn marker, or native char-level selection |
1199
+ | `onHighlightComplete` | `(text: string) => void` | Fires when a highlight is drawn |
1200
+ | `onReplyInThread` | `(text: string, rect: DOMRect) => void` | Reader chose "reply in thread" |
1201
+
1202
+ It renders block-level elements for the marker overlay, so give it a `<div>`
1203
+ wrapper, not a `<p>` — a `<div>` inside a `<p>` is invalid HTML and trips a
1204
+ hydration mismatch under SSR.
1205
+
1206
+ ### `<ReplyThreadPopup>`
1207
+
1208
+ A focused sub-conversation anchored to a highlighted passage.
1209
+
1210
+ | Prop | Type | Notes |
1211
+ | --- | --- | --- |
1212
+ | `activeReply` | `{ text: string; rect: DOMRect }` | Pass what `onReplyInThread` gave you |
1213
+ | `onClose` | `() => void` | |
1214
+ | `onSave` | `() => void` | |
1215
+ | `onSendMessage` | `(message, quotedText) => Promise<string> \| string` | **Provide this.** Without it the popup streams placeholder copy |
1216
+
1217
+ ### `<CustomCursor>`
1218
+
1219
+ Optional. Swaps the pointer for a marker or text caret over elements carrying
1220
+ `data-cursor="marker"` / `data-cursor="text"`. Mount once, near the root, and
1221
+ hide the native cursor yourself:
1222
+
1223
+ ```css
1224
+ * { cursor: none; }
1225
+ ```
1226
+
1227
+ ### Buttons
1228
+
1229
+ `Button` is one component in four materials — `primary`, `secondary`, `ghost`,
1230
+ `glass` — across five sizes (`xs` `s` `m` `l` `xl`, 24 through 48px). It takes
1231
+ `icon`, `iconRight` and `loading`. Icon-only needs an `aria-label`.
1232
+
1233
+ `GlassButton` is a deprecated wrapper around `<Button variant="glass">`, kept so
1234
+ existing call sites keep working. Its `s` / `m` / `l` map to `m` / `l` / `xl`.
1235
+
1236
+ Neither takes a dark-mode prop: the theme is a token swap on an ancestor. See
1237
+ [theming.md](./theming.md).
1238
+
1239
+ ## Theming
1240
+
1241
+ The kit reads CSS custom properties, all prefixed `--ick-`. That is the whole
1242
+ interface: no provider, no build step. They sit in a `@layer inline-chat-kit`
1243
+ cascade layer, so **any unlayered rule in your app wins** regardless of import
1244
+ order.
1245
+
1246
+ Colours are built from channel triplets, so a handful of lines moves everything
1247
+ derived from them:
1248
+
1249
+ ```css
1250
+ :root {
1251
+ --ick-ink-rgb: 20 20 24; /* text, hovers, borders */
1252
+ --ick-paper-rgb: 253 252 250; /* surfaces, and the light side of glass */
1253
+ --ick-marker-rgb: 120 200 255; /* the highlighter */
1254
+ --ick-font-sans: "Inter", system-ui, sans-serif;
1255
+ --ick-radius-xl: 12px;
1256
+ }
1257
+ ```
1258
+
1259
+ Note the spaces rather than commas — they are used as
1260
+ `rgb(var(--ick-ink-rgb) / 0.6)`.
1261
+
1262
+ If a font loader hands you a CSS variable, point the kit at it:
1263
+
1264
+ ```css
1265
+ :root {
1266
+ --ick-font-sans: var(--font-geist-sans);
1267
+ --ick-font-mono: var(--font-geist-mono);
1268
+ }
1269
+ ```
1270
+
1271
+ **Dark** follows `prefers-color-scheme` on its own. Set `data-theme="light"` or
1272
+ `data-theme="dark"` on the root element to pin it; `.light` and `.dark` work
1273
+ too, for projects that already have them.
1274
+
1275
+ Overrides go on `:root` for the whole page, or on any element with
1276
+ `class="ick-theme"` for a subtree — the class is what makes the derived tokens
1277
+ recompute there.
1278
+
1279
+ [theming.md](./theming.md) has the rest — the per-component tokens, how to
1280
+ adjust dark without touching light, and the two values that deliberately do not
1281
+ follow the theme. The complete list, resolved live, is the first entry in
1282
+ Storybook.
1283
+
1284
+ ## Tuning the motion
1285
+
1286
+ `animationConfig` takes the full `InlineAnimConfig` shape — bubble and button
1287
+ springs, ripple timing, the wrap thresholds that decide when a growing input
1288
+ breaks to a new line, action-row stagger, and the radial fan geometry for the
1289
+ `+` menu. Start from the defaults and override what you need:
1290
+
1291
+ ```tsx
1292
+ import { ChatInput, defaultInlineAnimConfig } from "inline-chat-kit";
1293
+
1294
+ <ChatInput
1295
+ {...props}
1296
+ animationConfig={{
1297
+ ...defaultInlineAnimConfig,
1298
+ bubble: { stiffness: 520, damping: 24, mass: 0.2 },
1299
+ }}
1300
+ />;
1301
+ ```
1302
+
1303
+ ## Next.js
1304
+
1305
+ Works in the App Router as-is — the bundle carries a `"use client"` directive.
1306
+ Import the stylesheet from a client component or your root layout.
1307
+
1308
+ A route handler that streams, and the `onSend` that reads it:
1309
+
1310
+ ```ts
1311
+ // app/api/chat/route.ts
1312
+ import Anthropic from "@anthropic-ai/sdk";
1313
+
1314
+ const anthropic = new Anthropic();
1315
+
1316
+ export async function POST(request: Request) {
1317
+ const { message, quotedText } = await request.json();
1318
+
1319
+ const stream = anthropic.messages.stream({
1320
+ model: "claude-sonnet-5",
1321
+ max_tokens: 1024,
1322
+ // The passage a thread hangs off, when there is one.
1323
+ system: quotedText
1324
+ ? `The reader highlighted this passage and is asking about it:\n\n${quotedText}`
1325
+ : undefined,
1326
+ messages: [{ role: "user", content: message }],
1327
+ });
1328
+
1329
+ return new Response(
1330
+ new ReadableStream({
1331
+ async start(controller) {
1332
+ for await (const event of stream) {
1333
+ if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
1334
+ controller.enqueue(new TextEncoder().encode(event.delta.text));
1335
+ }
1336
+ }
1337
+ controller.close();
1338
+ },
1339
+ }),
1340
+ { headers: { "content-type": "text/plain; charset=utf-8" } }
1341
+ );
1342
+ }
1343
+ ```
1344
+
1345
+ ```tsx
1346
+ "use client";
1347
+
1348
+ const { turns, setDraft, submit, stop } = useChatTurns({
1349
+ onSend: async function* (message, { signal }) {
1350
+ const response = await fetch("/api/chat", {
1351
+ method: "POST",
1352
+ headers: { "content-type": "application/json" },
1353
+ body: JSON.stringify({ message }),
1354
+ signal,
1355
+ });
1356
+ if (!response.ok) throw new Error(`chat failed: ${response.status}`);
1357
+ // `signal` aborts the request when the reader presses stop, which ends
1358
+ // this loop and settles the turn with whatever had already arrived.
1359
+ yield* response.body!.pipeThrough(new TextDecoderStream());
1360
+ },
1361
+ });
1362
+ ```
1363
+
1364
+ `ReplyThreadPopup` takes the same shape with the quoted passage as a second
1365
+ argument:
1366
+
1367
+ ```tsx
1368
+ <ReplyThreadPopup
1369
+ activeReply={activeReply}
1370
+ onClose={() => setActiveReply(null)}
1371
+ onSendMessage={async function* (message, quotedText, { signal }) {
1372
+ const response = await fetch("/api/chat", {
1373
+ method: "POST",
1374
+ headers: { "content-type": "application/json" },
1375
+ body: JSON.stringify({ message, quotedText }),
1376
+ signal,
1377
+ });
1378
+ yield* response.body!.pipeThrough(new TextDecoderStream());
1379
+ }}
1380
+ />
1381
+ ```
1382
+
1383
+ ## Contributing
1384
+
1385
+ The dev environment, the tooling and the release live in
1386
+ [CONTRIBUTING.md](../../CONTRIBUTING.md) at the root of the repo. What follows
1387
+ is what is peculiar about *this package's* tests.
1388
+
1389
+ ```bash
1390
+ npm install
1391
+ npm test
1392
+ npm run verify # everything CI runs
1393
+ ```
1394
+
1395
+ **Storybook is the source of truth for what this looks like.** A component
1396
+ change is not finished until its story shows it — and that is enforced rather
1397
+ than remembered: one test fails when something is exported without a story, and
1398
+ CI builds Storybook so a story that has drifted out of step fails there.
1399
+
1400
+ A second guard fails when a literal colour appears anywhere outside
1401
+ `styles/tokens.css`. Its exception list carries a reason per entry, because a
1402
+ list of paths to ignore becomes a list of things nobody looks at.
1403
+
1404
+ The tests stop at the edge of what jsdom can honestly answer. It has no layout
1405
+ engine and does not implement contenteditable editing, so the wrap thresholds,
1406
+ the overflow fade and the marker's hit-testing are not asserted there — a
1407
+ passing tick for those would be a lie about untested code. They live in the
1408
+ playground, with a real pointer and a real display.
1409
+
1410
+ `TextHighlighter` carries one regression guard worth knowing about: token spans
1411
+ must have no inline styles at rest. Motion writes styles onto elements it
1412
+ drives, so if that test fails, per-word animation has come back — and it cost
1413
+ 350 style writes per menu open the last time.
1414
+
1415
+ ### Releasing
1416
+
1417
+ Versions before 1.0 follow the pre-release convention: a breaking change bumps
1418
+ the **minor**, a fix bumps the patch.
1419
+
1420
+ ```bash
1421
+ npm version minor --workspace packages/inline-chat-kit
1422
+ npm run pack:kit
1423
+ ```
1424
+
1425
+ Write the entry in [CHANGELOG.md](./CHANGELOG.md) first, and put anything that
1426
+ would break an existing install under **Breaking** with what to do about it.
1427
+
1428
+ ## License
1429
+
1430
+ MIT