@intentface/chat 0.1.2 → 0.2.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@ for building AI chat interfaces, with no styling of their own.
5
5
 
6
6
  This is the Base UI model applied to chat: **the package owns behavior, you own
7
7
  every class.** There is no pre-styled `@intentface/chat` package. The demos on
8
- each [docs](https://intentface.dev/docs) page show how the parts fit together
8
+ each [docs](https://ui.intentface.com) page show how the parts fit together
9
9
  and are meant to be copied and restyled.
10
10
 
11
11
  ## Status
@@ -101,7 +101,7 @@ directive and import fine anywhere.
101
101
  ## Docs
102
102
 
103
103
  Full guides, live demos, and the API reference at
104
- [intentface.dev/docs](https://intentface.dev/docs).
104
+ [ui.intentface.com](https://ui.intentface.com).
105
105
 
106
106
  ## License
107
107
 
@@ -9,12 +9,19 @@ export type ReadableNode = {
9
9
  getAttribute?: (name: string) => string | null;
10
10
  };
11
11
  export declare const toReadable: (node: Node) => ReadableNode;
12
+ /** The writer marks its padding <br>; the reader recognizes it structurally. */
13
+ export declare const PADDING_BREAK_ATTRIBUTE = "data-padding-break";
12
14
  export declare const readDocumentFromDom: (root: ReadableNode, resolveChip: (id: string) => ChipData | null, makeId?: () => string) => {
13
15
  doc: SegmentDoc;
14
16
  dirty: boolean;
15
17
  };
16
18
  /** Logical position of (node, offset), or null when the point isn't inside root. */
17
19
  export declare const logicalRangeFromDom: (root: ReadableNode, targetNode: ReadableNode, targetOffset: number) => number | null;
20
+ /** DOM point for a logical position, preferring text-node points. */
21
+ export declare const domPointFromLogical: (root: HTMLElement, position: number) => {
22
+ node: Node;
23
+ offset: number;
24
+ };
18
25
  export type DomNodeSpec = {
19
26
  kind: "text";
20
27
  text: string;
@@ -32,16 +39,12 @@ export declare const documentToDomSpec: (doc: SegmentDoc) => DomNodeSpec[];
32
39
  * chip content. Returns the live id → element map for the portal layer.
33
40
  */
34
41
  export declare const renderDocumentToDom: (root: HTMLElement, doc: SegmentDoc) => Map<string, HTMLElement>;
35
- /** DOM point for a logical position, preferring text-node points. */
36
- export declare const domPointFromLogical: (root: HTMLElement, position: number) => {
37
- node: Node;
38
- offset: number;
39
- };
40
42
  export declare const readSelectionRange: (root: HTMLElement) => {
41
43
  start: number;
42
44
  end: number;
43
45
  } | null;
44
46
  export declare const writeCaretToDom: (root: HTMLElement, start: number, end?: number) => void;
47
+ export declare const scrollCaretIntoView: (root: HTMLElement) => void;
45
48
  export type BadgeToken = {
46
49
  start: number;
47
50
  end: number;
@@ -52,4 +55,3 @@ export type BadgeToken = {
52
55
  * changed — the caller must restore the caret afterwards.
53
56
  */
54
57
  export declare const syncBadge: (root: HTMLElement, token: BadgeToken | null) => boolean;
55
- export declare const scrollCaretIntoView: (root: HTMLElement) => void;
@@ -3,14 +3,33 @@
3
3
  // minimal structural node view, so tests need no DOM); the materializers at
4
4
  // the bottom are thin real-DOM wrappers over the pure spec builder.
5
5
  //
6
+ // One taxonomy, three walkers: classifyNode below is the single answer to
7
+ // "what is this node?", and leafWidth the single answer to "how many logical
8
+ // positions does it occupy?". The reader, logicalRangeFromDom and
9
+ // domPointFromLogical all defer to them — when they disagree the caret and the
10
+ // model desync, so the rule lives in exactly one place.
11
+ //
6
12
  // Writer/reader pact for line breaks: every "\n" renders as <br>, plus one
7
13
  // padding <br> when the document is empty or ends with "\n" (an unpadded
8
- // trailing line is unreachable/zero-height). The reader inverts this by
9
- // dropping exactly one trailing "\n" when the last rendered node was a <br>.
10
- // Round-trips: "" <br>, "a\n" a<br><br>, "a\n\n" a<br><br><br>.
14
+ // trailing line is unreachable/zero-height). The padding carries
15
+ // data-padding-break, so the reader identifies it structurally rather than
16
+ // inferring it from position a native edit that strands it mid-document then
17
+ // reads as scaffolding (dropped, dirty) instead of a phantom newline. Unmarked
18
+ // trailing <br>s are the browser's own (Chrome keeps an emptied line box
19
+ // alive); the trailing trim covers those too.
20
+ // Round-trips: "" ↔ <br·pad>, "a\n" ↔ a<br><br·pad>, "a\n\n" ↔ a<br><br><br·pad>.
11
21
  import { nextChipId } from "./segments.js";
12
22
  const TEXT_NODE = 3;
13
23
  const ELEMENT_NODE = 1;
24
+ export const toReadable = (node) => node;
25
+ const childrenOf = (node) => Array.from(node.childNodes);
26
+ // ---------------------------------------------------------------------------
27
+ // Node taxonomy — the shared vocabulary. Every walker in this file switches on
28
+ // a NodeClass rather than re-deriving node kinds from attributes, so they
29
+ // cannot drift apart.
30
+ // ---------------------------------------------------------------------------
31
+ /** The writer marks its padding <br>; the reader recognizes it structurally. */
32
+ export const PADDING_BREAK_ATTRIBUTE = "data-padding-break";
14
33
  // Elements whose appearance inside the editable means something rewrote our
15
34
  // flat structure (paste leftovers, extensions) — content still reads, but the
16
35
  // document gets renormalized from the model afterwards.
@@ -30,26 +49,94 @@ const BLOCK_NODE_NAMES = new Set([
30
49
  "H6",
31
50
  "TABLE",
32
51
  ]);
33
- export const toReadable = (node) => node;
34
- const chipIdOf = (node) => node.nodeType === ELEMENT_NODE ? (node.getAttribute?.("data-chip-id") ?? null) : null;
35
- // Non-editable elements without a chip identity are pure presentation (the
36
- // badge's hint span) — invisible to the model: the reader skips them and the
37
- // position mappers give them zero width.
38
- const isPresentationOnly = (node) => node.nodeType === ELEMENT_NODE &&
39
- node.getAttribute?.("contenteditable") === "false" &&
40
- chipIdOf(node) === null;
41
- // ---------------------------------------------------------------------------
42
- // Reader DOM segments. Adjacent text concatenates (fragmentation is
43
- // normalized logically, never via root.normalize() — WebKit collapses the
44
- // caret when the selection's text node gets merged), NBSP reads as a plain
45
- // space, badge spans and unknown inline elements are transparent wrappers,
46
- // chip spans resolve through the registry.
47
- // ---------------------------------------------------------------------------
48
- export const readDocumentFromDom = (root, resolveChip, makeId = nextChipId) => {
52
+ // Precedence is load-bearing: a chip span is also contenteditable="false", so
53
+ // the chip test must come before the presentation test.
54
+ const classifyNode = (node) => {
55
+ if (node.nodeType === TEXT_NODE)
56
+ return { kind: "text", text: node.data ?? "" };
57
+ if (node.nodeType !== ELEMENT_NODE)
58
+ return { kind: "ignored" };
59
+ if (node.nodeName === "BR") {
60
+ const marked = (node.getAttribute?.(PADDING_BREAK_ATTRIBUTE) ?? null) !== null;
61
+ return marked ? { kind: "padding" } : { kind: "break" };
62
+ }
63
+ const chipId = node.getAttribute?.("data-chip-id") ?? null;
64
+ if (chipId !== null)
65
+ return { kind: "chip", id: chipId };
66
+ if (node.getAttribute?.("contenteditable") === "false")
67
+ return { kind: "presentation" };
68
+ if (BLOCK_NODE_NAMES.has(node.nodeName))
69
+ return { kind: "block" };
70
+ return { kind: "wrapper" };
71
+ };
72
+ /**
73
+ * Logical positions a leaf occupies. Containers are the sum of their children,
74
+ * so they are not leaves and answer 0 here — the walkers descend instead.
75
+ */
76
+ const leafWidth = (classified) => {
77
+ switch (classified.kind) {
78
+ case "text":
79
+ return classified.text.length;
80
+ case "break":
81
+ case "chip":
82
+ return 1;
83
+ default:
84
+ return 0;
85
+ }
86
+ };
87
+ const tokenizeDom = (root) => {
88
+ const tokens = [];
89
+ let dirty = false;
90
+ const descend = (node) => {
91
+ for (const child of childrenOf(node))
92
+ visit(child);
93
+ };
94
+ const visit = (node) => {
95
+ const classified = classifyNode(node);
96
+ switch (classified.kind) {
97
+ case "text":
98
+ // Empty text nodes are editing residue — dropping them keeps the
99
+ // padding rules below looking at real neighbours.
100
+ if (classified.text.length > 0)
101
+ tokens.push(classified);
102
+ return;
103
+ case "break":
104
+ case "padding":
105
+ tokens.push({ kind: classified.kind });
106
+ return;
107
+ case "chip":
108
+ tokens.push(classified);
109
+ return;
110
+ case "presentation":
111
+ case "ignored":
112
+ return;
113
+ case "block":
114
+ dirty = true;
115
+ descend(node);
116
+ return;
117
+ case "wrapper":
118
+ descend(node);
119
+ return;
120
+ }
121
+ };
122
+ descend(root);
123
+ return { tokens, dirty };
124
+ };
125
+ /**
126
+ * Drop the writer's trailing line-box scaffolding: our own marked padding, or
127
+ * an unmarked <br> the browser added to keep an emptied last line alive. Any
128
+ * padding token that survives this was stranded mid-document.
129
+ */
130
+ const trimTrailingPadding = (tokens) => {
131
+ const last = tokens.at(-1);
132
+ if (last?.kind === "padding" || last?.kind === "break")
133
+ return tokens.slice(0, -1);
134
+ return tokens;
135
+ };
136
+ const foldTokens = (tokens, resolveChip, makeId) => {
49
137
  const doc = [];
50
138
  let dirty = false;
51
139
  let pendingText = "";
52
- let lastWasBreak = false;
53
140
  const seenChipIds = new Set();
54
141
  const flushText = () => {
55
142
  if (pendingText.length === 0)
@@ -63,140 +150,156 @@ export const readDocumentFromDom = (root, resolveChip, makeId = nextChipId) => {
63
150
  }
64
151
  pendingText = "";
65
152
  };
66
- const visit = (node) => {
67
- if (node.nodeType === TEXT_NODE) {
68
- const content = (node.data ?? "").replace(/\u00A0/g, " ");
69
- if (content.length > 0) {
70
- pendingText += content;
71
- lastWasBreak = false;
72
- }
73
- return;
74
- }
75
- if (node.nodeType !== ELEMENT_NODE)
76
- return;
77
- if (node.nodeName === "BR") {
78
- pendingText += "\n";
79
- lastWasBreak = true;
80
- return;
81
- }
82
- const chipId = chipIdOf(node);
83
- if (chipId !== null) {
84
- // Chip span content is presentation — never read. Unknown ids are
85
- // leftovers from DOM the model doesn't know (mark dirty, skip);
86
- // duplicated ids (clone paths) get a fresh identity.
87
- const chip = resolveChip(chipId);
88
- if (!chip) {
153
+ for (const token of trimTrailingPadding(tokens)) {
154
+ switch (token.kind) {
155
+ case "text":
156
+ // Adjacent text concatenates logically, never via root.normalize()
157
+ // WebKit collapses the caret when the selection's node gets merged.
158
+ pendingText += token.text.replace(/\u00A0/g, " ");
159
+ break;
160
+ case "break":
161
+ pendingText += "\n";
162
+ break;
163
+ case "padding":
164
+ // Survived the trim, so it is not the trailing scaffolding: a native
165
+ // edit stranded it mid-document. Not content — repaint from the model.
89
166
  dirty = true;
90
- return;
167
+ break;
168
+ case "chip": {
169
+ // Unknown ids are leftovers from DOM the model doesn't know (mark
170
+ // dirty, skip); duplicated ids (clone paths) get a fresh identity.
171
+ const chip = resolveChip(token.id);
172
+ if (!chip) {
173
+ dirty = true;
174
+ break;
175
+ }
176
+ flushText();
177
+ const id = seenChipIds.has(token.id) ? makeId() : token.id;
178
+ seenChipIds.add(token.id);
179
+ doc.push({ type: "chip", id, chip });
180
+ break;
91
181
  }
92
- flushText();
93
- const id = seenChipIds.has(chipId) ? makeId() : chipId;
94
- seenChipIds.add(chipId);
95
- doc.push({ type: "chip", id, chip });
96
- lastWasBreak = false;
97
- return;
98
182
  }
99
- // Presentation-only elements (the badge's hint span) never reach the model.
100
- if (isPresentationOnly(node))
101
- return;
102
- // Badge span / unknown inline: transparent wrapper. Block elements also
103
- // read through, but flag the document for renormalization.
104
- if (BLOCK_NODE_NAMES.has(node.nodeName))
105
- dirty = true;
106
- for (const child of Array.from(node.childNodes))
107
- visit(child);
108
- };
109
- for (const child of Array.from(root.childNodes))
110
- visit(child);
111
- // Invert the writer's padding <br>: one trailing "\n" is the padding, not
112
- // content — but only when a <br> was actually the last rendered node.
113
- if (lastWasBreak && pendingText.endsWith("\n")) {
114
- pendingText = pendingText.slice(0, -1);
115
183
  }
116
184
  flushText();
117
185
  return { doc, dirty };
118
186
  };
187
+ export const readDocumentFromDom = (root, resolveChip, makeId = nextChipId) => {
188
+ const { tokens, dirty: structureDirty } = tokenizeDom(root);
189
+ const { doc, dirty: contentDirty } = foldTokens(tokens, resolveChip, makeId);
190
+ return { doc, dirty: structureDirty || contentDirty };
191
+ };
119
192
  // ---------------------------------------------------------------------------
120
- // Position mapping — DOM points ↔ logical positions. Chips count 1, <br>
121
- // counts 1 (it renders a "\n"), badge/unknown wrappers are transparent.
193
+ // Position mapping — DOM points ↔ logical positions, inverses of each other
194
+ // over leafWidth. Chips count 1, a real <br> counts 1 (it renders a "\n"),
195
+ // padding and presentation count 0, wrappers are transparent.
122
196
  // ---------------------------------------------------------------------------
123
197
  /** Logical position of (node, offset), or null when the point isn't inside root. */
124
198
  export const logicalRangeFromDom = (root, targetNode, targetOffset) => {
125
199
  let position = 0;
126
200
  let found = null;
127
- const visit = (node) => {
128
- if (node === targetNode && node.nodeType !== TEXT_NODE && chipIdOf(node) === null) {
129
- // Element point: offset is a child index — resolve by walking children
130
- // until the index, then record.
131
- const children = Array.from(node.childNodes).slice(0, targetOffset);
132
- for (const child of children) {
133
- if (measure(child))
134
- return true;
135
- }
136
- found = position;
137
- return true;
138
- }
139
- if (node.nodeType === TEXT_NODE) {
140
- if (node === targetNode) {
141
- found = position + Math.min(targetOffset, (node.data ?? "").length);
142
- return true;
143
- }
144
- position += (node.data ?? "").length;
145
- return false;
146
- }
147
- if (node.nodeType !== ELEMENT_NODE)
148
- return false;
149
- if (node.nodeName === "BR") {
150
- position += 1;
151
- return false;
152
- }
153
- if (chipIdOf(node) !== null) {
154
- // A point inside a chip clamps to the chip's start.
155
- if (node === targetNode || contains(node, targetNode)) {
156
- found = position;
157
- return true;
158
- }
159
- position += 1;
160
- return false;
161
- }
162
- // Presentation-only elements are zero-width; a point inside one clamps to
163
- // its boundary.
164
- if (isPresentationOnly(node)) {
165
- if (node === targetNode || contains(node, targetNode)) {
166
- found = position;
167
- return true;
168
- }
169
- return false;
170
- }
171
- for (const child of Array.from(node.childNodes)) {
201
+ const contains = (parent, target) => childrenOf(parent).some((child) => child === target || contains(child, target));
202
+ // Element point: the offset is a child index advance past that many
203
+ // children, then record where we landed.
204
+ const measureUpTo = (node, childIndex) => {
205
+ for (const child of childrenOf(node).slice(0, childIndex)) {
172
206
  if (visit(child))
173
207
  return true;
174
208
  }
175
209
  return false;
176
210
  };
177
- // Advance `position` past a whole subtree (used for element-point offsets).
178
- const measure = (node) => visit(node);
179
- const contains = (parent, target) => {
180
- for (const child of Array.from(parent.childNodes)) {
181
- if (child === target || contains(child, target))
211
+ const visit = (node) => {
212
+ const classified = classifyNode(node);
213
+ if (node === targetNode && classified.kind !== "text" && classified.kind !== "chip") {
214
+ if (measureUpTo(node, targetOffset))
182
215
  return true;
216
+ found = position;
217
+ return true;
218
+ }
219
+ switch (classified.kind) {
220
+ case "text":
221
+ if (node === targetNode) {
222
+ found = position + Math.min(targetOffset, classified.text.length);
223
+ return true;
224
+ }
225
+ position += leafWidth(classified);
226
+ return false;
227
+ case "break":
228
+ case "padding":
229
+ position += leafWidth(classified);
230
+ return false;
231
+ // A point inside a chip or a hint span clamps to that node's start; they
232
+ // differ only in width.
233
+ case "chip":
234
+ case "presentation":
235
+ if (node === targetNode || contains(node, targetNode)) {
236
+ found = position;
237
+ return true;
238
+ }
239
+ position += leafWidth(classified);
240
+ return false;
241
+ case "wrapper":
242
+ case "block":
243
+ for (const child of childrenOf(node)) {
244
+ if (visit(child))
245
+ return true;
246
+ }
247
+ return false;
248
+ case "ignored":
249
+ return false;
183
250
  }
184
- return false;
185
251
  };
186
252
  if (root === targetNode) {
187
- const children = Array.from(root.childNodes).slice(0, targetOffset);
188
- for (const child of children) {
189
- if (measure(child))
190
- return found;
191
- }
192
- return position;
253
+ return measureUpTo(root, targetOffset) ? found : position;
193
254
  }
194
- for (const child of Array.from(root.childNodes)) {
255
+ for (const child of childrenOf(root)) {
195
256
  if (visit(child))
196
257
  return found;
197
258
  }
198
259
  return found;
199
260
  };
261
+ /** DOM point for a logical position, preferring text-node points. */
262
+ export const domPointFromLogical = (root, position) => {
263
+ let remaining = position;
264
+ const walk = (parent) => {
265
+ for (let index = 0; index < parent.childNodes.length; index++) {
266
+ const child = parent.childNodes[index];
267
+ if (!child)
268
+ continue;
269
+ const classified = classifyNode(toReadable(child));
270
+ switch (classified.kind) {
271
+ case "text": {
272
+ const width = leafWidth(classified);
273
+ if (remaining <= width)
274
+ return { node: child, offset: remaining };
275
+ remaining -= width;
276
+ continue;
277
+ }
278
+ // Atomic children: the point is the gap before them. Padding is
279
+ // zero-width, so the caret can never be written past the model's end.
280
+ case "break":
281
+ case "padding":
282
+ case "chip":
283
+ if (remaining === 0)
284
+ return { node: parent, offset: index };
285
+ remaining -= leafWidth(classified);
286
+ continue;
287
+ case "presentation":
288
+ case "ignored":
289
+ continue;
290
+ case "wrapper":
291
+ case "block": {
292
+ const inner = walk(child);
293
+ if (inner)
294
+ return inner;
295
+ continue;
296
+ }
297
+ }
298
+ }
299
+ return remaining === 0 ? { node: parent, offset: parent.childNodes.length } : null;
300
+ };
301
+ return walk(root) ?? { node: root, offset: root.childNodes.length };
302
+ };
200
303
  export const documentToDomSpec = (doc) => {
201
304
  const specs = [];
202
305
  for (const segment of doc) {
@@ -236,6 +339,12 @@ const createChipSpan = (id) => {
236
339
  span.style.whiteSpace = "normal";
237
340
  return span;
238
341
  };
342
+ const createBreak = (padding) => {
343
+ const br = document.createElement("br");
344
+ if (padding)
345
+ br.setAttribute(PADDING_BREAK_ATTRIBUTE, "");
346
+ return br;
347
+ };
239
348
  /**
240
349
  * Render the canonical child list. Existing chip spans are reused by id —
241
350
  * moving a node preserves its React portal; recreating it would remount the
@@ -254,7 +363,7 @@ export const renderDocumentToDom = (root, doc) => {
254
363
  if (spec.kind === "text")
255
364
  return document.createTextNode(spec.text);
256
365
  if (spec.kind === "br")
257
- return document.createElement("br");
366
+ return createBreak(spec.padding);
258
367
  const span = existingSpans.get(spec.id) ?? createChipSpan(spec.id);
259
368
  // Without an accessible boundary the chip reads as bare prose inside the
260
369
  // textbox — label + type gives AT an atomic token ("Rasmus, @ mention").
@@ -267,42 +376,10 @@ export const renderDocumentToDom = (root, doc) => {
267
376
  root.replaceChildren(...children);
268
377
  return chipElements;
269
378
  };
270
- /** DOM point for a logical position, preferring text-node points. */
271
- export const domPointFromLogical = (root, position) => {
272
- let remaining = position;
273
- const walk = (parent) => {
274
- for (let index = 0; index < parent.childNodes.length; index++) {
275
- const child = parent.childNodes[index];
276
- if (!child)
277
- continue;
278
- if (child.nodeType === TEXT_NODE) {
279
- const length = child.data.length;
280
- if (remaining <= length)
281
- return { node: child, offset: remaining };
282
- remaining -= length;
283
- continue;
284
- }
285
- if (child.nodeType !== ELEMENT_NODE)
286
- continue;
287
- const element = child;
288
- if (element.nodeName === "BR" || element.hasAttribute("data-chip-id")) {
289
- if (remaining === 0)
290
- return { node: parent, offset: index };
291
- remaining -= 1;
292
- continue;
293
- }
294
- // Presentation-only (the badge's hint span): zero width, never a target.
295
- if (isPresentationOnly(toReadable(element)))
296
- continue;
297
- // Transparent wrapper (badge span): descend.
298
- const inner = walk(element);
299
- if (inner)
300
- return inner;
301
- }
302
- return remaining === 0 ? { node: parent, offset: parent.childNodes.length } : null;
303
- };
304
- return walk(root) ?? { node: root, offset: root.childNodes.length };
305
- };
379
+ // ---------------------------------------------------------------------------
380
+ // Selection reading the caret out of the document and writing it back, plus
381
+ // keeping it visible after a programmatic write.
382
+ // ---------------------------------------------------------------------------
306
383
  export const readSelectionRange = (root) => {
307
384
  const selection = root.ownerDocument.getSelection();
308
385
  if (!selection || selection.rangeCount === 0)
@@ -324,6 +401,30 @@ export const writeCaretToDom = (root, start, end = start) => {
324
401
  const focus = end === start ? anchor : domPointFromLogical(root, end);
325
402
  selection.setBaseAndExtent(anchor.node, anchor.offset, focus.node, focus.offset);
326
403
  };
404
+ // Native typing auto-scrolls; programmatic writes don't. Nudge only the nearest
405
+ // scrollable ancestor (never scrollIntoView — it yanks the whole page).
406
+ export const scrollCaretIntoView = (root) => {
407
+ const selection = root.ownerDocument.getSelection();
408
+ if (!selection || selection.rangeCount === 0)
409
+ return;
410
+ const rect = selection.getRangeAt(0).getBoundingClientRect();
411
+ let scroller = root;
412
+ while (scroller && scroller !== root.ownerDocument.body) {
413
+ const { overflowY } = getComputedStyle(scroller);
414
+ if ((overflowY === "auto" || overflowY === "scroll") &&
415
+ scroller.scrollHeight > scroller.clientHeight) {
416
+ break;
417
+ }
418
+ scroller = scroller.parentElement;
419
+ }
420
+ if (!scroller || scroller === root.ownerDocument.body)
421
+ return;
422
+ const view = scroller.getBoundingClientRect();
423
+ if (rect.top < view.top)
424
+ scroller.scrollTop -= view.top - rect.top;
425
+ else if (rect.bottom > view.bottom)
426
+ scroller.scrollTop += rect.bottom - view.bottom;
427
+ };
327
428
  const badgeSpansOf = (root) => [
328
429
  ...root.querySelectorAll("[data-command-badge]"),
329
430
  ];
@@ -334,7 +435,7 @@ const unwrapBadgeSpan = (span) => {
334
435
  // Presentation children (the hint span) are the badge's own chrome — they
335
436
  // die with it rather than spilling into content.
336
437
  for (const child of [...span.children]) {
337
- if (isPresentationOnly(toReadable(child)))
438
+ if (classifyNode(toReadable(child)).kind === "presentation")
338
439
  child.remove();
339
440
  }
340
441
  while (span.firstChild)
@@ -402,30 +503,3 @@ export const syncBadge = (root, token) => {
402
503
  range.insertNode(span);
403
504
  return true;
404
505
  };
405
- // ---------------------------------------------------------------------------
406
- // Caret visibility — native typing auto-scrolls; programmatic writes don't.
407
- // Nudge only the nearest scrollable ancestor (never scrollIntoView — it
408
- // yanks the whole page).
409
- // ---------------------------------------------------------------------------
410
- export const scrollCaretIntoView = (root) => {
411
- const selection = root.ownerDocument.getSelection();
412
- if (!selection || selection.rangeCount === 0)
413
- return;
414
- const rect = selection.getRangeAt(0).getBoundingClientRect();
415
- let scroller = root;
416
- while (scroller && scroller !== root.ownerDocument.body) {
417
- const { overflowY } = getComputedStyle(scroller);
418
- if ((overflowY === "auto" || overflowY === "scroll") &&
419
- scroller.scrollHeight > scroller.clientHeight) {
420
- break;
421
- }
422
- scroller = scroller.parentElement;
423
- }
424
- if (!scroller || scroller === root.ownerDocument.body)
425
- return;
426
- const view = scroller.getBoundingClientRect();
427
- if (rect.top < view.top)
428
- scroller.scrollTop -= view.top - rect.top;
429
- else if (rect.bottom > view.bottom)
430
- scroller.scrollTop += rect.bottom - view.bottom;
431
- };
@@ -6,7 +6,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
6
6
  // presence attributes (data-open / data-closed) generated from state, plus
7
7
  // the animation contract: data-starting-style on the panel's first open
8
8
  // frame, data-ending-style while its exit animations run (hide/unmount waits
9
- // for them), and the panel's natural height as --collapsible-panel-height.
9
+ // for them), and the panel's natural height as --panel-height. That value is
10
+ // released once the open transition settles, so an open panel falls back to
11
+ // `auto` and tracks content that grows underneath it.
10
12
  // Every part supports the Base UI render prop.
11
13
  import { createContext, use, useCallback, useId, useRef, useState, } from "react";
12
14
  import { useOpenTransition } from "./render/transition.js";
@@ -76,9 +78,10 @@ const CollapsiblePanel = ({ keepMounted = false, className, render, style, ...el
76
78
  {
77
79
  id: panelId,
78
80
  hidden,
79
- style: height !== null
80
- ? { "--collapsible-panel-height": `${height}px` }
81
- : undefined,
81
+ // Once the open transition settles the height is released back to null, so the
82
+ // property stops being written and a `height: var(…)` consumer falls back to auto
83
+ // — that is what lets an open panel track content that grows.
84
+ style: height !== null ? { "--panel-height": `${height}px` } : undefined,
82
85
  },
83
86
  elementProps,
84
87
  ],
@@ -35,12 +35,6 @@ export type UseOpenTransitionOptions = {
35
35
  /** Called when the close animation completes, alongside the internal unmount. */
36
36
  onClosed?: () => void;
37
37
  };
38
- /**
39
- * Drives a Base UI-style open/close for an element: keeps it `mounted` through the exit,
40
- * reports `transitionStatus` (feed it to a stateAttributesMapping for data-open/closed/
41
- * starting-style/ending-style), optionally measures its natural `height`, and unmounts once
42
- * `element.getAnimations()` resolve. Composes useTransitionStatus + useOpenChangeComplete.
43
- */
44
38
  export declare const useOpenTransition: (open: boolean, ref: RefObject<HTMLElement | null>, { measureHeight, onClosed }?: UseOpenTransitionOptions) => {
45
39
  mounted: boolean;
46
40
  transitionStatus: TransitionStatus;
@@ -176,24 +176,67 @@ export const useOpenChangeComplete = ({ enabled = true, open, ref, onComplete: o
176
176
  * starting-style/ending-style), optionally measures its natural `height`, and unmounts once
177
177
  * `element.getAnimations()` resolve. Composes useTransitionStatus + useOpenChangeComplete.
178
178
  */
179
+ // Inline alignment can distort a scroll-based measurement, so it is neutralized for the read
180
+ // and restored immediately after (Base UI does the same before measuring a collapsible).
181
+ const ALIGNMENT_PROPERTIES = ["justify-content", "align-items", "align-content", "justify-items"];
182
+ const measureNaturalHeight = (element) => {
183
+ const saved = ALIGNMENT_PROPERTIES.map((property) => [
184
+ property,
185
+ element.style.getPropertyValue(property),
186
+ element.style.getPropertyPriority(property),
187
+ ]);
188
+ for (const [property] of saved) {
189
+ element.style.setProperty(property, "initial", "important");
190
+ }
191
+ const naturalHeight = element.scrollHeight;
192
+ for (const [property, value, priority] of saved) {
193
+ if (value)
194
+ element.style.setProperty(property, value, priority);
195
+ else
196
+ element.style.removeProperty(property);
197
+ }
198
+ return naturalHeight;
199
+ };
179
200
  export const useOpenTransition = (open, ref, { measureHeight = false, onClosed } = {}) => {
180
- const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
201
+ // Both flags on: `idle` is the settled-open status the height release gates on, and
202
+ // deferring `ending` by a frame leaves one frame where a closing panel is still at its
203
+ // open size — which is where the close has to be measured.
204
+ const { mounted, setMounted, transitionStatus } = useTransitionStatus(open, true, true);
181
205
  const [height, setHeight] = useState(null);
182
- // Measure on the transitional frames (before the CSS transition runs). scrollHeight is the
183
- // natural content height regardless of any clamp the data-starting/ending-style sets.
184
206
  useIsoLayoutEffect(() => {
185
207
  if (!measureHeight)
186
208
  return;
187
- if (transitionStatus === "starting" || transitionStatus === "ending") {
188
- const element = ref.current;
189
- if (element)
190
- setHeight(element.scrollHeight);
209
+ const element = ref.current;
210
+ if (!element)
211
+ return;
212
+ // Closing: measure on the deferred frame. By the `ending` frame the consumer's closed
213
+ // styles have landed, so we would measure the clamped box instead of the natural one.
214
+ if (!open && mounted && (transitionStatus === "idle" || transitionStatus === "starting")) {
215
+ setHeight(measureNaturalHeight(element));
216
+ return;
191
217
  }
192
- }, [measureHeight, transitionStatus, ref]);
218
+ // Opening: measure on the first frame, before the transition runs.
219
+ if (open && transitionStatus === "starting") {
220
+ setHeight(measureNaturalHeight(element));
221
+ }
222
+ }, [measureHeight, open, mounted, transitionStatus, ref]);
223
+ // Release the measurement once the open transition settles, so the panel tracks content
224
+ // that grows while it is open. With `height` null the custom property is never written, so
225
+ // a consumer's `height: var(--…)` is invalid at computed-value time and falls back to
226
+ // `auto`. The close re-measures a pixel value first, so it still animates from a number.
227
+ useOpenChangeComplete({
228
+ open: true,
229
+ ref,
230
+ enabled: measureHeight && open && mounted && transitionStatus === "idle",
231
+ onComplete: () => setHeight(null),
232
+ });
193
233
  useOpenChangeComplete({
194
234
  open,
195
235
  ref,
196
- enabled: !open && mounted,
236
+ // Gated on `ending`, not merely `!open`: with the ending state deferred by a frame, an
237
+ // earlier check would call getAnimations() before the closed styles applied, find
238
+ // nothing running, and cut the exit off.
239
+ enabled: !open && mounted && transitionStatus === "ending",
197
240
  onComplete: () => {
198
241
  setMounted(false);
199
242
  onClosed?.();
@@ -1,18 +1,23 @@
1
1
  export declare const prefersReducedMotion: () => boolean;
2
2
  export declare const resolveScrollBehavior: (behavior: ScrollBehavior) => ScrollBehavior;
3
3
  export declare const scrollContainerTo: (el: HTMLElement, top: number, behavior: ScrollBehavior) => void;
4
+ export type ViewportBox = {
5
+ width: number;
6
+ height: number;
7
+ };
8
+ export declare const readViewportBox: (el: HTMLElement | null) => ViewportBox;
9
+ export declare const sameViewportBox: (a: ViewportBox, b: ViewportBox) => boolean;
4
10
  export declare const wasPrepended: (mutations: MutationRecord[], previousFirst: Element | null, content: HTMLElement) => boolean;
5
11
  export declare const DEFAULT_BOTTOM_OFFSET = 128;
6
- export declare const DEFAULT_DOCK_SELECTOR = "[data-composer-context-window], [data-composer-container]";
7
- export declare const queryDockParts: (root: HTMLElement, dockSelector: string) => Element[];
12
+ export declare const DOCK_SELECTOR = "[data-thread-composer]";
13
+ export declare const queryDock: (root: HTMLElement) => HTMLElement | null;
8
14
  /**
9
- * Height (px) to reserve at the bottom for the dock parts matching
10
- * `dockSelector` but NOT the command-list / ask-user panel. The dock is
11
- * bottom-anchored, so it sits in a fixed region while the panel grows upward
12
- * above it. The inset is measured from a single reference the bottom-most
13
- * match's top to the root's bottom not a sum of matches, so a taller part
14
- * stacked above must fit within COMPOSER_GAP. Returns null when no dock is
15
- * mounted yet.
15
+ * Height (px) to reserve at the bottom for the dock: its top edge down to the
16
+ * root's bottom, plus the content gap. Measured against the root's bottom
17
+ * rather than the slot's own height, so a dock that floats above the bottom
18
+ * edge still reserves the space beneath it. Parts that must NOT reserve space
19
+ * the command-list / ask-user panel, the scroll button are positioned out
20
+ * of the slot's flow. Returns null when the slot isn't mounted yet.
16
21
  */
17
- export declare const measureDockInset: (root: HTMLElement, dockSelector: string) => number | null;
22
+ export declare const measureDockInset: (root: HTMLElement) => number | null;
18
23
  export declare const measureTopInset: (root: HTMLElement) => number;
@@ -11,6 +11,8 @@ export const resolveScrollBehavior = (behavior) => behavior === "smooth" && pref
11
11
  export const scrollContainerTo = (el, top, behavior) => {
12
12
  el.scrollTo({ top, behavior: resolveScrollBehavior(behavior) });
13
13
  };
14
+ export const readViewportBox = (el) => el ? { width: el.clientWidth, height: el.clientHeight } : { width: 0, height: 0 };
15
+ export const sameViewportBox = (a, b) => a.width === b.width && a.height === b.height;
14
16
  // A prepend = rows were added, nothing removed, and the previously-first row
15
17
  // is still connected but no longer first (older history loading in above).
16
18
  export const wasPrepended = (mutations, previousFirst, content) => previousFirst?.isConnected === true &&
@@ -22,36 +24,23 @@ export const DEFAULT_BOTTOM_OFFSET = 128;
22
24
  // Breathing room between the last line of content and the composer dock. The
23
25
  // bottom overlay spans it in the styled layer.
24
26
  const COMPOSER_GAP = 32;
25
- export const DEFAULT_DOCK_SELECTOR = "[data-composer-context-window], [data-composer-container]";
26
- // `dockSelector` is public API, so it may be an invalid selector string.
27
- // Degrade to "no dock parts" rather than letting querySelectorAll throw a
28
- // SyntaxError inside the layout effect (which would crash the render).
29
- export const queryDockParts = (root, dockSelector) => {
30
- try {
31
- return [...root.querySelectorAll(dockSelector)];
32
- }
33
- catch {
34
- return [];
35
- }
36
- };
27
+ // The dock is the Thread.Composer slot — the one element the thread reserves
28
+ // space for. Null until it mounts.
29
+ export const DOCK_SELECTOR = "[data-thread-composer]";
30
+ export const queryDock = (root) => root.querySelector(DOCK_SELECTOR);
37
31
  /**
38
- * Height (px) to reserve at the bottom for the dock parts matching
39
- * `dockSelector` but NOT the command-list / ask-user panel. The dock is
40
- * bottom-anchored, so it sits in a fixed region while the panel grows upward
41
- * above it. The inset is measured from a single reference the bottom-most
42
- * match's top to the root's bottom not a sum of matches, so a taller part
43
- * stacked above must fit within COMPOSER_GAP. Returns null when no dock is
44
- * mounted yet.
32
+ * Height (px) to reserve at the bottom for the dock: its top edge down to the
33
+ * root's bottom, plus the content gap. Measured against the root's bottom
34
+ * rather than the slot's own height, so a dock that floats above the bottom
35
+ * edge still reserves the space beneath it. Parts that must NOT reserve space
36
+ * the command-list / ask-user panel, the scroll button are positioned out
37
+ * of the slot's flow. Returns null when the slot isn't mounted yet.
45
38
  */
46
- export const measureDockInset = (root, dockSelector) => {
47
- let dockTop = null;
48
- for (const part of queryDockParts(root, dockSelector)) {
49
- const top = part.getBoundingClientRect().top;
50
- if (dockTop === null || top > dockTop)
51
- dockTop = top;
52
- }
53
- if (dockTop === null)
39
+ export const measureDockInset = (root) => {
40
+ const dock = queryDock(root);
41
+ if (!dock)
54
42
  return null;
43
+ const dockTop = dock.getBoundingClientRect().top;
55
44
  return Math.round(root.getBoundingClientRect().bottom - dockTop + COMPOSER_GAP);
56
45
  };
57
46
  // Top inset reserved by the top overlay, measured straight off the rendered
@@ -37,17 +37,8 @@ export type ThreadRootProps = PrimitiveProps<"div"> & {
37
37
  * current, so leave it off unless older content actually loads in above.
38
38
  */
39
39
  preserveScrollOnPrepend?: boolean;
40
- /**
41
- * CSS selector for the bottom-docked parts the thread reserves space for.
42
- * Every match is observed for resize, but the reserved inset is measured
43
- * from a single reference — the bottom-most match's top edge, not a sum of
44
- * matches — so a taller part stacked above it must fit within the content
45
- * gap. Must be a valid CSS selector. Defaults to the styled composer's dock
46
- * slots.
47
- */
48
- dockSelector?: string;
49
40
  };
50
- export declare const ThreadRoot: ({ autoScroll, preserveScrollOnPrepend, dockSelector, className, render, style, ...elementProps }: ThreadRootProps) => import("react").JSX.Element;
41
+ export declare const ThreadRoot: ({ autoScroll, preserveScrollOnPrepend, className, render, style, ...elementProps }: ThreadRootProps) => import("react").JSX.Element;
51
42
  export type ThreadOverlayState = {
52
43
  /** Which edge this overlay marks, surfaced as data-thread-overlay. */
53
44
  direction: "top" | "bottom";
@@ -12,7 +12,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
12
12
  import { createContext, memo, use, useCallback, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore, } from "react";
13
13
  import { useRefWithInit } from "../internal/render/useRefWithInit.js";
14
14
  import { useRenderElement } from "../internal/render/useRenderElement.js";
15
- import { DEFAULT_BOTTOM_OFFSET, DEFAULT_DOCK_SELECTOR, measureDockInset, measureTopInset, queryDockParts, resolveScrollBehavior, scrollContainerTo, wasPrepended, } from "./geometry.js";
15
+ import { DEFAULT_BOTTOM_OFFSET, measureDockInset, measureTopInset, queryDock, readViewportBox, resolveScrollBehavior, sameViewportBox, scrollContainerTo, wasPrepended, } from "./geometry.js";
16
16
  import { createEdgeStore, createVisibilityStore, EMPTY_VISIBILITY, } from "./stores.js";
17
17
  // Latest-ref: read the current value from long-lived effects/callbacks without
18
18
  // re-subscribing them when it changes.
@@ -299,14 +299,24 @@ const useThreadScroll = (rootRef, mode, preserveScrollOnPrepend) => {
299
299
  // late, so a position check here would scroll on stale "at bottom" the
300
300
  // instant after the user wheels up, hijacking their scroll and re-arming
301
301
  // the follow in a loop they can't escape.
302
+ //
303
+ // A resize alone can't say whether content grew or the viewport did: the
304
+ // reserve tracks --thread-turn-area, so a container animating open resizes
305
+ // `content` every frame. Only a viewport change moves the scroller's own
306
+ // box — re-pin those instantly instead of smooth-scrolling a moving target
307
+ // for the length of the animation.
308
+ let viewport = readViewportBox(scrollRef.current);
302
309
  const follow = () => {
310
+ const previous = viewport;
311
+ viewport = readViewportBox(scrollRef.current);
312
+ const resized = !sameViewportBox(previous, viewport);
303
313
  if (skipNextResize) {
304
314
  skipNextResize = false;
305
315
  return;
306
316
  }
307
- if (followingRef.current) {
308
- scrollToBottom("smooth");
309
- }
317
+ if (!followingRef.current)
318
+ return;
319
+ scrollToBottom(resized ? "instant" : "smooth");
310
320
  };
311
321
  land();
312
322
  const turns = new MutationObserver(land);
@@ -517,32 +527,34 @@ const useThreadScroll = (rootRef, mode, preserveScrollOnPrepend) => {
517
527
  * (--thread-turn-min-height) to it; otherwise the reserve falls back to 0.
518
528
  * Recomputes only on root (window) / composer-dock resize — never per token.
519
529
  */
520
- const useThreadInsets = (rootRef, dockSelector) => {
530
+ const useThreadInsets = (rootRef) => {
521
531
  useLayoutEffect(() => {
522
532
  const root = rootRef.current;
523
533
  if (!root)
524
534
  return;
535
+ // Read both insets before writing either property: a write followed by a forced
536
+ // layout read dips scrollHeight for a frame, and that scrollTop clamp is final.
525
537
  const apply = () => {
526
- const bottomInset = measureDockInset(root, dockSelector);
538
+ const bottomInset = measureDockInset(root);
539
+ const topInset = measureTopInset(root);
540
+ const area = Math.max(0, Math.round(root.clientHeight - topInset - (bottomInset ?? DEFAULT_BOTTOM_OFFSET)));
527
541
  if (bottomInset !== null) {
528
542
  root.style.setProperty("--thread-overlay-bottom-height", `${bottomInset}px`);
529
543
  }
530
- const topInset = measureTopInset(root);
531
- const area = Math.max(0, Math.round(root.clientHeight - topInset - (bottomInset ?? DEFAULT_BOTTOM_OFFSET)));
532
544
  root.style.setProperty("--thread-turn-area", `${area}px`);
533
545
  };
534
546
  apply();
535
547
  const observer = new ResizeObserver(apply);
536
548
  observer.observe(root);
537
- for (const part of queryDockParts(root, dockSelector)) {
538
- observer.observe(part);
539
- }
549
+ const dock = queryDock(root);
550
+ if (dock)
551
+ observer.observe(dock);
540
552
  return () => observer.disconnect();
541
- }, [dockSelector, rootRef]);
553
+ }, [rootRef]);
542
554
  };
543
- export const ThreadRoot = ({ autoScroll = "follow", preserveScrollOnPrepend = false, dockSelector = DEFAULT_DOCK_SELECTOR, className, render, style, ...elementProps }) => {
555
+ export const ThreadRoot = ({ autoScroll = "follow", preserveScrollOnPrepend = false, className, render, style, ...elementProps }) => {
544
556
  const rootRef = useRef(null);
545
- useThreadInsets(rootRef, dockSelector);
557
+ useThreadInsets(rootRef);
546
558
  const scroll = useThreadScroll(rootRef, autoScroll, preserveScrollOnPrepend);
547
559
  const element = useRenderElement("div", { className, render, style }, {
548
560
  ref: rootRef,
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@intentface/chat",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Headless chat UI primitives — unstyled compound components, hooks, and wire formats for building AI chat interfaces.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
8
  "author": "Intentface",
9
- "homepage": "https://intentface.dev",
9
+ "homepage": "https://ui.intentface.com",
10
10
  "repository": {
11
11
  "type": "git",
12
12
  "url": "git+https://github.com/Intentface/intentface-chat.git",
@@ -80,8 +80,8 @@
80
80
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/add-dist-extensions.mjs && node scripts/smoke-dist.mjs",
81
81
  "test": "bun test",
82
82
  "typecheck": "tsc --noEmit",
83
- "prepack": "bun run build && node scripts/swap-exports.mjs pre",
84
- "postpack": "node scripts/swap-exports.mjs post"
83
+ "verify:release": "bun run typecheck && bun test && bun run build && publint",
84
+ "prepack": "bun run build"
85
85
  },
86
86
  "dependencies": {
87
87
  "@floating-ui/dom": "^1.7.6",
@@ -98,6 +98,7 @@
98
98
  "@types/react": "^19",
99
99
  "@types/react-dom": "^19",
100
100
  "axe-core": "^4.12.1",
101
+ "publint": "0.3.23",
101
102
  "typescript": "^5"
102
103
  },
103
104
  "publishConfig": {