@djangocfg/widget-chat 0.1.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/package.json +95 -0
  4. package/src/composer/composer.tsx +100 -0
  5. package/src/composer/context.ts +33 -0
  6. package/src/composer/controls/button.tsx +104 -0
  7. package/src/composer/controls/language-menu.tsx +123 -0
  8. package/src/composer/controls/send.tsx +83 -0
  9. package/src/composer/controls/think.tsx +50 -0
  10. package/src/composer/controls/tools.tsx +130 -0
  11. package/src/composer/controls/tooltip.tsx +10 -0
  12. package/src/composer/controls/voice.tsx +266 -0
  13. package/src/composer/field/field.tsx +140 -0
  14. package/src/composer/field/input.tsx +214 -0
  15. package/src/composer/field/keys.ts +44 -0
  16. package/src/composer/field/use-autosize.ts +134 -0
  17. package/src/composer/focus-field.ts +27 -0
  18. package/src/composer/index.ts +86 -0
  19. package/src/composer/stories/field.tsx +112 -0
  20. package/src/composer/stories/harness.tsx +127 -0
  21. package/src/composer/tools/menu.tsx +129 -0
  22. package/src/composer/tools/rows.tsx +180 -0
  23. package/src/composer/tools/use-menu-nav.ts +66 -0
  24. package/src/composer/tools/use-recent-tools.ts +30 -0
  25. package/src/composer/tray/banner.tsx +157 -0
  26. package/src/composer/tray/command-progress.tsx +116 -0
  27. package/src/composer/tray/picked-tools.tsx +104 -0
  28. package/src/composer/tray/queue.tsx +202 -0
  29. package/src/composer/tray/use-replace-text.ts +58 -0
  30. package/src/composer/use-draft-persistence.ts +79 -0
  31. package/src/content/index.ts +16 -0
  32. package/src/content/media-content.tsx +38 -0
  33. package/src/content/response.tsx +221 -0
  34. package/src/core/adapter.ts +29 -0
  35. package/src/core/capabilities.ts +286 -0
  36. package/src/core/chunks.ts +66 -0
  37. package/src/core/content/incomplete-math.ts +118 -0
  38. package/src/core/content/index.ts +9 -0
  39. package/src/core/draft-storage.ts +62 -0
  40. package/src/core/events.ts +88 -0
  41. package/src/core/focus/index.ts +10 -0
  42. package/src/core/focus/roving.ts +56 -0
  43. package/src/core/group-parts.ts +80 -0
  44. package/src/core/index.ts +34 -0
  45. package/src/core/links/index.ts +33 -0
  46. package/src/core/links/prose.ts +191 -0
  47. package/src/core/links/scheme.ts +98 -0
  48. package/src/core/links/tool-id.ts +26 -0
  49. package/src/core/media/allowed-src.ts +138 -0
  50. package/src/core/media/block.ts +254 -0
  51. package/src/core/media/index.ts +22 -0
  52. package/src/core/media/kinds.ts +85 -0
  53. package/src/core/outbound.ts +86 -0
  54. package/src/core/parts.ts +169 -0
  55. package/src/core/queue/index.ts +15 -0
  56. package/src/core/queue/queue.ts +100 -0
  57. package/src/core/recall/boundary.ts +77 -0
  58. package/src/core/recall/from-transcript.ts +47 -0
  59. package/src/core/recall/index.ts +23 -0
  60. package/src/core/recall/ladder.ts +171 -0
  61. package/src/core/reload-decision.ts +36 -0
  62. package/src/core/scroll/index.ts +17 -0
  63. package/src/core/scroll/missed.ts +106 -0
  64. package/src/core/scroll/sent.ts +54 -0
  65. package/src/core/text-replace.ts +20 -0
  66. package/src/core/tool-name.ts +31 -0
  67. package/src/core/tool-picks-set.ts +48 -0
  68. package/src/core/tool-picks.ts +142 -0
  69. package/src/core/tool-prompt.ts +47 -0
  70. package/src/core/tools/capability.ts +34 -0
  71. package/src/core/tools/catalogue.ts +109 -0
  72. package/src/core/tools/index.ts +17 -0
  73. package/src/core/tools/layout.ts +44 -0
  74. package/src/core/tools/nav.ts +32 -0
  75. package/src/core/tools/rank.ts +60 -0
  76. package/src/core/tools/recent.ts +39 -0
  77. package/src/core/transcript.ts +301 -0
  78. package/src/core/voice/earcons.ts +58 -0
  79. package/src/core/voice/index.ts +26 -0
  80. package/src/core/voice/language-search.ts +62 -0
  81. package/src/core/voice/languages.ts +267 -0
  82. package/src/core/voice/resolve-language.ts +93 -0
  83. package/src/core/voice/session.ts +196 -0
  84. package/src/core/voice/web-speech.ts +170 -0
  85. package/src/i18n/index.ts +87 -0
  86. package/src/i18n/locales/ar.ts +97 -0
  87. package/src/i18n/locales/da.ts +90 -0
  88. package/src/i18n/locales/de.ts +90 -0
  89. package/src/i18n/locales/en.ts +90 -0
  90. package/src/i18n/locales/es.ts +90 -0
  91. package/src/i18n/locales/fr.ts +90 -0
  92. package/src/i18n/locales/it.ts +90 -0
  93. package/src/i18n/locales/ja.ts +83 -0
  94. package/src/i18n/locales/ko.ts +83 -0
  95. package/src/i18n/locales/nl.ts +90 -0
  96. package/src/i18n/locales/no.ts +90 -0
  97. package/src/i18n/locales/pl.ts +96 -0
  98. package/src/i18n/locales/ptBR.ts +90 -0
  99. package/src/i18n/locales/ru.ts +96 -0
  100. package/src/i18n/locales/sv.ts +90 -0
  101. package/src/i18n/locales/tr.ts +89 -0
  102. package/src/i18n/locales/zh.ts +83 -0
  103. package/src/i18n/plural.ts +62 -0
  104. package/src/i18n/types.ts +257 -0
  105. package/src/index.ts +18 -0
  106. package/src/primitives/index.ts +43 -0
  107. package/src/primitives/message.tsx +85 -0
  108. package/src/primitives/part-registry.tsx +44 -0
  109. package/src/primitives/stories-harness.tsx +118 -0
  110. package/src/primitives/suggestions.tsx +112 -0
  111. package/src/primitives/tool-call.tsx +145 -0
  112. package/src/primitives/tooltip.tsx +48 -0
  113. package/src/primitives/transcript.tsx +285 -0
  114. package/src/primitives/use-dismiss.ts +100 -0
  115. package/src/primitives/use-missed-count.ts +30 -0
  116. package/src/primitives/use-overflows.ts +69 -0
  117. package/src/primitives/use-return-on-send.ts +29 -0
  118. package/src/primitives/use-roving-focus.ts +69 -0
  119. package/src/primitives/use-select-all-transcript.ts +111 -0
  120. package/src/primitives/use-stick-to-bottom.ts +83 -0
  121. package/src/primitives/use-turn-activity.ts +25 -0
  122. package/src/store/context.ts +28 -0
  123. package/src/store/hooks.ts +215 -0
  124. package/src/store/index.ts +31 -0
  125. package/src/store/provider.tsx +97 -0
  126. package/src/store/store.ts +394 -0
  127. package/src/store/subscribers.ts +55 -0
  128. package/src/store/surfaces.ts +174 -0
  129. package/src/store/use-draft.ts +168 -0
  130. package/src/store/use-queue.ts +61 -0
  131. package/src/store/use-recall.ts +264 -0
  132. package/src/store/use-reload-on-revision.ts +67 -0
  133. package/src/store/use-speech-language.ts +84 -0
  134. package/src/store/use-voice.ts +116 -0
  135. package/src/styles/block.css +128 -0
  136. package/src/styles/composer.css +449 -0
  137. package/src/styles/index.css +47 -0
  138. package/src/styles/markdown.css +368 -0
  139. package/src/styles/surface.css +102 -0
  140. package/src/styles/tool.css +128 -0
  141. package/src/styles/turn-mark.css +97 -0
  142. package/src/testing/index.ts +15 -0
  143. package/src/testing/mock-adapter.ts +69 -0
  144. package/src/testing/mock-tools.ts +101 -0
  145. package/src/testing/mock-voice.ts +81 -0
  146. package/src/ui/chat-composer.tsx +286 -0
  147. package/src/ui/chat-reply-chip.tsx +42 -0
  148. package/src/ui/chat-suggestions.tsx +87 -0
  149. package/src/ui/chat-transcript.tsx +157 -0
  150. package/src/ui/chat.tsx +197 -0
  151. package/src/ui/collapsible-body.tsx +180 -0
  152. package/src/ui/collapsible-row.tsx +81 -0
  153. package/src/ui/day-divider.tsx +49 -0
  154. package/src/ui/index.ts +54 -0
  155. package/src/ui/jump-to-latest.tsx +60 -0
  156. package/src/ui/link-chip.tsx +73 -0
  157. package/src/ui/message-actions.tsx +163 -0
  158. package/src/ui/message-quote.tsx +47 -0
  159. package/src/ui/message.tsx +295 -0
  160. package/src/ui/pending.tsx +85 -0
  161. package/src/ui/renderers/data.tsx +63 -0
  162. package/src/ui/renderers/reasoning.tsx +73 -0
  163. package/src/ui/renderers/tool.tsx +176 -0
  164. package/src/ui/turn-mark.tsx +31 -0
  165. package/src/utils/day-heading.ts +46 -0
  166. package/src/utils/error-message.ts +10 -0
  167. package/src/utils/index.ts +12 -0
  168. package/src/utils/markdown-text.ts +175 -0
  169. package/src/widgets/index.ts +14 -0
  170. package/src/widgets/media.tsx +239 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `@djangocfg/widget-chat` — the CSS the package's own markup needs to work.
