@bitforgehq/ui 0.4.0 → 0.5.0-next.3

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 (80) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/README.md +12 -12
  3. package/ai/MANIFEST.index.json +65 -2
  4. package/ai/MANIFEST.json +2221 -737
  5. package/ai/REFERENCE.index.json +161 -0
  6. package/ai/REFERENCE.json +1336 -27
  7. package/ai/UI_GUIDE.md +61 -20
  8. package/ai/components/app-frame.md +1 -1
  9. package/ai/components/chat-composer.md +357 -0
  10. package/ai/components/chat-message.md +251 -0
  11. package/ai/components/chat-thread.md +521 -0
  12. package/ai/components/checkbox.md +7 -0
  13. package/ai/components/combobox.md +12 -0
  14. package/ai/components/date-input.md +9 -0
  15. package/ai/components/datetime-input.md +2 -0
  16. package/ai/components/field.md +10 -0
  17. package/ai/components/file-input.md +498 -0
  18. package/ai/components/header.md +1 -1
  19. package/ai/components/image-input.md +368 -0
  20. package/ai/components/image-viewer.md +309 -0
  21. package/ai/components/media-card.md +478 -0
  22. package/ai/components/menu-button.md +2 -0
  23. package/ai/components/menu.md +10 -0
  24. package/ai/components/multi-combobox.md +7 -0
  25. package/ai/components/nav-group.md +10 -3
  26. package/ai/components/nav-item.md +11 -2
  27. package/ai/components/number-input.md +2 -7
  28. package/ai/components/popover.md +13 -0
  29. package/ai/components/radio-group.md +6 -0
  30. package/ai/components/segmented.md +14 -2
  31. package/ai/components/select.md +100 -42
  32. package/ai/components/sidebar-toggle.md +16 -9
  33. package/ai/components/sidebar.md +41 -13
  34. package/ai/components/tab-group.md +18 -2
  35. package/ai/components/table.md +17 -2
  36. package/ai/components/theme-menu-button.md +1 -0
  37. package/ai/components/time-input.md +2 -0
  38. package/ai/components/toolbar.md +7 -18
  39. package/ai/reference/bf-chat-composer-harness.md +64 -0
  40. package/ai/reference/bf-chat-message-role.md +27 -0
  41. package/ai/reference/bf-chat-message-status.md +28 -0
  42. package/ai/reference/bf-fade-in-directive.md +18 -0
  43. package/ai/reference/bf-file-input-harness.md +67 -0
  44. package/ai/reference/bf-file-input-variant.md +27 -0
  45. package/ai/reference/bf-image-input-harness.md +67 -0
  46. package/ai/reference/bf-image-input-shape.md +27 -0
  47. package/ai/reference/bf-image-viewer-actions-context.md +29 -0
  48. package/ai/reference/bf-image-viewer-harness.md +87 -0
  49. package/ai/reference/bf-image-viewer-image.md +25 -0
  50. package/ai/reference/bf-image-viewer-options.md +33 -0
  51. package/ai/reference/bf-image-viewer-origin.md +23 -0
  52. package/ai/reference/bf-image-viewer-ref.md +55 -0
  53. package/ai/reference/bf-image-viewer-service.md +32 -0
  54. package/ai/reference/bf-labels.md +105 -75
  55. package/ai/reference/bf-media-card-aspect.md +28 -0
  56. package/ai/reference/bf-media-card-harness.md +66 -0
  57. package/ai/reference/bf-motion-list-directive.md +22 -0
  58. package/ai/reference/bf-prose-directive.md +16 -0
  59. package/ai/reference/bf-rejected-file-reason.md +28 -0
  60. package/ai/reference/bf-rejected-file.md +28 -0
  61. package/ai/reference/bf-select-harness.md +14 -5
  62. package/ai/reference/bf-select-option.md +2 -0
  63. package/ai/reference/bf-size.md +3 -0
  64. package/ai/reference/bf_table_sort_select.md +23 -0
  65. package/ai/reference/provide-bf-labels.md +10 -3
  66. package/ai/reference/provide-bf-select.md +20 -0
  67. package/bin/bf-ui.js +1 -0
  68. package/fesm2022/bitforgehq-ui-overlay.mjs +2649 -606
  69. package/fesm2022/bitforgehq-ui-overlay.mjs.map +1 -1
  70. package/fesm2022/bitforgehq-ui-testing.mjs +356 -10
  71. package/fesm2022/bitforgehq-ui-testing.mjs.map +1 -1
  72. package/fesm2022/bitforgehq-ui.mjs +7531 -3989
  73. package/fesm2022/bitforgehq-ui.mjs.map +1 -1
  74. package/llms-full.txt +4151 -130
  75. package/llms.txt +31 -1
  76. package/package.json +1 -1
  77. package/theme.css +60 -0
  78. package/types/bitforgehq-ui-overlay.d.ts +684 -7
  79. package/types/bitforgehq-ui-testing.d.ts +434 -6
  80. package/types/bitforgehq-ui.d.ts +1473 -182
