@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,116 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ import {
4
+ VoiceSession,
5
+ createWebSpeechCapability,
6
+ type PlayEarcon,
7
+ type VoiceCapability,
8
+ type VoiceSnapshot,
9
+ } from "../core";
10
+
11
+ /**
12
+ * React binding for `VoiceSession`.
13
+ *
14
+ * Deliberately thin, exactly like `useMentions`. Everything with a race in it —
15
+ * a result arriving after stop, an engine that ends on its own, the interim that
16
+ * must replace rather than append — lives in `core/voice`, tested without a DOM.
17
+ * This file only subscribes.
18
+ */
19
+
20
+ export interface UseVoiceResult extends VoiceSnapshot {
21
+ /** Whether a microphone exists at all. `false` ⇒ render no control. */
22
+ readonly available: boolean;
23
+ readonly start: () => void;
24
+ readonly stop: () => void;
25
+ readonly cancel: () => void;
26
+ /** Take the dictated text and reset, for handing to the draft. */
27
+ readonly take: () => string;
28
+ }
29
+
30
+ const IDLE: VoiceSnapshot = {
31
+ recording: false,
32
+ committed: "",
33
+ interim: "",
34
+ text: "",
35
+ };
36
+
37
+ export function useVoice(
38
+ capability?: VoiceCapability | false,
39
+ language = "en-US",
40
+ playEarcon?: PlayEarcon,
41
+ ): UseVoiceResult {
42
+ const [snapshot, setSnapshot] = useState(IDLE);
43
+
44
+ // Read through a ref for the same reason the capability is: a host passes an
45
+ // inline arrow at every realistic call site, so keying the session on it would
46
+ // rebuild the session mid-sentence and drop the words.
47
+ const currentEarcon = useRef(playEarcon);
48
+ currentEarcon.current = playEarcon;
49
+
50
+ // The language, readable at START without being a dependency. The default
51
+ // engine reads it through this function, so changing the language applies to
52
+ // the NEXT dictation instead of rebuilding the session mid-sentence.
53
+ const currentLanguage = useRef(language);
54
+ currentLanguage.current = language;
55
+
56
+ // The package's own engine, built at most once. `undefined` on a browser with
57
+ // no Web Speech, which is what makes "no engine ⇒ no button" hold.
58
+ const fallback = useMemo(
59
+ () => createWebSpeechCapability({ language: () => currentLanguage.current }),
60
+ [],
61
+ );
62
+
63
+ // Absent capability falls back to the browser rather than to nothing — the
64
+ // one capability where absent does not mean absent. A host that supplies one
65
+ // overrides the default outright, and `false` refuses the fallback too, which
66
+ // is the only way to say "no dictation" when absence already means "default".
67
+ const effective = capability === false ? undefined : (capability ?? fallback);
68
+
69
+ // Same reason as `useMentions`: the capability is an object literal at every
70
+ // realistic call site, so it is a new reference on every parent render.
71
+ // Keying the session on it would rebuild it mid-sentence and drop the words.
72
+ const latest = useRef(effective);
73
+ latest.current = effective;
74
+
75
+ const session = useMemo(() => {
76
+ if (effective === undefined) return null;
77
+ return new VoiceSession({
78
+ // Read through the ref so a re-created capability object still routes to
79
+ // the host's CURRENT engine without rebuilding the session.
80
+ capability: {
81
+ start: (onText, signal) => latest.current!.start(onText, signal),
82
+ stop: () => latest.current!.stop(),
83
+ },
84
+ onChange: setSnapshot,
85
+ // Through the ref, so a host that rebuilds the callback each render still
86
+ // reaches its CURRENT player without the session being rebuilt.
87
+ playEarcon: (kind) => currentEarcon.current?.(kind),
88
+ });
89
+ // Presence alone rebuilds it. A `VoiceCapability` has no behavioural
90
+ // primitives to key on — both members are functions, and functions are read
91
+ // through the ref — so this is the whole dependency. Keyed on the EFFECTIVE
92
+ // engine: a host capability arriving late must replace the default, and
93
+ // keying on `capability` alone would leave the browser engine in place.
94
+ }, [effective === undefined]);
95
+
96
+ useEffect(() => () => session?.dispose(), [session]);
97
+
98
+ // The ACTIONS memo on `session` alone, never on `snapshot` — the same defect
99
+ // `use-mentions.ts` guards against. A caller may put `stop` in an effect's
100
+ // dependency array (stopping dictation when a turn is sent is exactly that
101
+ // shape); rebuilt on every snapshot, that effect would fire on each interim
102
+ // result and re-run forever. Nothing about an action depends on the snapshot:
103
+ // recording does not change what `cancel` does.
104
+ const actions = useMemo(
105
+ () => ({
106
+ available: session !== null,
107
+ start: () => session?.start(),
108
+ stop: () => session?.stop(),
109
+ cancel: () => session?.cancel(),
110
+ take: () => session?.take() ?? "",
111
+ }),
112
+ [session],
113
+ );
114
+
115
+ return useMemo(() => ({ ...snapshot, ...actions }), [snapshot, actions]);
116
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The rhythm between the block-level parts of one turn.
3
+ *
4
+ * A message stacks its parts at a flat 8px, which is right between two
5
+ * paragraphs and too tight around a BOX: a tool call, an enrichment card, a
6
+ * picture. At the same gap as prose those boxes touch each other and the
7
+ * sentence that introduced them, and several in a row read as one striped
8
+ * slab rather than as separate things.
9
+ *
10
+ * Here rather than in a class list because the condition is structural: the
11
+ * spacing depends on what the PREVIOUS sibling is, which no class on the
12
+ * element itself can express. The transcript's `gap` cannot express it either —
13
+ * a gap is uniform by definition, and these parts are not.
14
+ *
15
+ * `.chat-tool` and `.chat-block` share every rule. They stay two selectors
16
+ * because they are two markup contracts (a tool call is rendered by `tool.tsx`,
17
+ * a card and a picture by `data.tsx` and `media.tsx`), and collapsing them into
18
+ * one class would mean one renderer silently deciding the other's shape.
19
+ */
20
+
21
+ /* A box sits closer to the sentence that introduced it than to the next one, so
22
+ * it belongs to the text above rather than floating between the two. */
23
+ .chat-tool,
24
+ .chat-block {
25
+ margin-block-start: 0.875rem;
26
+ }
27
+
28
+ /* Between two boxes, less: they are peers in one sequence, and at the larger
29
+ * gap a run of them stops reading as a list. Four bordered boxes at the flat
30
+ * 4px read as one striped block, which is the shape this pair of rules exists
31
+ * to prevent — it is spacing WITHIN a message, never the transcript's gap
32
+ * between messages. */
33
+ .chat-tool + .chat-tool,
34
+ .chat-block + .chat-block,
35
+ .chat-tool + .chat-block,
36
+ .chat-block + .chat-tool {
37
+ margin-block-start: 0.5rem;
38
+ }
39
+
40
+ /* The first part of a turn has no neighbour to separate from, and the margin
41
+ * would push it away from the turn's own top edge. `:first-child` alone is not
42
+ * enough: a reply commonly opens with a picture the assistant produced, which
43
+ * is the first RENDERED part while the transcript still gives the row its own
44
+ * spacing. */
45
+ .chat-tool:first-child,
46
+ .chat-block:first-child {
47
+ margin-block-start: 0;
48
+ }
49
+
50
+ /**
51
+ * A disclosure's own marker, replaced by an icon.
52
+ *
53
+ * The native `disclosure-closed` marker renders as a `▶` whose size and colour
54
+ * the page cannot set — it tracks the platform, not the transcript's type
55
+ * scale, and stands out against every other chevron in the app. Removing it
56
+ * needs BOTH rules: `display: list-item` is what draws it, and Safari keeps
57
+ * drawing it until `::-webkit-details-marker` is hidden too.
58
+ */
59
+ .chat-disclosure > summary {
60
+ display: flex;
61
+ list-style: none;
62
+ }
63
+
64
+ .chat-disclosure > summary::-webkit-details-marker {
65
+ display: none;
66
+ }
67
+
68
+ /* Rotated by the element's own state, so nothing has to mirror `open` in JS.
69
+ *
70
+ * The class goes on a SPAN wrapping the icon, never on the `<svg>`: a computed
71
+ * transform does not take on that node here, so rotating it silently does
72
+ * nothing while every rule involved still matches. */
73
+ .chat-disclosure-chevron {
74
+ display: inline-flex;
75
+ flex-shrink: 0;
76
+ transition: transform 150ms ease;
77
+ }
78
+
79
+ .chat-disclosure[open] > summary .chat-disclosure-chevron {
80
+ transform: rotate(90deg);
81
+ }
82
+
83
+ @media (prefers-reduced-motion: reduce) {
84
+ .chat-disclosure-chevron {
85
+ transition: none;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * An OPEN disclosure is a box; a closed one is a line.
91
+ *
92
+ * Only `[open]` carries the frame, and that asymmetry is the decision: closed,
93
+ * the summary is one muted line among the turn's other chrome, and framing it
94
+ * would draw a border around every collapsed reply. Open, its body is prose of
95
+ * the same colour and size as the answer below, so without an edge the reader
96
+ * cannot tell where the thinking stops and the reply starts.
97
+ *
98
+ * `--border` and `--radius`, not literals: the same pair every other box in the
99
+ * app uses, so this tracks the theme instead of pinning one palette.
100
+ *
101
+ * The inline padding is mirrored by a negative margin, so the frame grows
102
+ * OUTWARD from the text. Padding alone would indent the summary relative to the
103
+ * lines above it and the reply below, which is the one thing a frame here must
104
+ * not do — those three all start at the same left edge.
105
+ */
106
+ .chat-disclosure[open] {
107
+ border: 1px solid var(--border);
108
+ border-radius: var(--radius, 0.625rem);
109
+ padding: 0.5rem 0.625rem;
110
+ margin-inline: -0.625rem;
111
+ /* The same edge colour as the composer and every card in the app, not a
112
+ * louder one: this frames the model's thinking, which must stay quieter than
113
+ * the answer it sits above. */
114
+ }
115
+
116
+ /* The frame's bottom edge would otherwise touch the reply, putting a border
117
+ * against the first line of the answer. Only when open, because a closed
118
+ * summary is a plain line the turn already spaces. */
119
+ .chat-disclosure[open] {
120
+ margin-block-end: 0.75rem;
121
+ }
122
+
123
+ /* Inside the box the summary is a heading over its body, so it needs the gap
124
+ * the unframed version got from the surrounding flow. `> summary + *` rather
125
+ * than a class on the body: the body is whatever the renderer put there. */
126
+ .chat-disclosure[open] > summary + * {
127
+ margin-block-start: 0.375rem;
128
+ }
@@ -0,0 +1,449 @@
1
+ /**
2
+ * The composer's structural rules.
3
+ *
4
+ * Here rather than as Tailwind variants because each needs a selector a class
5
+ * list cannot express: `:has()` looking DOWN at a descendant and acting on a
6
+ * different child, and "every button except one" on the queued row. Written as
7
+ * variants these become strings no reviewer can read a mistake out of.
8
+ */
9
+
10
+ /**
11
+ * A composer whose draft has outgrown one line gives the field the whole row,
12
+ * and the controls take their own line beneath it.
13
+ *
14
+ * A STAGED TOOL does the same, unconditionally: a chip sits between `+` and the
15
+ * text, so the field starts mid-row and the little that is left is squeezed
16
+ * against the send button. Two independent reasons to expand, one layout — the
17
+ * text no longer fits BESIDE things, whether because of its own length or
18
+ * because of what shares its line.
19
+ *
20
+ * `:has()` is load-bearing twice over. The condition lives on a DESCENDANT —
21
+ * the textarea reports its own rendered height as `data-multiline`, which is
22
+ * the only element that can know it — and the effect lands on a DIFFERENT
23
+ * child, the field's wrapper. Descendant-to-sibling is the one direction a
24
+ * selector can look, and this is why the rule cannot be a Tailwind variant.
25
+ *
26
+ * `flex-basis`, never `width`: the wrapper is a flex item, so the basis is what
27
+ * the algorithm distributes from and a `width` is ignored outright. Measured —
28
+ * with `width: 100%` the rule matched, loaded, and changed nothing.
29
+ */
30
+ .composer-box:has(textarea[data-multiline]) > *:has(> textarea),
31
+ .composer-box:has(.tool-chips > *) > *:has(> textarea) {
32
+ flex-basis: 100%;
33
+ }
34
+
35
+ /**
36
+ * A wrapped draft sends the `+` DOWN with the trailing controls, not up.
37
+ *
38
+ * Ordering alone cannot do it: `+` leads the shared line, so it also leads the
39
+ * wrap and lands on a line of its own ABOVE the text — measured as three rows,
40
+ * `+` over the draft and the other controls under it. Raising its order past
41
+ * the field's only while the field owns the row puts both halves on the line
42
+ * beneath, `+` still at their left edge.
43
+ */
44
+ .composer-box:has(textarea[data-multiline]) > .composer-lead,
45
+ .composer-box:has(.tool-chips > *) > .composer-lead {
46
+ order: 3;
47
+ }
48
+
49
+ /* The `+` sits `inset-y` from the left edge, matching the send button's gap to
50
+ * the right and the bottom. The box's horizontal inset is breathing room for
51
+ * the TEXT and is wider, so the difference is given back here — the same
52
+ * correction `.composer-controls` makes at the other end. */
53
+ .composer-lead {
54
+ margin-inline-start: calc(var(--chat-composer-inset-y) - var(--chat-composer-inset-x));
55
+ }
56
+
57
+ /**
58
+ * The control row reaches past the box's TEXT inset, at both ends.
59
+ *
60
+ * On the row rather than on the box: the field above it must still start flush
61
+ * with the text, and a box-level inset would move both.
62
+ */
63
+ .composer-controls {
64
+ /* The row's own gap to the edge must EQUAL the vertical one, and the box's
65
+ * horizontal inset is wider — that width is breathing room for the TEXT, not
66
+ * for a control. Given back at BOTH ends, so the leading `+` and the trailing
67
+ * send button each sit the same distance from their side as from the bottom.
68
+ *
69
+ * `width: auto` is what makes the negative margin WIDEN the row. The class
70
+ * list's `w-full` pins it to 100% of the content box, so the margin only slid
71
+ * it left and the send button kept the full text inset on the right — a gap
72
+ * measured at 21px against the intended 9px. */
73
+ width: auto;
74
+ margin-inline: calc(var(--chat-composer-inset-y) - var(--chat-composer-inset-x));
75
+ }
76
+
77
+ /**
78
+ * A staged tool: a capsule, matching the controls it sits between.
79
+ *
80
+ * `--radius-full`, not a rounded rectangle. The `+` to its left is a circle and
81
+ * the send button to its right is a capsule, so a 4px corner reads as a control
82
+ * from a different set. No border for the same reason — neither neighbour has
83
+ * one, and an outline around a soft ground draws a box the row does not need.
84
+ */
85
+ .tool-chip {
86
+ border-radius: var(--radius-full, 9999px);
87
+ padding-inline: 0.5rem;
88
+ font-size: 0.75rem;
89
+ background: color-mix(in oklab, var(--muted) 70%, transparent);
90
+ color: var(--muted-foreground);
91
+ transition: background 120ms ease, color 120ms ease;
92
+ }
93
+
94
+ .tool-chip:hover {
95
+ background: var(--muted);
96
+ color: var(--foreground);
97
+ }
98
+
99
+ /**
100
+ * The remove control appears on approach, and holds its column regardless.
101
+ *
102
+ * `opacity`, never `display`: removing it from layout would resize the chip
103
+ * under the pointer, moving the target the user is reaching for. Revealed on
104
+ * `focus-within` too, or it is unreachable without a mouse.
105
+ */
106
+ .tool-chip > button {
107
+ opacity: 0;
108
+ transition: opacity 120ms ease;
109
+ }
110
+
111
+ .tool-chip:hover > button,
112
+ .tool-chip:focus-within > button {
113
+ opacity: 0.6;
114
+ }
115
+
116
+ .tool-chip > button:hover,
117
+ .tool-chip > button:focus-visible {
118
+ opacity: 1;
119
+ }
120
+
121
+ /**
122
+ * The staged row scrolls without a visible scrollbar, fading at each edge.
123
+ *
124
+ * A MASK rather than a gradient overlay: a gradient must name a colour, so it
125
+ * needs one declaration per theme and breaks over any other ground. A mask
126
+ * removes the paint itself, which is correct on every background from one rule.
127
+ *
128
+ * Both edges are declared, but each only bites when there is something to
129
+ * scroll — `animation-timeline: scroll()` drives them from the row's own scroll
130
+ * position, so a row that fits shows no fade at all. Where that timeline is
131
+ * unsupported the row simply scrolls with no fade, which is the pre-existing
132
+ * behaviour and not a regression.
133
+ */
134
+ /* Registered, or the keyframes below step between values instead of easing:
135
+ an unregistered custom property has no type, so it cannot interpolate. */
136
+ @property --tool-chips-fade-start {
137
+ syntax: "<length>";
138
+ inherits: false;
139
+ initial-value: 0px;
140
+ }
141
+
142
+ @property --tool-chips-fade-end {
143
+ syntax: "<length>";
144
+ inherits: false;
145
+ initial-value: 0px;
146
+ }
147
+
148
+ .tool-chips {
149
+ scrollbar-width: none;
150
+ --tool-chips-fade-start: 0px;
151
+ --tool-chips-fade-end: 0px;
152
+ mask-image: linear-gradient(
153
+ to right,
154
+ transparent 0,
155
+ #000 var(--tool-chips-fade-start),
156
+ #000 calc(100% - var(--tool-chips-fade-end)),
157
+ transparent 100%
158
+ );
159
+ animation: tool-chips-fade linear both;
160
+ animation-timeline: scroll(self inline);
161
+ }
162
+
163
+ .tool-chips::-webkit-scrollbar {
164
+ display: none;
165
+ }
166
+
167
+ /**
168
+ * Start and end fades, driven by scroll position rather than by a listener.
169
+ *
170
+ * At the very start there is nothing hidden to the left, so only the right edge
171
+ * fades; at the end the reverse. The middle keyframe fades both, which is the
172
+ * only moment content is cut off on each side.
173
+ */
174
+ @keyframes tool-chips-fade {
175
+ from {
176
+ --tool-chips-fade-start: 0px;
177
+ --tool-chips-fade-end: 1.5rem;
178
+ }
179
+ 50% {
180
+ --tool-chips-fade-start: 1.5rem;
181
+ --tool-chips-fade-end: 1.5rem;
182
+ }
183
+ to {
184
+ --tool-chips-fade-start: 1.5rem;
185
+ --tool-chips-fade-end: 0px;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * The field sets its own tracking, because what a host supplies is a CONTROL's.
191
+ *
192
+ * A design system's negative tracking is tuned for labels — a chip, a menu row,
193
+ * something taken in at a glance, where tightening buys density. This field is
194
+ * prose: it holds the same sentences the transcript renders, at the transcript's
195
+ * size, so it must match the transcript exactly. A composer and a transcript
196
+ * that disagree about tracking read as two different conversations, and the
197
+ * disagreement is most visible at the moment it matters — the line you typed
198
+ * sitting directly above the reply to it.
199
+ *
200
+ * `normal` is therefore not "no decision"; it is the same decision the reading
201
+ * surface makes, restated here because the host's `--letter-spacing-base` is
202
+ * declared once high up on a control-sized ancestor and would otherwise arrive
203
+ * as fixed pixels computed for a size this field does not use.
204
+ */
205
+ .composer-box textarea {
206
+ letter-spacing: normal;
207
+ }
208
+
209
+ /**
210
+ * The ring OUTSIDE the border — two lines, one box.
211
+ *
212
+ * `box-shadow`, not a second border or an outline: a border would change the
213
+ * box and push every control 2px inward, and an outline cannot be given the
214
+ * card's radius on every engine. A spread-only shadow paints outside the edge
215
+ * and costs no layout, so the field keeps the geometry the buttons align to.
216
+ *
217
+ * The colour is the BORDER's until focus, so the ring reads as depth rather
218
+ * than as a permanent highlight; on focus both lines move to `--ring` together,
219
+ * because two rings in disagreeing colours read as an error state.
220
+ *
221
+ * The radius is DERIVED, never chosen. Two rounded shapes look parallel only
222
+ * when the outer radius equals the inner one plus the gap between them —
223
+ * otherwise the corners converge or splay while the straight edges stay even,
224
+ * which reads as sloppiness without naming its cause.
225
+ *
226
+ * The controls are circles (`rounded-full`), so their radius is half their
227
+ * height, and the gap the corner has to clear is the vertical inset. That sum
228
+ * comes out at exactly half the composer's own height — a capsule. Which is the
229
+ * point: a capsule is what concentric-with-a-circle MEANS, not a style choice,
230
+ * and it stays correct if the height changes for any reason.
231
+ *
232
+ * The single-line height is what this must follow, never the box's own height:
233
+ * a multi-line draft grows the box, and a capsule at that height would balloon
234
+ * into an oval while the buttons stayed the same circles.
235
+ */
236
+ .composer-box {
237
+ /* Two axes, because they answer to different things. The vertical inset is
238
+ * structural — it plus the 32px control row IS the composer's height. The
239
+ * horizontal one is breathing room, free to be wider, and it alone decides
240
+ * the corner: a rounded rectangle turns along its SIDES, so the horizontal
241
+ * inset is the distance a corner arc has to clear. */
242
+ --chat-composer-inset-y: 0.5rem;
243
+ --chat-composer-inset-x: 0.875rem;
244
+ --chat-composer-control-h: calc(var(--chat-text, 1rem) * var(--chat-leading, 1.65) + 8px);
245
+ /* CONCENTRIC with the controls: their radius plus the inset that clears them.
246
+ * Derived rather than a second token, so a control radius change moves both
247
+ * arcs together and the corners cannot stop running parallel. */
248
+ border-radius: calc(var(--radius-control, 0.625rem) + var(--chat-composer-inset-y));
249
+ /* The horizontal inset is the TEXT's breathing room and is deliberately wider
250
+ * than the vertical one. The control row gives that difference back on both
251
+ * ends (`.composer-controls`), so a corner button keeps an equal gap to the
252
+ * bottom and the side while the field still starts clear of the edge. */
253
+ padding: var(--chat-composer-inset-y) var(--chat-composer-inset-x);
254
+ box-shadow: 0 0 0 2px color-mix(in oklab, var(--border) 55%, transparent);
255
+ transition: box-shadow 120ms ease;
256
+ }
257
+
258
+ .composer-box:focus-within {
259
+ box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 40%, transparent);
260
+ }
261
+
262
+
263
+ /**
264
+ * A queued prompt: its text is the edit control, its icons appear on approach.
265
+ *
266
+ * Written as CSS rather than a class list because the row needs two structural
267
+ * selectors — "every button EXCEPT the text one" and a reveal driven by the
268
+ * row's own hover — and as Tailwind variants that becomes a string no reviewer
269
+ * can read a mistake out of.
270
+ *
271
+ * The text button carries no visible affordance on purpose. It fills the row,
272
+ * so a background or an underline would draw a second box inside a box the
273
+ * queue already frames; the pointer and the tooltip are the invitation.
274
+ */
275
+ .queued-row {
276
+ display: flex;
277
+ gap: 0.25rem;
278
+ border-radius: var(--radius-lg, 0.5rem);
279
+ padding-block: 0.375rem;
280
+ padding-inline: 0.625rem 0.25rem;
281
+ font-size: 0.875rem;
282
+ line-height: 1.25rem;
283
+ background: color-mix(in oklab, var(--muted) 60%, transparent);
284
+ color: var(--muted-foreground);
285
+ }
286
+
287
+ /**
288
+ * The prompt text, which IS the edit control.
289
+ *
290
+ * Capped and scrollable rather than truncated: a queued prompt is a message the
291
+ * user has not seen sent, so hiding part of it is the failure the strip exists
292
+ * to prevent. The composer sits at the bottom of the transcript, so an
293
+ * uncapped one would grow upward into it.
294
+ */
295
+ .queued-row > button[data-action="edit"] {
296
+ min-width: 0;
297
+ flex: 1;
298
+ cursor: text;
299
+ text-align: start;
300
+ }
301
+
302
+ .queued-row > button[data-action="edit"] span {
303
+ display: block;
304
+ max-height: 5rem;
305
+ overflow-y: auto;
306
+ white-space: pre-line;
307
+ overflow-wrap: break-word;
308
+ }
309
+
310
+ .queued-row > button:not([data-action="edit"]) {
311
+ display: grid;
312
+ place-items: center;
313
+ block-size: 1.5rem;
314
+ inline-size: 1.5rem;
315
+ flex-shrink: 0;
316
+ border-radius: var(--radius-control, 0.375rem);
317
+ }
318
+
319
+ .queued-row > button:not([data-action="edit"]):hover {
320
+ background: var(--muted);
321
+ color: var(--foreground);
322
+ }
323
+
324
+ /**
325
+ * Aligned to the FIRST line, not to the row.
326
+ *
327
+ * `center` would float the icons into the middle of a wrapped prompt, away from
328
+ * the line they read as belonging to; the prompt is capped and scrolls, so the
329
+ * row's height is not the text's. `flex-start` plus a small negative offset
330
+ * puts the 24px button's centre on the 20px first line, which is right whether
331
+ * the prompt takes one line or ten.
332
+ */
333
+ .queued-row {
334
+ align-items: flex-start;
335
+ }
336
+
337
+ /**
338
+ * Revealed on hover AND on keyboard focus. Without `focus-within` the controls
339
+ * are unreachable without a mouse; `opacity` rather than `display` so the row
340
+ * does not reflow and shift the text under the pointer as they appear.
341
+ */
342
+ .queued-row > button:not([data-action="edit"]) {
343
+ margin-block-start: -2px;
344
+ opacity: 0;
345
+ transition: opacity 120ms ease;
346
+ }
347
+
348
+ .queued-row:hover > button:not([data-action="edit"]),
349
+ .queued-row:focus-within > button:not([data-action="edit"]) {
350
+ opacity: 1;
351
+ }
352
+
353
+ /**
354
+ * Dictation, while the microphone is open.
355
+ *
356
+ * The BUTTON is the indicator. A separate badge or a bar above the composer
357
+ * would be a second place to look for one fact, and the control the user just
358
+ * pressed is where they are already looking.
359
+ *
360
+ * The GLYPH tumbles, reusing `turn-mark-tumble` — the same motion the reasoning
361
+ * marker uses in the transcript, so one vocabulary covers both places this
362
+ * app says "still going". A pulse was tried first and is the wrong answer
363
+ * for the reason `turn-mark.css` already gives: every spinner on the web says
364
+ * "busy", while rotation has a direction, so a glance separates live from
365
+ * stalled.
366
+ *
367
+ * Only the icon turns, never the button: rotating the hit target would spin its
368
+ * focus ring and its hover plate with it.
369
+ */
370
+ .composer-mic[data-recording] > svg {
371
+ animation: turn-mark-tumble 1.6s linear infinite;
372
+ /* The glyph's own box, so the rotation is about its centre rather than about
373
+ * a corner of the grid cell. */
374
+ transform-origin: 50% 50%;
375
+ }
376
+
377
+ /**
378
+ * Recording OUTRANKS hover, and gets an edge of its own.
379
+ *
380
+ * `ComposerButton`'s neutral tone carries `hover:text-foreground`, which ties
381
+ * with `aria-pressed:text-destructive` on specificity — so source order decides
382
+ * and the glyph turns white under the pointer. The pointer is over this button
383
+ * almost by definition: the user just clicked it to start.
384
+ *
385
+ * Here rather than in the button's own tone string, which is shared by four
386
+ * controls across three packages. Red is not a hover state; it says the
387
+ * microphone is open, and that fact does not depend on where the pointer is.
388
+ *
389
+ * The RING bounds the spinning glyph. Recording swaps the mic for a 14px filled
390
+ * square, and a small square turning in open space reads as a loose fragment
391
+ * rather than as a control — the ring is what it turns inside. Static, because
392
+ * the glyph already carries the motion and a second moving element would say
393
+ * the same thing twice.
394
+ *
395
+ * `inset` so it paints inside the button's own box: an outward ring would sit
396
+ * on the neighbouring controls at this spacing, and `box-shadow` keeps it out
397
+ * of layout either way, so the row does not shift when recording starts.
398
+ */
399
+ .composer-mic[data-recording],
400
+ .composer-mic[data-recording]:hover {
401
+ color: var(--destructive, hsl(0 67% 60%));
402
+ box-shadow: inset 0 0 0 1.5px color-mix(in oklab, var(--destructive, hsl(0 67% 60%)) 45%, transparent);
403
+ }
404
+
405
+ @media (prefers-reduced-motion: reduce) {
406
+ /* Still, but not invisible: recording is the one state here with a privacy
407
+ * cost, and `--destructive` on the glyph carries it once motion is off — the
408
+ * same fallback the turn mark takes. */
409
+ .composer-mic[data-recording] > svg {
410
+ animation: none;
411
+ }
412
+ }
413
+
414
+ /* The tool sheet's rows.
415
+ *
416
+ * Keyboard and pointer converge on ONE cursor: arrow keys set `data-active`,
417
+ * and hovering a row adopts it, so the sheet never shows two highlights at
418
+ * once. `:hover` is not a second rule here for that reason — the component
419
+ * reports the hover upward and the same attribute paints it.
420
+ */
421
+ .composer-tool-row[data-active] {
422
+ background: var(--accent);
423
+ color: var(--accent-foreground);
424
+ }
425
+
426
+ /* The forget control shares the check's column, so a row never grows or
427
+ * reflows when it appears. Hidden until the row is hovered or the control
428
+ * itself is focused: keyboard-only users cannot hover, and a control that Tab
429
+ * can reach but the eye cannot find is worse than an absent one. */
430
+ .composer-tool-forget {
431
+ opacity: 0;
432
+ color: var(--muted-foreground);
433
+ transition: opacity 120ms ease-out;
434
+ }
435
+
436
+ .composer-tool-row:hover .composer-tool-forget,
437
+ .composer-tool-forget:focus-visible {
438
+ opacity: 1;
439
+ }
440
+
441
+ .composer-tool-forget:hover {
442
+ color: var(--foreground);
443
+ }
444
+
445
+ @media (prefers-reduced-motion: reduce) {
446
+ .composer-tool-forget {
447
+ transition: none;
448
+ }
449
+ }