3
+ *
4
+ * Import once, anywhere in a host's stylesheet:
5
+ *
6
+ * @import "@djangocfg/widget-chat/styles";
7
+ *
8
+ * ## What belongs here, and what does not
9
+ *
10
+ * This file carries rules that are part of the COMPONENTS' contract — layout
11
+ * that the markup would be broken without, and that no host would ever want
12
+ * differently. It carries no product VALUES: not the type scale, not the
13
+ * reading width, not a colour. Those stay the host's, declared as `--chat-*`
14
+ * tokens; a host defines them alongside its own theme.
15
+ *
16
+ * The dividing question is "would a second consumer want this changed?" — and
17
+ * it is about the VALUE, not about the rule. Which class consumes which token
18
+ * is not a decision anyone would take differently; it is wiring, and it belongs
19
+ * beside the markup it wires, or it breaks the next time that markup moves.
20
+ * Every `var()` here therefore carries a fallback, so a host that declares no
21
+ * tokens still gets a readable surface rather than a collapsed one.
22
+ *
23
+ * ## Why CSS at all, in a package that ships none
24
+ *
25
+ * Because `:has()` is the only mechanism that expresses some of these rules,
26
+ * and it has no JavaScript equivalent that is not a resize observer writing a
27
+ * class —
28
+ * a measure-and-render cycle for a question the browser has already answered.
29
+ * Everything else the package needs is a Tailwind class on its own markup.
30
+ *
31
+ * The package is consumed as SOURCE (`"main": "./src/index.ts"`), so a host's
32
+ * Tailwind entry can import this directly; there is no build step to teach.
33
+ */
34
+
35
+ /**
36
+ * Split by SUBSYSTEM, one file each, because they answer to different owners:
37
+ * the composer's rules are layout the markup breaks without, the markdown
38
+ * surface styles what the renderer emits, and the block rhythm belongs to the
39
+ * transcript. A single file made "is this rule still used?" a question about
40
+ * 513 lines.
41
+ */
42
+ @import "./surface.css";
43
+ @import "./turn-mark.css";
44
+ @import "./composer.css";
45
+ @import "./markdown.css";
46
+ @import "./block.css";
47
+ @import "./tool.css";
@@ -0,0 +1,368 @@
1
+ /**
2
+ * The MARKDOWN surface — how a rendered message reads.
3
+ *
4
+ * These style markup the PACKAGE renders (`streamdown` output inside a
5
+ * message), so they ship with it: a second consumer that mounted the transcript
6
+ * and got un-gutter'd lists, a doubled code background and document-scale
7
+ * headings would be inheriting a bug, not exercising a choice.
8
+ *
9
+ * What stays with the HOST is what a product decides — the type SCALE, the
10
+ * reading COLUMN, and the `.chat-surface`/`.chat-column` classes it puts on its
11
+ * own elements. Consumed here as variables WITH FALLBACKS, so this degrades to
12
+ * sensible relative sizing when a host declares none.
13
+ */
14
+
15
+ /**
16
+ * Zero-specificity by design.
17
+ *
18
+ * Everything below is `:where()`, so every rule has ZERO specificity: a
19
+ * component that sets its own value always wins, and these are defaults rather
20
+ * than overrides. That matters because the agent's markdown is rendered by
21
+ * `streamdown`, and fighting a library with specificity is how a stylesheet
22
+ * becomes unmaintainable.
23
+ *
24
+ * The through-line is that a message is not a DOCUMENT. Document defaults —
25
+ * heavy code chips, big headings, generous block margins — are calibrated for a
26
+ * page the reader arrives at, not for a paragraph inside a conversation.
27
+ */
28
+
29
+ /* Monospace, INSIDE a sentence.
30
+ *
31
+ * The default here is a chip: background, padding, radius. At prose weight that
32
+ * reads heavier than the words around it, so a sentence with three identifiers
33
+ * in it turns into a row of buttons. Keeping the tint but cutting the padding
34
+ * and softening the ground keeps it identifiable as code without letting it
35
+ * outrank the sentence carrying it. */
36
+ .chat-surface :where(code, kbd, samp) {
37
+ font-size: var(--chat-text-code, 0.92em);
38
+ border-radius: 0.25rem;
39
+ padding: 0.05em 0.3em;
40
+ background: color-mix(in oklab, currentColor 8%, transparent);
41
+ }
42
+
43
+ /* A fenced block is the opposite case: it IS the content, so it keeps its own
44
+ * ground and padding. Reset the inline rule rather than restate it — a `code`
45
+ * inside a `pre` matched the selector above and got a second background. */
46
+ /* A fenced block scrolls rather than spilling out of the column, for the same
47
+ * reason a table does. The guards that hold prose do not reach it:
48
+ * `break-words` is scoped to the user bubble and code arrives from the model,
49
+ * and nothing wraps a long line inside a `pre`.
50
+ *
51
+ * What spills is not the block's own width — the `pre` measures at the column
52
+ * either way. Its CONTENT overflows it, and with `overflow-x: visible` that
53
+ * paints past the edge and grows the transcript's scroll area instead: measured
54
+ * live, one 400-character line took the scroller from 1218 to 3434 while every
55
+ * element in the row still reported 768. So the whole transcript scrolls
56
+ * sideways with no element admitting it did. */
57
+ .chat-surface :where(pre) {
58
+ font-size: var(--chat-text-code, 0.92em);
59
+ border-radius: 0.5rem;
60
+ line-height: 1.5;
61
+ overflow-x: auto;
62
+ }
63
+ .chat-surface :where(pre code, pre kbd, pre samp) {
64
+ padding: 0;
65
+ background: transparent;
66
+ font-size: inherit;
67
+ }
68
+
69
+ /* Headings stay close to the body. An `h1` at document scale inside a bubble
70
+ * reads as a shout, and the agent emits them freely. The steps are deliberately
71
+ * small — the job is to mark structure, not to build a title page. */
72
+ .chat-surface :where(h1, h2, h3, h4, h5, h6) {
73
+ font-size: calc(var(--chat-text, 1em) * 1.08);
74
+ line-height: 1.35;
75
+ font-weight: 600;
76
+ margin-block: 1.2em 0.5em;
77
+ }
78
+ .chat-surface :where(h1:first-child, h2:first-child, h3:first-child) {
79
+ margin-block-start: 0;
80
+ }
81
+
82
+ /* Lists need their marker gutter back.
83
+ *
84
+ * Measured on a live app: `padding-left: 0`, so the bullets rendered
85
+ * OUTSIDE the text column and the items did not line up with the paragraphs
86
+ * above them. `1.4em` is a marker plus a space at the current size, and it
87
+ * scales with the type because it is an em. */
88
+ .chat-surface :where(ul, ol) {
89
+ padding-inline-start: 1.4em;
90
+ }
91
+ .chat-surface :where(li) {
92
+ margin-block: 0.15em;
93
+ }
94
+ .chat-surface :where(li > ul, li > ol) {
95
+ margin-block: 0.15em;
96
+ }
97
+
98
+ /* Block rhythm. One value, so paragraphs, lists, quotes and code blocks are
99
+ * separated by the same distance and the message reads as one column rather
100
+ * than as stacked cards. */
101
+ .chat-surface :where(p, ul, ol, pre, blockquote, table) {
102
+ margin-block: 0.7em;
103
+ }
104
+
105
+ /* A notice is a UI row that happens to be a <p>, so the prose rhythm above
106
+ * spaces it against a sibling whose parent already sets a gap. Keyed on the
107
+ * element's own `data-slot` rather than a class, so it cannot be lost by a
108
+ * caller passing `className`, and in `:where()` so it only REMOVES. */
109
+ .chat-surface :where(p[data-slot="notice"]) {
110
+ margin-block: 0;
111
+ }
112
+
113
+ /* The opt-out for UI rendered INSIDE the surface that is not prose.
114
+ *
115
+ * A popover, a tooltip or a menu portalled into `.chat-surface` inherits the
116
+ * block rhythm above, which spaces its rows like paragraphs — three equal gaps
117
+ * where the design has a heading, a subtitle and a detail line. A consumer
118
+ * cannot win that with a utility class: `:where()` scores zero, so the two tie
119
+ * on specificity and this sheet loads later.
120
+ *
121
+ * `.chat-plain` states the exemption where the rhythm is defined, so a reader
122
+ * of either sees both. It also resets the type scale, because the same surface
123
+ * sets a prose line-height that a dense card must not inherit.
124
+ *
125
+ * Wrapped in `:where()` for the same reason everything else here is: this must
126
+ * REMOVE the prose rhythm, not impose a rhythm of its own. Written as a plain
127
+ * class it scored (0,2,0) and beat the card's own `mt-*` utilities — the rows
128
+ * collapsed against each other and no spacing set on them had any effect. */
129
+ .chat-surface :where(.chat-plain) :where(p, ul, ol, pre, blockquote, table) {
130
+ margin-block: 0;
131
+ }
132
+ .chat-surface :where(.chat-plain) {
133
+ font-size: 0.875rem;
134
+ line-height: 1.4;
135
+ }
136
+ /* No leading gap at the top of a message, and none trailing at the bottom —
137
+ * the bubble's own padding owns those edges. Scoped to a first/last child of
138
+ * ANY container rather than of the surface, because each message renders inside
139
+ * its own wrapper; but `> *` on that wrapper only, so a nested list's first item
140
+ * keeps its rhythm. */
141
+ .chat-surface :where(* > p:first-child, * > ul:first-child, * > ol:first-child, * > pre:first-child) {
142
+ margin-block-start: 0;
143
+ }
144
+ .chat-surface :where(* > p:last-child, * > ul:last-child, * > ol:last-child, * > pre:last-child) {
145
+ margin-block-end: 0;
146
+ }
147
+
148
+ /* A quote is marked by a RULE, not by a filled box: it is an aside inside a
149
+ * message that already sits in a bubble, and a second surface inside the first
150
+ * reads as a nested card. */
151
+ .chat-surface :where(blockquote) {
152
+ padding-inline-start: 0.9em;
153
+ border-inline-start: 2px solid color-mix(in oklab, currentColor 20%, transparent);
154
+ color: color-mix(in oklab, currentColor 75%, transparent);
155
+ }
156
+
157
+ /* Tables scroll rather than widen the column. A wide table is the one markdown
158
+ * element that can break the reading column outright, and a horizontal scrollbar
159
+ * on the table beats one on the page. */
160
+ .chat-surface :where(table) {
161
+ display: block;
162
+ overflow-x: auto;
163
+ border-collapse: collapse;
164
+ font-size: var(--chat-text-small, 0.8em);
165
+ }
166
+ .chat-surface :where(th, td) {
167
+ padding: 0.35em 0.6em;
168
+ border: 1px solid color-mix(in oklab, currentColor 15%, transparent);
169
+ text-align: start;
170
+ }
171
+
172
+ /* A separator between thoughts, not a page break. */
173
+ .chat-surface :where(hr) {
174
+ margin-block: 1.2em;
175
+ border: 0;
176
+ border-block-start: 1px solid color-mix(in oklab, currentColor 15%, transparent);
177
+ }
178
+
179
+ /* An inline chip — a file the agent named, or a bare url it wrote. Both are the
180
+ * sibling of inline code above, and both follow the same three rules for the
181
+ * same reasons, so the rules live on ONE class and each kind adds only what is
182
+ * genuinely its own.
183
+ *
184
+ * Sized in `em` so it tracks the bubble's text instead of pinning itself to a
185
+ * rem the surface knows nothing about. Coloured by INHERITANCE, never by a
186
+ * named token: the user's bubble has no primary fill in dark theme, so a
187
+ * `--primary-foreground` resolved to near-black against light prose and the
188
+ * reference all but vanished. And the ground is mixed FROM that inherited
189
+ * colour, so it tints toward whichever direction the theme runs — a fixed white
190
+ * wash shows on a dark bubble and disappears on a light one.
191
+ *
192
+ * The class exists so the component carries none of this in a class list: the
193
+ * rule belongs with the prose it sits in, beside the code chip it matches. */
194
+ .chat-surface .chat-chip {
195
+ /* Neither a bare `button` nor an `a` inherits type and colour from its
196
+ * context, so the three lines a UA stylesheet would otherwise decide are
197
+ * stated here. For the file chip this is the whole reason it is a plain
198
+ * button: the shared `Button` gives these AND four other things a word
199
+ * inside a sentence must not have. For the link chip it is what strips the
200
+ * default underline and link colour. */
201
+ font: inherit;
202
+ color: inherit;
203
+ cursor: pointer;
204
+ text-decoration: none;
205
+ font-size: var(--chat-text-code, 0.92em);
206
+ /* An inline-flex box exports its FIRST item's baseline, and the first item
207
+ * here is the icon — an SVG, which has no text baseline to give. That is why
208
+ * the label rode visibly higher than the words on either side, and why no
209
+ * amount of padding or line-height fixes it: none of them change WHICH child
210
+ * the baseline comes from.
211
+ *
212
+ * `align-items: baseline` makes the label the donor instead, and
213
+ * `align-self: center` on the icon (below) re-centres the icon against it.
214
+ * Measured against the neighbouring prose, chip bottom minus text bottom:
215
+ *
216
+ * line-height 1.4, align center → -3.50 (the reported defect)
217
+ * line-height inherit → -3.50, and 24.29px tall: `inherit`
218
+ * takes the multiplier, not the height
219
+ * line-height 1, align center → -3.50 height changed, drift did not
220
+ * align-items: baseline → 0.00
221
+ *
222
+ * `baseline-source: last` reaches the same result and is not used: it is the
223
+ * newer property, unsupported in Safari, and buys nothing here.
224
+ *
225
+ * `normal` rather than a number: the font's own line box is what the label
226
+ * needs to sit in, and every value tried moved the box relative to the text
227
+ * around it (`1` → -1.28, `1.29` → +0.98, `inherit` → +3.28, `normal` →
228
+ * -0.50). The residue is the difference between two DIFFERENT type sizes'
229
+ * bottom edges, not a baseline offset, so it cannot reach zero and does not
230
+ * need to. */
231
+ line-height: normal;
232
+ align-items: baseline;
233
+ border-radius: 0.25rem;
234
+ /* Horizontal padding only, and the vertical breathing room comes from the
235
+ * shadow below instead.
236
+ *
237
+ * Vertical padding cannot be used here: on a flex container the baseline is
238
+ * measured from the padding box, so every form of it drags the label off the
239
+ * line. Measured, drift against the neighbouring text:
240
+ *
241
+ * padding-block: 2px → +2.00
242
+ * padding-block + matching negative margin → +2.00 (margin is not counted)
243
+ * padding-block + vertical-align offset → +4.00 (they ADD, not cancel)
244
+ * padding-block on the label instead → +0.72
245
+ * box-shadow spread → 0.00
246
+ */
247
+ padding: 0 0.3em;
248
+ /* Horizontal only, and exactly the shadow's spread: the ground below paints
249
+ * OUTSIDE the box, so without this it laps over the words on either side and
250
+ * the chip reads as touching them. Inline margin does not affect the
251
+ * baseline (measured: drift stays 0.00), which is why the axis that could not
252
+ * take padding can take this. */
253
+ margin-inline: 0.18em;
254
+ background: color-mix(in oklab, currentColor 10%, transparent);
255
+ /* The ground, grown evenly on all four sides. A shadow paints outside the
256
+ * border box without joining layout, so the chip reads as having padding
257
+ * while its baseline, its height and the paragraph's line box are all
258
+ * untouched. Same colour as the background, so the two are one shape. */
259
+ box-shadow: 0 0 0 0.18em color-mix(in oklab, currentColor 10%, transparent);
260
+ }
261
+ /* The icon must NOT sit on the baseline it just handed to the label — a glyph
262
+ * aligned that way hangs its whole body above the line. Centring it against the
263
+ * label is the counterpart to `align-items: baseline` above.
264
+ *
265
+ * The size is an em for the same reason the type is: a fixed 16px leaves the
266
+ * icon behind the moment a surface raises its text. */
267
+ .chat-surface .chat-chip > svg {
268
+ align-self: center;
269
+ width: 1em;
270
+ height: 1em;
271
+ }
272
+ /* A host-supplied typed icon arrives as a SPAN wrapping the svg, sized in
273
+ * pixels inline. Both have to be overridden here: the pixel size does not track
274
+ * the surface's type, and the span would donate the baseline the label owns. */
275
+ .chat-surface .chat-chip > span:where([aria-hidden="true"], [role="img"]) {
276
+ align-self: center;
277
+ width: 1em !important;
278
+ height: 1em !important;
279
+ }
280
+ .chat-surface .chat-chip > span:where([aria-hidden="true"], [role="img"]) > svg {
281
+ width: 100%;
282
+ height: 100%;
283
+ display: block;
284
+ }
285
+ /* Both surfaces move together, and INSTANTLY. A hover lighting only the
286
+ * background shows the shadow as a separate unlit ring; a transition covering
287
+ * only `background-color` eases one half while the ring snaps, which separates
288
+ * the two halves of one shape. Do not add a transition without covering both. */
289
+ .chat-surface .chat-chip:where(:hover) {
290
+ background: color-mix(in oklab, currentColor 18%, transparent);
291
+ box-shadow: 0 0 0 0.18em color-mix(in oklab, currentColor 18%, transparent);
292
+ }
293
+ /* An ordinary link the agent wrote, where the author chose the label.
294
+ *
295
+ * A host overriding the renderer's `a` component opts out of the library's
296
+ * default anchor, which is where the class, `target` and `rel` came from. With
297
+ * none of them a link is indistinguishable from the prose around it, so this
298
+ * states them rather than leaving the host's fallback bare.
299
+ *
300
+ * `overflow-wrap` because a url too long for the column otherwise widens the
301
+ * bubble instead of wrapping. */
302
+ .chat-surface .chat-link {
303
+ text-decoration: underline;
304
+ text-underline-offset: 0.15em;
305
+ overflow-wrap: anywhere;
306
+ }
307
+
308
+ /* WHICH SIDE a link sits on is read from the DOM, not passed down.
309
+ *
310
+ * The accent is right against assistant prose and wrong inside a user bubble,
311
+ * whose saturated ground already spends the accent — so the link needs the
312
+ * bubble's own foreground there instead.
313
+ *
314
+ * That decision cannot travel as a prop. `components` is memoized ONCE per
315
+ * screen (`Response` re-parses every message when the object's identity
316
+ * changes), so a per-message value threaded through it would either be stale or
317
+ * cost a full re-parse per render. The message wrapper already publishes
318
+ * `data-role`, which is a fact about the DOM the link is standing in and needs
319
+ * no plumbing at all.
320
+ *
321
+ * Assistant is the default rather than a mirrored rule: an unknown or absent
322
+ * role reads as prose, which is the safe direction — an accent that fails to
323
+ * apply is a dull link, while a foreground that fails to apply is invisible
324
+ * text on a saturated ground. */
325
+ .chat-surface .chat-link,
326
+ .chat-surface .chat-link-chip {
327
+ color: var(--primary);
328
+ }
329
+
330
+ .chat-surface [data-role="user"] .chat-link,
331
+ .chat-surface [data-role="user"] .chat-link-chip {
332
+ color: inherit;
333
+ }
334
+
335
+ /* A url label carries a domain AND a path, so the ceiling is generous; the
336
+ * component still elides the middle, and this is only the backstop. */
337
+ .chat-surface .chat-link-chip {
338
+ max-width: 20rem;
339
+ }
340
+
341
+ /* A rendered diagram, and the source shown when one cannot be drawn.
342
+ *
343
+ * The SVG carries no dimensions of its own worth honouring — the renderer sizes
344
+ * it to the graph, which is routinely wider than the column. Constraining the
345
+ * element and letting the child scale is what keeps a wide diagram from
346
+ * widening the bubble, the same rule every other wide block here follows. */
347
+ .chat-surface .chat-diagram {
348
+ display: flex;
349
+ justify-content: center;
350
+ overflow-x: auto;
351
+ }
352
+
353
+ .chat-surface .chat-diagram > svg {
354
+ max-width: 100%;
355
+ height: auto;
356
+ }
357
+
358
+ /* The fallback is the agent's own source, so it reads as a listing rather than
359
+ * as an error: the same ground and rhythm as a code block, because that is what
360
+ * it is. Colour comes from `currentColor` and cannot be wrong in either theme. */
361
+ .chat-surface .chat-diagram-source {
362
+ overflow-x: auto;
363
+ padding: 0.75rem;
364
+ border-radius: var(--radius, 0.5rem);
365
+ background: color-mix(in oklab, currentColor 6%, transparent);
366
+ font-size: 0.8125em;
367
+ white-space: pre;
368
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `.chat-surface` and `.chat-column` — the two classes `ChatTranscript` puts on
3
+ * its own root, and so the two this package must make work.
4
+ *
5
+ * They were the host's for as long as the VALUES were the argument. They are
6
+ * not: what a transcript reads at and how wide it is allowed to get are still
7
+ * the product's answers, and they still arrive as `--chat-*` tokens the host
8
+ * declares. What lives here is only the WIRING — which class consumes which
9
+ * token — which is not a decision at all, and which broke as soon as the
10
+ * markup moved into this package while the rules stayed behind.
11
+ *
12
+ * Every token carries a fallback, so the components work in a host that
13
+ * declares none: a workbench, a test, a second consumer. A missing token must
14
+ * degrade to a readable default, never to zero.
15
+ */
16
+
17
+ /**
18
+ * The type. Chat is the one surface in an app that is sustained READING
19
+ * rather than scanning a control, so it does not inherit the control scale.
20
+ */
21
+ .chat-surface {
22
+ font-size: var(--chat-text, 1rem);
23
+ line-height: var(--chat-leading, 1.65);
24
+ /* NORMAL, and that is the whole decision. A design system's negative tracking
25
+ * is tuned for LABELS — a chip, a menu row, something taken in at a glance,
26
+ * where tightening buys density. Prose is the opposite job: the reader needs
27
+ * the word shapes open. Measured against the two surfaces this one is judged
28
+ * beside, both set prose at `normal` and neither tightens it.
29
+ *
30
+ * Declared rather than left alone, because a token like
31
+ * `--letter-spacing-base` is set once high in the tree on a control-sized
32
+ * element — so it resolves THERE and would arrive here as fixed pixels
33
+ * computed for a size this surface does not use. */
34
+ letter-spacing: normal;
35
+ }
36
+
37
+ /**
38
+ * A fling that lands on the bottom edge must not hand its leftover momentum to
39
+ * the page behind, which then bounces back into this box.
40
+ */
41
+ .chat-surface {
42
+ overscroll-behavior: contain;
43
+ }
44
+
45
+ /**
46
+ * The reading column, applied to the CONTENTS of a full-width surface.
47
+ *
48
+ * Every direct child is centred and capped, so the scroller keeps its scrollbar
49
+ * at the pane's edge while the transcript stays readable on a wide display. A
50
+ * host puts the same class on its composer wrapper — one rule for both, because
51
+ * a transcript and a composer at different widths disagree visibly about where
52
+ * the conversation is.
53
+ *
54
+ * A CSS rule rather than a wrapper element per row: the transcript renders its
55
+ * rows as direct children, and threading a container through it would make the
56
+ * primitive responsible for page layout it has no business knowing about.
57
+ */
58
+ /*
59
+ * The scrollbar-side inset, shared by the transcript and the composer so the
60
+ * two never disagree about where the column ends.
61
+ *
62
+ * A token because the right answer differs by device: a desktop needs only
63
+ * enough to clear a real scrollbar, while a phone overlays its own and wants
64
+ * the inset to match the screen's other margins — reply text sat against the
65
+ * bare edge at 0px while the header beside it kept 20.
66
+ */
67
+ .chat-inset-r {
68
+ padding-right: var(--chat-edge-r, 1rem);
69
+ }
70
+
71
+ .chat-column > * {
72
+ margin-inline: auto;
73
+ width: 100%;
74
+ max-width: var(--chat-column, 48rem);
75
+ }
76
+
77
+ /**
78
+ * The row a jump landed on, marked just long enough to be found.
79
+ *
80
+ * An animation rather than a persistent state: the mark answers "which row did
81
+ * I mean", and a highlight that stayed would become a selection nobody chose.
82
+ * Removed and re-added by `ChatSurfaces.revealMessage`, so a second jump to the
83
+ * same row flashes again.
84
+ */
85
+ .chat-revealed {
86
+ animation: chat-reveal 1.2s ease-out;
87
+ }
88
+
89
+ @keyframes chat-reveal {
90
+ from {
91
+ background: color-mix(in oklab, var(--primary) 18%, transparent);
92
+ }
93
+ to {
94
+ background: transparent;
95
+ }
96
+ }
97
+
98
+ @media (prefers-reduced-motion: reduce) {
99
+ .chat-revealed {
100
+ animation-duration: 0.01ms;
101
+ }
102
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Tool calls — a summary that opens.
3
+ *
4
+ * Form adapted from vercel/ai-elements `packages/elements/src/tool.tsx`
5
+ * (Apache-2.0, submodule @ 6a9d5b1): card shell, header-as-trigger, rotating
6
+ * chevron. The disclosure RULE is ours — they collapse unconditionally, which
7
+ * renders a running call as a stalled row.
8
+ *
9
+ * Here rather than in the class list because every rule below is appearance.
10
+ */
11
+
12
+ /* Spacing is not here: `block.css` owns the rhythm for every block-level part,
13
+ * because a tool call and a card sitting next to each other need one rule, not
14
+ * two that agree until someone edits one of them. */
15
+
16
+ /**
17
+ * The running marker TUMBLES, reusing `turn-mark-tumble` — one motion for every
18
+ * place this app says "still going".
19
+ *
20
+ * A square, never a circle, and that is the reference's measured point: the
21
+ * corners are what make rotation visible, and at this size a turning circle is
22
+ * indistinguishable from a still one. A ring would be worse still — it promises
23
+ * progress, and no tool here knows its own duration.
24
+ */
25
+ .chat-tool-dot {
26
+ inline-size: 0.5rem;
27
+ block-size: 0.5rem;
28
+ border-radius: 999px;
29
+ }
30
+
31
+ /* Bigger while it turns: at 8px the corners travel too few pixels for the
32
+ * rotation to register, which is the whole reason this is a square. */
33
+ .chat-tool-dot[data-running] {
34
+ inline-size: 0.625rem;
35
+ block-size: 0.625rem;
36
+ border-radius: 0.1rem;
37
+ animation: turn-mark-tumble 1.6s linear infinite;
38
+ }
39
+
40
+ /**
41
+ * A running call is legible as a BLOCK, not as an 8px glyph.
42
+ *
43
+ * The marker alone put the entire signal in the smallest element on the row —
44
+ * at a glance a running call and a finished one were the same card. The border
45
+ * carries it at the size of the thing it describes; the marker then confirms
46
+ * what the border already said.
47
+ *
48
+ * Border colour only: no ground, no shadow, nothing that shifts layout when a
49
+ * call completes.
50
+ */
51
+ .chat-tool[data-state="input-streaming"],
52
+ .chat-tool[data-state="input-available"] {
53
+ border-color: color-mix(in oklab, var(--primary) 45%, var(--border));
54
+ }
55
+
56
+ /* The status word earns the accent for the same reason — "Running" printed in
57
+ * the same grey as "Done" states the fact and hides it. */
58
+ .chat-tool[data-state="input-streaming"] .chat-tool-status,
59
+ .chat-tool[data-state="input-available"] .chat-tool-status {
60
+ color: var(--primary);
61
+ }
62
+
63
+ @media (prefers-reduced-motion: reduce) {
64
+ /* Still, but not invisible — the accent colour alone carries "running". */
65
+ .chat-tool-dot[data-running] {
66
+ animation: none;
67
+ }
68
+ }
69
+
70
+ /* The header is the trigger, so it must read as pressable across its width. */
71
+ .chat-tool-header {
72
+ display: flex;
73
+ width: 100%;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ gap: 0.75rem;
77
+ cursor: pointer;
78
+ text-align: start;
79
+ }
80
+
81
+ /* Transform only: rotation costs no layout, so the header cannot shift. */
82
+ .chat-tool-chevron {
83
+ flex-shrink: 0;
84
+ transition: transform 150ms ease;
85
+ }
86
+
87
+ .chat-tool[data-open] .chat-tool-chevron {
88
+ transform: rotate(180deg);
89
+ }
90
+
91
+ @media (prefers-reduced-motion: reduce) {
92
+ .chat-tool-chevron {
93
+ transition: none;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Tool output is the one surface here guaranteed to carry machine-generated strings —
99
+ * paths, hashes, base64, a datamarked transcript whose words are joined by a
100
+ * marker rune. None carry a break opportunity, so the box grows until the text
101
+ * leaves it.
102
+ *
103
+ * `anywhere`, not `break-all`: `break-all` chops ordinary prose too, and a
104
+ * result is usually both. Same property as the long-url rule in `markdown.css`.
105
+ *
106
+ * `min-width: 0` is load-bearing: a grid item's automatic minimum size is its
107
+ * content, so an unbreakable token widens the track before wrapping applies.
108
+ */
109
+ .chat-tool-input,
110
+ .chat-tool-output {
111
+ min-width: 0;
112
+ overflow-wrap: anywhere;
113
+ white-space: pre-wrap;
114
+ }
115
+
116
+ /* Collapsing solves the common case, not the reader who OPENS a call that
117
+ * returned five tool descriptions. Capped so no result buries the answer, tall
118
+ * enough that an ordinary one needs no scrolling. */
119
+ .chat-tool-output {
120
+ max-height: 18rem;
121
+ overflow-y: auto;
122
+ }
123
+
124
+ /* Never capped: an error is short, and it is why the turn went as it did. */
125
+ .chat-tool-output[data-error] {
126
+ max-height: none;
127
+ color: var(--destructive, hsl(0 67% 60%));
128
+ }