@@ -0,0 +1,521 @@
1
+ # bf-chat-thread
2
+
3
+ Scrolling chat message log that follows new text and offers a jump to latest
4
+
5
+ `BfChatThreadComponent` from `@bitforgehq/ui` · Data display · since 0.5.0
6
+
7
+ ## Example
8
+
9
+ ```ts
10
+ import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
11
+ import {
12
+ BfChatMessageComponent,
13
+ BfChatThreadComponent,
14
+ type BfChatMessageRole,
15
+ type BfChatMessageStatus,
16
+ } from '@bitforgehq/ui';
17
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
18
+ import { BfChatComposerComponent } from '@bitforgehq/ui/overlay';
19
+ import { ArrowDown, ArrowUp, Square } from 'lucide';
20
+
21
+ interface ChatTurn {
22
+ readonly id: number;
23
+ readonly role: BfChatMessageRole;
24
+ readonly text: string;
25
+ readonly streaming: boolean;
26
+ readonly status: BfChatMessageStatus;
27
+ }
28
+
29
+ /* Canned replies for the fake stream; a real app streams its model's answer instead. */
30
+ const REPLIES = [
31
+ 'Here are three directions. Ember and Oat: warm, rustic, a wood-fired oven in the window. ' +
32
+ 'Second Cup: a playful name for a neighbourhood spot with refills on the house. ' +
33
+ 'Field Notes: quiet, bookish, with long tables for people who work while they sip.',
34
+ 'For the opening week, try a pay-what-you-like morning, a latte art evening with the ' +
35
+ 'baristas, and a punch card that fills twice as fast. Small, friendly, easy to repeat.',
36
+ ];
37
+ /* One word every 60 ms. */
38
+ const WORD_DELAY = 60;
39
+
40
+ /**
41
+ * Chat with a streaming reply.
42
+ *
43
+ * A `bf-chat-thread` above a `bf-chat-composer`: send a message and a fake assistant reply
44
+ * streams in word by word, the thread following it to the bottom. Scroll up while it writes and
45
+ * the "Jump to latest" button appears; Stop ends the reply and marks it stopped.
46
+ */
47
+ @Component({
48
+ selector: 'app-example-chat-thread-overview',
49
+ imports: [
50
+ BfChatThreadComponent,
51
+ BfChatMessageComponent,
52
+ BfChatComposerComponent,
53
+ BfIconComponent,
54
+ ],
55
+ template: `
56
+ <div class="flex h-[26rem] flex-col gap-3">
57
+ <bf-chat-thread label="Cafe ideas" class="flex-1">
58
+ @for (turn of turns(); track turn.id) {
59
+ <bf-chat-message
60
+ [role]="turn.role"
61
+ [author]="turn.role === 'user' ? 'You' : 'Assistant'"
62
+ [streaming]="turn.streaming"
63
+ [status]="turn.status"
64
+ >
65
+ <p>{{ turn.text }}</p>
66
+ </bf-chat-message>
67
+ }
68
+ <bf-icon bfSlot="jump-icon" [icon]="arrowDown" />
69
+ </bf-chat-thread>
70
+ <bf-chat-composer
71
+ placeholder="Ask for ideas"
72
+ [(value)]="draft"
73
+ [running]="running()"
74
+ (sent)="ask($event)"
75
+ (stopped)="stop()"
76
+ >
77
+ <bf-icon bfSlot="send-icon" [icon]="arrowUp" />
78
+ <bf-icon bfSlot="stop-icon" [icon]="square" />
79
+ </bf-chat-composer>
80
+ </div>
81
+ `,
82
+ })
83
+ export class ChatThreadOverviewExampleComponent {
84
+ protected readonly arrowUp = ArrowUp;
85
+ protected readonly arrowDown = ArrowDown;
86
+ protected readonly square = Square;
87
+
88
+ readonly draft = signal('');
89
+ readonly turns = signal<ChatTurn[]>([
90
+ {
91
+ id: 1,
92
+ role: 'user',
93
+ text: 'I am opening a small cafe. Can you help me name it?',
94
+ streaming: false,
95
+ status: 'done',
96
+ },
97
+ {
98
+ id: 2,
99
+ role: 'assistant',
100
+ text: 'Gladly. Tell me about the place: the street, the mood, who you hope will come in.',
101
+ streaming: false,
102
+ status: 'done',
103
+ },
104
+ ]);
105
+ readonly running = computed(() => this.turns().some((turn) => turn.streaming));
106
+
107
+ private nextId = 3;
108
+ private replyIndex = 0;
109
+ private timer: ReturnType<typeof setInterval> | null = null;
110
+
111
+ constructor() {
112
+ inject(DestroyRef).onDestroy(() => this.clearTimer());
113
+ }
114
+
115
+ ask(text: string): void {
116
+ const replyId = this.nextId + 1;
117
+ this.turns.update((turns) => [
118
+ ...turns,
119
+ { id: this.nextId, role: 'user', text, streaming: false, status: 'done' },
120
+ { id: replyId, role: 'assistant', text: '', streaming: true, status: 'done' },
121
+ ]);
122
+ this.nextId += 2;
123
+ const words = (REPLIES[this.replyIndex++ % REPLIES.length] ?? '').split(' ');
124
+ let shown = 0;
125
+ this.timer = setInterval(() => {
126
+ shown += 1;
127
+ const done = shown >= words.length;
128
+ this.updateTurn(replyId, { text: words.slice(0, shown).join(' '), streaming: !done });
129
+ if (done) {
130
+ this.clearTimer();
131
+ }
132
+ }, WORD_DELAY);
133
+ }
134
+
135
+ stop(): void {
136
+ this.clearTimer();
137
+ const streaming = this.turns().find((turn) => turn.streaming);
138
+ if (streaming) {
139
+ this.updateTurn(streaming.id, { streaming: false, status: 'stopped' });
140
+ }
141
+ }
142
+
143
+ private updateTurn(id: number, change: Partial<ChatTurn>): void {
144
+ this.turns.update((turns) =>
145
+ turns.map((turn) => (turn.id === id ? { ...turn, ...change } : turn)),
146
+ );
147
+ }
148
+
149
+ private clearTimer(): void {
150
+ if (this.timer !== null) {
151
+ clearInterval(this.timer);
152
+ this.timer = null;
153
+ }
154
+ }
155
+ }
156
+ ```
157
+
158
+ ## Overview
159
+
160
+ The scrolling message list of a chat: a `role="log"` around `bf-chat-message` elements that
161
+ stays pinned to the newest message while text arrives. When the user scrolls up it stops
162
+ following and shows a "Jump to latest" button, which scrolls back down (smoothly, at once under
163
+ reduced motion) and follows again. While a message streams the log is `aria-busy`, so a screen
164
+ reader announces the reply once it is complete, not piece by piece. Messages added after the
165
+ first render rise and fade in; the ones it opens with do not. Give it a height (or `flex-1` in a
166
+ column): it scrolls inside.
167
+
168
+ The message list of a chat with an assistant: a scrolling `role="log"` of `bf-chat-message` elements that stays pinned to the newest message while a reply streams in, with a "Jump to latest" button once the user scrolls away.
169
+
170
+ ## When to use
171
+
172
+ Use `bf-chat-thread` for a conversation between the user and an assistant (an ideation chat, a help bot), with a [bf-chat-composer](https://bitforge-ui.web.app/components/chat-composer) below it. Use [bf-timeline](https://bitforge-ui.web.app/components/timeline) instead for a history of events with actors and times, and [bf-list](https://bitforge-ui.web.app/components/list) for rows the user picks from.
173
+
174
+ ## Basic usage
175
+
176
+ Put one [bf-chat-message](https://bitforge-ui.web.app/components/chat-message) per message inside, oldest first, usually from an `@for`. Give the thread a height (a fixed one, `max-h-*`, or `flex-1` in a column with a fixed height): it scrolls inside, and its own `p-1` keeps the focus ring and the bubbles off the edge. The example streams a pretend reply word by word: send a message, then scroll up while it writes.
177
+
178
+ ```ts
179
+ import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
180
+ import {
181
+ BfChatMessageComponent,
182
+ BfChatThreadComponent,
183
+ type BfChatMessageRole,
184
+ type BfChatMessageStatus,
185
+ } from '@bitforgehq/ui';
186
+ import { BfIconComponent } from '@bitforgehq/ui/icons';
187
+ import { BfChatComposerComponent } from '@bitforgehq/ui/overlay';
188
+ import { ArrowDown, ArrowUp, Square } from 'lucide';
189
+
190
+ interface ChatTurn {
191
+ readonly id: number;
192
+ readonly role: BfChatMessageRole;
193
+ readonly text: string;
194
+ readonly streaming: boolean;
195
+ readonly status: BfChatMessageStatus;
196
+ }
197
+
198
+ /* Canned replies for the fake stream; a real app streams its model's answer instead. */
199
+ const REPLIES = [
200
+ 'Here are three directions. Ember and Oat: warm, rustic, a wood-fired oven in the window. ' +
201
+ 'Second Cup: a playful name for a neighbourhood spot with refills on the house. ' +
202
+ 'Field Notes: quiet, bookish, with long tables for people who work while they sip.',
203
+ 'For the opening week, try a pay-what-you-like morning, a latte art evening with the ' +
204
+ 'baristas, and a punch card that fills twice as fast. Small, friendly, easy to repeat.',
205
+ ];
206
+ /* One word every 60 ms. */
207
+ const WORD_DELAY = 60;
208
+
209
+ /**
210
+ * Chat with a streaming reply.
211
+ *
212
+ * A `bf-chat-thread` above a `bf-chat-composer`: send a message and a fake assistant reply
213
+ * streams in word by word, the thread following it to the bottom. Scroll up while it writes and
214
+ * the "Jump to latest" button appears; Stop ends the reply and marks it stopped.
215
+ */
216
+ @Component({
217
+ selector: 'app-example-chat-thread-overview',
218
+ imports: [
219
+ BfChatThreadComponent,
220
+ BfChatMessageComponent,
221
+ BfChatComposerComponent,
222
+ BfIconComponent,
223
+ ],
224
+ template: `
225
+ <div class="flex h-[26rem] flex-col gap-3">
226
+ <bf-chat-thread label="Cafe ideas" class="flex-1">
227
+ @for (turn of turns(); track turn.id) {
228
+ <bf-chat-message
229
+ [role]="turn.role"
230
+ [author]="turn.role === 'user' ? 'You' : 'Assistant'"
231
+ [streaming]="turn.streaming"
232
+ [status]="turn.status"
233
+ >
234
+ <p>{{ turn.text }}</p>
235
+ </bf-chat-message>
236
+ }
237
+ <bf-icon bfSlot="jump-icon" [icon]="arrowDown" />
238
+ </bf-chat-thread>
239
+ <bf-chat-composer
240
+ placeholder="Ask for ideas"
241
+ [(value)]="draft"
242
+ [running]="running()"
243
+ (sent)="ask($event)"
244
+ (stopped)="stop()"
245
+ >
246
+ <bf-icon bfSlot="send-icon" [icon]="arrowUp" />
247
+ <bf-icon bfSlot="stop-icon" [icon]="square" />
248
+ </bf-chat-composer>
249
+ </div>
250
+ `,
251
+ })
252
+ export class ChatThreadOverviewExampleComponent {
253
+ protected readonly arrowUp = ArrowUp;
254
+ protected readonly arrowDown = ArrowDown;
255
+ protected readonly square = Square;
256
+
257
+ readonly draft = signal('');
258
+ readonly turns = signal<ChatTurn[]>([
259
+ {
260
+ id: 1,
261
+ role: 'user',
262
+ text: 'I am opening a small cafe. Can you help me name it?',
263
+ streaming: false,
264
+ status: 'done',
265
+ },
266
+ {
267
+ id: 2,
268
+ role: 'assistant',
269
+ text: 'Gladly. Tell me about the place: the street, the mood, who you hope will come in.',
270
+ streaming: false,
271
+ status: 'done',
272
+ },
273
+ ]);
274
+ readonly running = computed(() => this.turns().some((turn) => turn.streaming));
275
+
276
+ private nextId = 3;
277
+ private replyIndex = 0;
278
+ private timer: ReturnType<typeof setInterval> | null = null;
279
+
280
+ constructor() {
281
+ inject(DestroyRef).onDestroy(() => this.clearTimer());
282
+ }
283
+
284
+ ask(text: string): void {
285
+ const replyId = this.nextId + 1;
286
+ this.turns.update((turns) => [
287
+ ...turns,
288
+ { id: this.nextId, role: 'user', text, streaming: false, status: 'done' },
289
+ { id: replyId, role: 'assistant', text: '', streaming: true, status: 'done' },
290
+ ]);
291
+ this.nextId += 2;
292
+ const words = (REPLIES[this.replyIndex++ % REPLIES.length] ?? '').split(' ');
293
+ let shown = 0;
294
+ this.timer = setInterval(() => {
295
+ shown += 1;
296
+ const done = shown >= words.length;
297
+ this.updateTurn(replyId, { text: words.slice(0, shown).join(' '), streaming: !done });
298
+ if (done) {
299
+ this.clearTimer();
300
+ }
301
+ }, WORD_DELAY);
302
+ }
303
+
304
+ stop(): void {
305
+ this.clearTimer();
306
+ const streaming = this.turns().find((turn) => turn.streaming);
307
+ if (streaming) {
308
+ this.updateTurn(streaming.id, { streaming: false, status: 'stopped' });
309
+ }
310
+ }
311
+
312
+ private updateTurn(id: number, change: Partial<ChatTurn>): void {
313
+ this.turns.update((turns) =>
314
+ turns.map((turn) => (turn.id === id ? { ...turn, ...change } : turn)),
315
+ );
316
+ }
317
+
318
+ private clearTimer(): void {
319
+ if (this.timer !== null) {
320
+ clearInterval(this.timer);
321
+ this.timer = null;
322
+ }
323
+ }
324
+ }
325
+ ```
326
+
327
+ ## Following new text
328
+
329
+ The thread follows the newest message: when its content grows (new text in a streaming message, a new message, a taller composer that shrinks the log) it scrolls to the bottom in the next frame. The measuring runs in a `ResizeObserver` on the content and on the log, and the scroll is written in the next render, never inside the observer, so a resize can never end in the "ResizeObserver loop" error. When the user scrolls up (the wheel, a drag, a swipe, or <kbd>Arrow Up</kbd>, <kbd>Page Up</kbd> or <kbd>Home</kbd> in the log) it stops following and shows the "Jump to latest" button; new text then grows below without moving what the user reads. The button scrolls back down (smoothly; at once under reduced motion) and follows again. Scrolling back to the bottom by hand follows again too. A conversation too short to scroll keeps following. The `jump-icon` slot puts an icon before the button's text.
330
+
331
+ ## States
332
+
333
+ A message can end `stopped` (the user pressed Stop) or `failed`, with a short status line under it and an `action` slot beside it for a Retry button. See [bf-chat-message](https://bitforge-ui.web.app/components/chat-message) for its inputs.
334
+
335
+ ```ts
336
+ import { Component, signal } from '@angular/core';
337
+ import {
338
+ BfButtonComponent,
339
+ BfChatMessageComponent,
340
+ BfChatThreadComponent,
341
+ type BfChatMessageStatus,
342
+ } from '@bitforgehq/ui';
343
+
344
+ /**
345
+ * Stopped and failed messages.
346
+ *
347
+ * A reply the user stopped shows "Stopped" under its text; a reply that failed shows "Something
348
+ * went wrong" in the danger colour, with a Retry button in the message's `action` slot. Retry
349
+ * here answers at once; a real app would ask its model again.
350
+ */
351
+ @Component({
352
+ selector: 'app-example-chat-thread-states',
353
+ imports: [BfChatThreadComponent, BfChatMessageComponent, BfButtonComponent],
354
+ template: `
355
+ <bf-chat-thread label="Message states" class="max-h-[24rem]">
356
+ <bf-chat-message role="user">Write a slogan for the cafe.</bf-chat-message>
357
+ <bf-chat-message status="stopped">
358
+ <p>Good coffee, slow mornings and a</p>
359
+ </bf-chat-message>
360
+ <bf-chat-message role="user">Try a shorter one.</bf-chat-message>
361
+ <bf-chat-message [status]="status()">
362
+ @if (status() === 'done') {
363
+ <p>Stay for the second cup.</p>
364
+ }
365
+ @if (status() === 'failed') {
366
+ <bf-button bfSlot="action" variant="text" size="sm" (clicked)="retry()">Retry</bf-button>
367
+ }
368
+ </bf-chat-message>
369
+ </bf-chat-thread>
370
+ `,
371
+ })
372
+ export class ChatThreadStatesExampleComponent {
373
+ readonly status = signal<BfChatMessageStatus>('failed');
374
+
375
+ retry(): void {
376
+ this.status.set('done');
377
+ }
378
+ }
379
+ ```
380
+
381
+ ## Rendered markdown and rich content
382
+
383
+ The message body is styled as prose (`bfProse`): headings, lists, links, inline code, code blocks and quotes, for the markdown the app renders to HTML itself (and sanitises). The `content` slot under the text holds cards, images or chips, outside the prose styles. `bfProse` works on any element of the app too, such as a help article.
384
+
385
+ ```ts
386
+ import { Component, signal } from '@angular/core';
387
+ import {
388
+ BfCardComponent,
389
+ BfChatMessageComponent,
390
+ BfChatThreadComponent,
391
+ BfChipComponent,
392
+ } from '@bitforgehq/ui';
393
+
394
+ /* The app renders its markdown to HTML (and sanitises it) itself; the message styles it. */
395
+ const REPLY_HTML = `
396
+ <h3>Three name ideas</h3>
397
+ <ol>
398
+ <li><strong>Ember and Oat</strong>: warm and rustic.</li>
399
+ <li><strong>Second Cup</strong>: playful, for regulars.</li>
400
+ <li><strong>Field Notes</strong>: quiet and bookish.</li>
401
+ </ol>
402
+ <p>Check the domain with <code>whois embersandoat.com</code> before you print the menus.</p>
403
+ <blockquote>A short name is easier to say out loud and to find.</blockquote>
404
+ `;
405
+
406
+ /**
407
+ * Rendered markdown and inline content.
408
+ *
409
+ * The reply's body is the app's rendered markdown, styled as prose: a heading, a numbered list,
410
+ * inline code and a quote. Below it, the `content` slot holds a card and chips the user can pick
411
+ * from, outside the prose styling.
412
+ */
413
+ @Component({
414
+ selector: 'app-example-chat-thread-rich-content',
415
+ imports: [BfChatThreadComponent, BfChatMessageComponent, BfCardComponent, BfChipComponent],
416
+ template: `
417
+ <bf-chat-thread label="Rich reply" class="max-h-[30rem]">
418
+ <bf-chat-message role="user" author="You">Name ideas, with a logo mood?</bf-chat-message>
419
+ <bf-chat-message author="Assistant">
420
+ <div [innerHTML]="replyHtml"></div>
421
+ <bf-card bfSlot="content" variant="tint" class="w-64">
422
+ <p class="text-label text-text font-bold">Logo mood</p>
423
+ <p class="text-caption text-text-muted">Hand-drawn, charcoal on oat paper</p>
424
+ </bf-card>
425
+ <div bfSlot="content" class="flex flex-wrap gap-2">
426
+ @for (name of names; track name) {
427
+ <bf-chip [selected]="picked() === name" (changed)="picked.set($event ? name : '')">{{
428
+ name
429
+ }}</bf-chip>
430
+ }
431
+ </div>
432
+ </bf-chat-message>
433
+ </bf-chat-thread>
434
+ `,
435
+ })
436
+ export class ChatThreadRichContentExampleComponent {
437
+ readonly replyHtml = REPLY_HTML;
438
+ readonly names = ['Ember and Oat', 'Second Cup', 'Field Notes'];
439
+ readonly picked = signal('');
440
+ }
441
+ ```
442
+
443
+ ## Motion
444
+
445
+ A message added after the first render rises 4 px and fades in (`--animate-chat-message-in`: 200 ms on `--ease-settle`); the messages the thread opens with appear at once. A streaming reply shows a pulsing caret at the end of its text (`--animate-chat-caret`). Under `prefers-reduced-motion: reduce` messages appear without motion, the caret stays still and the jump is instant.
446
+
447
+ ## Desktop
448
+
449
+ The log scrolls with the wheel and the trackpad, and takes keyboard focus (its focus ring shows on <kbd>Tab</kbd>), so the arrow and page keys scroll it. The "Jump to latest" button is the outline button at `sm` with a raised shadow, centred over the bottom of the log; it has the button's hover and pressed steps, and after a press focus moves to the log, so it never falls to the page. Under a coarse pointer its hit area grows to 44 px.
450
+
451
+ ## Theming
452
+
453
+ - The log has no surface of its own: it shows the page (`bg`) or the card it sits on; its focus ring is `ring`.
454
+ - Messages: see [bf-chat-message](https://bitforge-ui.web.app/components/chat-message) (`primary-soft` user bubbles, `text` prose, `text-muted` headers).
455
+ - Jump button: bf-button's outline look (`surface`, `border-strong`, `text`, `primary-soft-pressed` while pressed) with `shadow-raised`.
456
+ - Motion: `--animate-chat-message-in` and `--animate-chat-caret`, timed by `--bf-duration-layout` and `--ease-settle`.
457
+
458
+ ## Accessibility
459
+
460
+ The scrolling element is a `role="log"` with `aria-live="polite"`, named by `label` or the `chatThread` label ("Conversation"), so a screen reader announces new messages as they are added without interrupting. While any message streams, the log is `aria-busy="true"` (and so is the message): a screen reader holds the changes and reads the reply once, when it is complete, instead of every piece of text. Set each message's `author` so the announcement says who speaks. The log is focusable (`tabindex="0"`) so keyboard users can scroll it. "Jump to latest" is a real `button` with the `chatJumpToLatest` label as its text; it is only in the tab order while it shows, and pressing it moves focus to the log.
461
+
462
+ ### Keyboard
463
+
464
+ | Key | Where | Action |
465
+ | ------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------- |
466
+ | <kbd>Tab</kbd> | Page | Moves focus to the log, then to the messages' links and buttons, then to "Jump to latest" while it shows |
467
+ | <kbd>Arrow Up</kbd> / <kbd>Page Up</kbd> / <kbd>Home</kbd> | Log | Scrolls up and stops following new text |
468
+ | <kbd>Arrow Down</kbd> / <kbd>Page Down</kbd> / <kbd>End</kbd> | Log | Scrolls down; at the bottom it follows again |
469
+ | <kbd>Enter</kbd> / <kbd>Space</kbd> | Jump to latest | Scrolls to the newest message, follows again and focuses the log |
470
+
471
+ ## Inputs
472
+
473
+ | Name | Type | Default | Description |
474
+ | ------- | -------- | ------- | ------------------------------------------------------------------------------ |
475
+ | `label` | `string` | `''` | The log's accessible name; empty uses the `chatThread` label ("Conversation"). |
476
+
477
+ ## Slots
478
+
479
+ - `default`: The messages, each a `bf-chat-message` (in order, oldest first).
480
+ - `jump-icon`: An icon before the "Jump to latest" text, such as an arrow down.
481
+
482
+ ## Methods
483
+
484
+ ### `jumpToLatest(): void`
485
+
486
+ Scrolls to the newest message (smoothly, at once under reduced motion) and follows it again.
487
+
488
+ ## Notes
489
+
490
+ ### Do
491
+
492
+ - Give the thread a bounded height so it scrolls inside, with the composer below it.
493
+ - Set `author` on every message so the log announces who wrote it.
494
+ - Render and sanitise markdown in the app, and let the body style it.
495
+
496
+ ### Don't
497
+
498
+ - Don't animate or scroll the thread yourself; it follows and animates new messages on its own.
499
+ - Don't put a message's cards or chips in its text: use the `content` slot, which the prose styles leave alone.
500
+
501
+ ## Migration notes
502
+
503
+ ### 0.5.0
504
+
505
+ New: `bf-chat-thread` and `bf-chat-message`, the message list of a chat with an assistant. The
506
+ thread is a scrolling `role="log"` (`aria-live="polite"`, named by `label` or the new
507
+ `chatThread` label) that stays pinned to the newest message while text arrives. When the user
508
+ scrolls up it stops following and shows a "Jump to latest" button (new label
509
+ `chatJumpToLatest`, optional `jump-icon` slot), which scrolls back down (smoothly, at once under
510
+ reduced motion) and follows again. While a message streams the log is `aria-busy`, so the reply
511
+ is announced once, when it is complete. Messages added after the first render rise 4 px and fade
512
+ in (new `--animate-chat-message-in`, 200 ms on `--ease-settle`, off under reduced motion). A
513
+ message's `role` (new type `BfChatMessageRole`: `'user' | 'assistant'`) draws a `primary-soft`
514
+ bubble at the inline end or a full-width reply; `author`, `time` and `timeText` fill an optional
515
+ header; `streaming` shows a pulsing caret at the end of the text (new `--animate-chat-caret`,
516
+ static under reduced motion); `status` (new type `BfChatMessageStatus`:
517
+ `'done' | 'stopped' | 'failed'`) adds a status line (new labels `chatMessageStopped`,
518
+ `chatMessageFailed`, or `statusMessage`) with an `action` slot for a Retry button; the `content`
519
+ slot holds cards, images or chips under the text. New directive `bfProse` (`BfProseDirective`)
520
+ styles the app's rendered markdown (headings, lists, links, code, quotes) with the semantic tokens;
521
+ the message body uses it.
@@ -289,6 +289,7 @@ Hovering the row (box and label) darkens the box border, or the checked fill, wi
289
289
  - Hover over the row (pointer cursor): unchecked border `text-muted` (`danger` on error); checked box `primary-hover`.
290
290
  - Pressed (the row held down): the box border turns `text`; not while disabled or read-only.
291
291
  - Disabled: border `surface-sunken`, background `surface-muted`; label text `text-disabled`.
292
+ - Motion: the mark grows in and shrinks out over 150 ms on `ease-md` (a fade only under reduced motion); an error message fades in (`animate-message-in`).
292
293
  - Read-only: `surface-muted` background, no hover, default cursor; unchecked keeps the `border-strong` border, checked (or indeterminate) a `primary` border and mark (≥ 4.8:1 on `surface-muted` in both themes).
293
294
  - Focus ring (on the visually hidden native input): `ring`.
294
295
 
@@ -359,6 +360,12 @@ Focuses the native checkbox; Signal Forms calls it from `focusBoundControl()`.
359
360
 
360
361
  ## Migration notes
361
362
 
363
+ ### 0.5.0
364
+
365
+ The check mark and the dash grow in as they appear and shrink as they go (150 ms on `ease-md`),
366
+ instead of switching opacity at once; under reduced motion they only fade. An error message fades
367
+ in when it appears.
368
+
362
369
  ### 0.3.0
363
370
 
364
371
  New `ariaLabelledby` input: the id(s) of the element(s) that name the box when no visible `label`
@@ -317,6 +317,9 @@ so touch apps use `fieldSize: 'lg'`.
317
317
  - `bf-field` renders the `<label for>`, the help and the error text; `aria-describedby`,
318
318
  `aria-invalid` and `aria-errormessage` follow the same rules as on `bf-select`.
319
319
  - `focus()` focuses the input; Signal Forms calls it from `focusBoundControl()`.
320
+ - Motion: the list grows out of the field as it opens (a fade, a slight scale and a 4 px slide,
321
+ 180 ms) and fades back as it closes (130 ms), keeping the options it showed. `aria-expanded`
322
+ changes at once and the closing list is inert. Under `prefers-reduced-motion` it only fades.
320
323
 
321
324
  ### Keyboard
322
325
 
@@ -393,6 +396,15 @@ Focuses the input; Signal Forms calls it from `focusBoundControl()`.
393
396
 
394
397
  ## Migration notes
395
398
 
399
+ ### 0.5.0
400
+
401
+ The list panel is exactly as wide as the field (it shrank to its content inside the CDK pane).
402
+
403
+ The list of `bf-combobox` and `bf-multi-combobox` animates open and closed with the popover's
404
+ motion (180 ms in, 130 ms out). `aria-expanded` changes at once; the closing list is inert and
405
+ keeps the options it showed, so a reset query does not reflow it while it fades, and typing or
406
+ `ArrowDown` during the exit opens a fresh list. Under `prefers-reduced-motion` it only fades.
407
+
396
408
  ### 0.3.1
397
409
 
398
410
  New `bf-combobox` (`BfComboboxComponent<T>`) in `@bitforgehq/ui/overlay`: a text field with a
@@ -291,6 +291,8 @@ The field box shows the `primary` border and focus ring while any part has focus
291
291
 
292
292
  The parts are a `role="group"` named by `label` (else `ariaLabel` or `ariaLabelledby`) and described by the help and error text. Each part is a `role="spinbutton"` named from the `dateInputDay`, `dateInputMonth` and `dateInputYear` labels, with `aria-valuemin`, `aria-valuemax`, `aria-valuenow` (absent while empty) and `aria-valuetext` (the month's name); `aria-invalid`, `aria-errormessage`, `aria-required`, `aria-readonly` and `aria-disabled` reflect the field. The separators are `aria-hidden`. The calendar button is named by `dateInputOpenCalendar` and carries `aria-haspopup="dialog"`, `aria-expanded` and, while open, `aria-controls`; the popover is a non-modal `dialog` named by the button, holding a `bf-calendar` with its own grid semantics. Focus moves into the calendar on open and back to the button on a pick or <kbd>Escape</kbd>. In native mode the platform input carries the label, `aria-invalid` and `aria-describedby`.
293
293
 
294
+ Motion: the calendar grows out of the field as it opens (a fade, a slight scale and a 4 px slide, 180 ms) and fades back as it closes (130 ms). Focus returns at once and the closing popover is inert. Under `prefers-reduced-motion` it only fades.
295
+
294
296
  ### Keyboard
295
297
 
296
298
  | Key | Where | Action |
@@ -377,6 +379,13 @@ Closes the calendar popover, focus back on its button; does nothing while it is
377
379
 
378
380
  ## Migration notes
379
381
 
382
+ ### 0.5.0
383
+
384
+ The calendar popover of `bf-date-input`, the time list of `bf-time-input` and the popover of
385
+ `bf-datetime-input` animate open and closed with the popover's motion (180 ms in, 130 ms out).
386
+ Focus returns to the button at once; the closing panel is inert until its exit has played. Under
387
+ `prefers-reduced-motion` they only fade.
388
+
380
389
  ### 0.3.1
381
390
 
382
391
  New `bf-date-input` in `@bitforgehq/ui/overlay`: a date field typed part by part, the day, month
@@ -115,6 +115,8 @@ As the date and the time inputs: the focus ring on the field while a part has fo
115
115
 
116
116
  The parts are a `role="group"` named by `label` (else `ariaLabel` or `ariaLabelledby`), each a named `role="spinbutton"` with its range, value and value text, and the field's invalid, required, read-only and disabled state. The button is named by `dateInputOpenCalendar`, with `aria-haspopup="dialog"`, `aria-expanded` and `aria-controls`; the popover is a non-modal `dialog` named by the button, holding a `bf-calendar` grid and a `role="listbox"` of times named by `timeInputOpenList` (`aria-activedescendant`, `aria-selected`, `aria-disabled`). Focus moves into the calendar on open, to the list after a day is picked, and back to the button after a time is picked or on <kbd>Escape</kbd>.
117
117
 
118
+ Motion: the popover grows out of the field as it opens (a fade, a slight scale and a 4 px slide, 180 ms) and fades back as it closes (130 ms). Focus returns at once and the closing popover is inert. Under `prefers-reduced-motion` it only fades.
119
+
118
120
  ### Keyboard
119
121
 
120
122
  | Key | Where | Action |
@@ -232,6 +232,7 @@ export class FieldHelpAndErrorExampleComponent {
232
232
 
233
233
  - Label: `text-muted`, switching to `primary-text` (primary as text, ≥ 4.5:1 on `bg` and `surface`) while the control inside is focused (`group-focus-within/bf-field`) and to `text-disabled` when `disabled`.
234
234
  - Required marker: `danger`. Error text: `danger`. Help text: `text-muted`.
235
+ - Motion: the error text fades in over 150 ms when it appears (`animate-message-in`, opacity only, so it stays under reduced motion).
235
236
 
236
237
  Label, control, help text and error text sit 6 px apart, the same label-to-message spacing as `bf-checkbox`, `bf-radio-group` and `bf-toggle`.
237
238
 
@@ -268,6 +269,15 @@ The field renders the label as a native `<label for>` pointing at `controlId`, a
268
269
 
269
270
  ## Migration notes
270
271
 
272
+ ### 0.5.0
273
+
274
+ New `labelId`: the label's id (`<controlId>-label`), which the label now carries, for a control
275
+ that names itself with `aria-labelledby`.
276
+
277
+ The error message fades in when it appears (new `--animate-message-in`, 150 ms of opacity),
278
+ including a message held back during a press, which fades in once the press ends. `bf-toggle` and
279
+ `bf-calendar` messages do the same. It is an opacity fade only, so it stays under reduced motion.
280
+
271
281
  ### 0.3.0
272
282
 
273
283
  **Behaviour:** the label of a focused field is `primary-text` (was `primary`).