@urbicon-ui/blocks 6.43.0 → 6.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/dist/components/Calendar/calendar.variants.d.ts +63 -63
  3. package/dist/components/Chat/ChatMessage/ChatMessage.svelte +93 -67
  4. package/dist/components/Chat/ChatMessage/chat-message.variants.d.ts +7 -0
  5. package/dist/components/Chat/ChatMessage/chat-message.variants.js +45 -12
  6. package/dist/components/Chat/ChatMessage/index.d.ts +20 -5
  7. package/dist/components/Chat/CodeBlock/CodeBlock.svelte +59 -33
  8. package/dist/components/Chat/CodeBlock/code-block.variants.d.ts +7 -0
  9. package/dist/components/Chat/CodeBlock/code-block.variants.js +28 -13
  10. package/dist/components/Chat/CodeBlock/index.d.ts +39 -4
  11. package/dist/components/Chat/PromptInput/PromptInput.svelte +9 -16
  12. package/dist/components/Chat/PromptInput/prompt-input.variants.js +16 -15
  13. package/dist/components/Chat/ToolCallCard/ToolCallCard.svelte +9 -4
  14. package/dist/components/Chat/ToolCallCard/index.d.ts +4 -3
  15. package/dist/components/Chat/ToolCallCard/tool-call-card.variants.d.ts +0 -3
  16. package/dist/components/Chat/ToolCallCard/tool-call-card.variants.js +4 -4
  17. package/dist/components/CopyButton/CopyButton.svelte +9 -19
  18. package/dist/components/CopyButton/copy-button.variants.d.ts +4 -4
  19. package/dist/components/FileUpload/fileUpload.variants.js +6 -1
  20. package/dist/components/PinInput/PinInput.svelte +12 -20
  21. package/dist/components/TimeInput/TimeInput.svelte +12 -20
  22. package/dist/i18n/index.d.ts +398 -2
  23. package/dist/internal/copy-state.svelte.d.ts +60 -0
  24. package/dist/internal/copy-state.svelte.js +76 -0
  25. package/dist/internal/core/CoreFieldMessage.svelte +81 -0
  26. package/dist/internal/core/CoreFieldMessage.svelte.d.ts +23 -0
  27. package/dist/internal/field-chrome.js +2 -2
  28. package/dist/primitives/Alert/alert.variants.d.ts +8 -8
  29. package/dist/primitives/Checkbox/Checkbox.svelte +10 -20
  30. package/dist/primitives/Checkbox/checkbox.variants.js +1 -1
  31. package/dist/primitives/Combobox/Combobox.svelte +16 -18
  32. package/dist/primitives/Combobox/combobox.variants.js +12 -5
  33. package/dist/primitives/Input/Input.svelte +10 -20
  34. package/dist/primitives/JourneyTimeline/journey-timeline.variants.d.ts +22 -22
  35. package/dist/primitives/RadioGroup/RadioGroup.svelte +10 -20
  36. package/dist/primitives/RadioGroup/radioGroup.variants.js +7 -2
  37. package/dist/primitives/Select/Select.svelte +10 -20
  38. package/dist/primitives/Select/select.variants.js +2 -2
  39. package/dist/primitives/Slider/Slider.svelte +10 -20
  40. package/dist/primitives/Spinner/spinner.variants.d.ts +16 -16
  41. package/dist/primitives/Stepper/stepper.variants.d.ts +11 -11
  42. package/dist/primitives/Stepper/stepper.variants.js +2 -1
  43. package/dist/primitives/Tab/tab.variants.js +5 -2
  44. package/dist/primitives/Textarea/Textarea.svelte +10 -15
  45. package/dist/primitives/Textarea/textarea.variants.js +2 -2
  46. package/dist/primitives/Toast/toast.variants.d.ts +12 -12
  47. package/dist/primitives/Toggle/Toggle.svelte +10 -20
  48. package/dist/primitives/Toggle/toggle.variants.js +9 -7
  49. package/dist/style/foundation.css +19 -9
  50. package/dist/style/semantic.css +59 -2
  51. package/dist/style/themes/forest.css +2 -2
  52. package/dist/style/themes/neutral.css +2 -2
  53. package/dist/style/themes/ocean.css +2 -2
  54. package/dist/style/themes/rose.css +2 -2
  55. package/dist/style/themes/sunset.css +2 -2
  56. package/dist/utils/figma-token-export.js +7 -3
  57. package/package.json +3 -3
@@ -1,5 +1,8 @@
1
1
  <script lang="ts">
2
+ import { useBlocksI18n } from '../../../i18n';
2
3
  import { getBlocksConfig, resolveSlotClasses } from '../../../provider';
4
+ import { createCopyState } from '../../../internal/copy-state.svelte';
5
+ import CoreIconButton from '../../../internal/core/CoreIconButton.svelte';
3
6
  import { Alert, Avatar, Button, Skeleton, Tooltip } from '../../../primitives';
4
7
  import { resolveIcon } from '../../../icons';
5
8
  import SparklesIconDefault from '../../../icons/SparklesIcon.svelte';
@@ -34,8 +37,9 @@
34
37
  layout = 'bubble',
35
38
  density = 'comfortable',
36
39
  roleLabels,
37
- copyLabel = 'Copy message',
38
- copiedLabel = 'Copied',
40
+ copyLabel,
41
+ copiedLabel,
42
+ copyFailedLabel,
39
43
  regenerateLabel = 'Regenerate',
40
44
  retryLabel = 'Retry',
41
45
  errorLabel = 'Something went wrong',
@@ -47,9 +51,15 @@
47
51
  ...restProps
48
52
  }: ChatMessageProps = $props();
49
53
 
54
+ const bt = useBlocksI18n();
50
55
  const blocksConfig = getBlocksConfig();
51
56
  const unstyled = $derived(unstyledProp || blocksConfig?.unstyled || false);
52
57
 
58
+ // Localised by default instead of hardcoded English in the prop signature.
59
+ const copyText = $derived(copyLabel ?? bt('accessibility.copy'));
60
+ const copiedText = $derived(copiedLabel ?? bt('accessibility.copied'));
61
+ const copyFailedText = $derived(copyFailedLabel ?? bt('accessibility.copyFailed'));
62
+
53
63
  const SparklesIcon = resolveIcon('sparkles', SparklesIconDefault);
54
64
  const UserIcon = resolveIcon('user', UserIconDefault);
55
65
  const InfoIcon = resolveIcon('info', InfoIconDefault);
@@ -93,7 +103,7 @@
93
103
  });
94
104
 
95
105
  // Concatenated text parts — what the copy action writes to the clipboard.
96
- const copyText = $derived(
106
+ const messageText = $derived(
97
107
  message.parts.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join('\n\n')
98
108
  );
99
109
 
@@ -114,27 +124,22 @@
114
124
  );
115
125
 
116
126
  // ── Copy interaction ─────────────────────────────────────────────────────
117
- let copied = $state(false);
118
- let resetTimer: ReturnType<typeof setTimeout> | undefined;
127
+ const copyState = createCopyState();
128
+
129
+ // Reports all three outcomes. A failed copy used to be console-only, so the
130
+ // action bar looked identical whether the clipboard write worked or not.
131
+ const copyButtonText = $derived(
132
+ copyState.phase === 'copied'
133
+ ? copiedText
134
+ : copyState.phase === 'error'
135
+ ? copyFailedText
136
+ : copyText
137
+ );
119
138
 
120
139
  async function copyMessage() {
121
- try {
122
- await navigator.clipboard.writeText(copyText);
123
- copied = true;
124
- if (resetTimer) clearTimeout(resetTimer);
125
- resetTimer = setTimeout(() => {
126
- copied = false;
127
- resetTimer = undefined;
128
- }, 2000);
129
- } catch (err) {
130
- // Never confirm a copy that failed (e.g. denied clipboard permission).
131
- console.error('ChatMessage: failed to copy message', err);
132
- }
140
+ const result = await copyState.copy(messageText);
141
+ if (!result.ok) console.error('ChatMessage: failed to copy message', result.error);
133
142
  }
134
-
135
- $effect(() => () => {
136
- if (resetTimer) clearTimeout(resetTimer);
137
- });
138
143
  </script>
139
144
 
140
145
  {#snippet defaultAvatar(role: ChatRole)}
@@ -249,65 +254,43 @@
249
254
  <div class={cls('actions')}>
250
255
  <!-- No copy for text-less messages (tool-call/reasoning-only): copying an
251
256
  empty string and confirming "Copied" would be a lie (review finding). -->
252
- {#if copyText}
253
- <Tooltip label={copyLabel}>
254
- <button
255
- type="button"
257
+ {#if messageText}
258
+ <Tooltip label={copyButtonText}>
259
+ <CoreIconButton
256
260
  class={cls('actionButton')}
257
261
  onclick={copyMessage}
258
- aria-label={copied ? copiedLabel : copyLabel}
262
+ aria-label={copyButtonText}
259
263
  >
260
- {#if copied}
264
+ {#if copyState.phase === 'copied'}
261
265
  <CheckIcon size={16} />
262
266
  {:else}
263
267
  <CopyIcon size={16} />
264
268
  {/if}
265
- </button>
269
+ </CoreIconButton>
266
270
  </Tooltip>
267
271
  {/if}
268
272
  {#if onRegenerate}
269
273
  <Tooltip label={regenerateLabel}>
270
- <button
271
- type="button"
274
+ <CoreIconButton
272
275
  class={cls('actionButton')}
273
276
  onclick={onRegenerate}
274
277
  aria-label={regenerateLabel}
275
278
  >
276
279
  <RefreshIcon size={16} />
277
- </button>
280
+ </CoreIconButton>
278
281
  </Tooltip>
279
282
  {/if}
280
283
  </div>
281
284
  {/snippet}
282
285
 
283
- <div class={cls('root', className)} data-role={message.role} data-status={status} {...restProps}>
284
- {#if layout === 'plain'}
285
- <div class={cls('header')}>
286
- {#if avatar}
287
- {@render avatar({ role: message.role })}
288
- {:else}
289
- {@render defaultAvatar(message.role)}
290
- {/if}
291
- <span class={cls('roleName')}>{roleLabel}</span>
292
- </div>
293
- <div class={cls('bubble')}>
294
- {@render body()}
295
- </div>
296
- {:else}
297
- <div class={cls('container')}>
298
- {#if message.role !== 'user'}
299
- {#if avatar}
300
- {@render avatar({ role: message.role })}
301
- {:else}
302
- {@render defaultAvatar(message.role)}
303
- {/if}
304
- {/if}
305
- <div class={cls('bubble')}>
306
- {@render body()}
307
- </div>
308
- </div>
309
- {/if}
310
-
286
+ <!--
287
+ `column` wraps the bubble together with everything that hangs under it —
288
+ citations, the status alert, the footer. They must be siblings of the bubble
289
+ INSIDE the column, not of `container`: the column is what carries the
290
+ role-dependent side (`items-end` for a user message), so the timestamp ends up
291
+ under the bubble's own edge instead of at the left margin.
292
+ -->
293
+ {#snippet belowBubble()}
311
294
  {#if sources.length}
312
295
  <div class={cls('sourcesFooter')}>
313
296
  {#each sources as source, i (source.id)}
@@ -326,20 +309,63 @@
326
309
  </div>
327
310
  {/if}
328
311
 
312
+ <!--
313
+ Metadata first in the DOM, actions second — and the footer reverses for a
314
+ user message (see the variants). That ordering is what puts the timestamp
315
+ against the bubble's own edge on BOTH sides: the action bar is `opacity-0`
316
+ until hover, so whichever element comes first at the edge is the only one
317
+ visible there. Actions-first left the timestamp floating one button-width in
318
+ from the edge, attached to nothing.
319
+ -->
329
320
  <div class={cls('footer')}>
330
- {#if actions}
331
- {@render actions({ message })}
332
- {:else}
333
- {@render defaultActions()}
334
- {/if}
335
321
  {#if metadata}
336
322
  {@render metadata({ message })}
337
323
  {:else if createdAtTime}
338
324
  <time class={cls('metadata')} datetime={createdAtIso}>{createdAtTime}</time>
339
325
  {/if}
326
+ {#if actions}
327
+ {@render actions({ message })}
328
+ {:else}
329
+ {@render defaultActions()}
330
+ {/if}
340
331
  </div>
332
+ {/snippet}
333
+
334
+ <div class={cls('root', className)} data-role={message.role} data-status={status} {...restProps}>
335
+ {#if layout === 'plain'}
336
+ <div class={cls('header')}>
337
+ {#if avatar}
338
+ {@render avatar({ role: message.role })}
339
+ {:else}
340
+ {@render defaultAvatar(message.role)}
341
+ {/if}
342
+ <span class={cls('roleName')}>{roleLabel}</span>
343
+ </div>
344
+ <div class={cls('column')}>
345
+ <div class={cls('bubble')}>
346
+ {@render body()}
347
+ </div>
348
+ {@render belowBubble()}
349
+ </div>
350
+ {:else}
351
+ <div class={cls('container')}>
352
+ {#if message.role !== 'user'}
353
+ {#if avatar}
354
+ {@render avatar({ role: message.role })}
355
+ {:else}
356
+ {@render defaultAvatar(message.role)}
357
+ {/if}
358
+ {/if}
359
+ <div class={cls('column')}>
360
+ <div class={cls('bubble')}>
361
+ {@render body()}
362
+ </div>
363
+ {@render belowBubble()}
364
+ </div>
365
+ </div>
366
+ {/if}
341
367
 
342
- <!-- Copy confirmation for screen readers — a live status region, present
343
- before `copied` flips so it always announces the change. -->
344
- <span class="sr-only" role="status">{copied ? copiedLabel : ''}</span>
368
+ <!-- Copy outcome for screen readers — a live status region, present before the
369
+ phase flips so it always announces the change. -->
370
+ <span class="sr-only" role="status">{copyState.phase === 'idle' ? '' : copyButtonText}</span>
345
371
  </div>
@@ -39,6 +39,13 @@ export declare const chatMessageVariants: ((props?: {
39
39
  } & {
40
40
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
41
41
  }) | undefined) => string;
42
+ column: (props?: ({
43
+ layout?: "bubble" | "plain" | undefined;
44
+ role?: "user" | "system" | "assistant" | undefined;
45
+ density?: "compact" | "comfortable" | undefined;
46
+ } & {
47
+ class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
48
+ }) | undefined) => string;
42
49
  bubble: (props?: ({
43
50
  layout?: "bubble" | "plain" | undefined;
44
51
  role?: "user" | "system" | "assistant" | undefined;
@@ -11,6 +11,13 @@ export const chatMessageVariants = tv({
11
11
  header: ['flex min-w-0 items-center gap-2'],
12
12
  roleName: ['text-sm font-medium text-text-secondary select-none'],
13
13
  avatar: ['shrink-0'],
14
+ // Everything that belongs UNDER the bubble — citations, the error alert, the
15
+ // footer — lives in this column, not as a sibling of `container`. It is what
16
+ // makes those rows inherit the bubble's side: a user bubble sits right, so
17
+ // its timestamp and citations must too. `items-*` per role does the aligning
18
+ // (see the compounds), and the width cap lives here rather than on `bubble`
19
+ // so an alert spans the column while the bubble still hugs its text.
20
+ column: ['flex min-w-0 flex-col'],
14
21
  // The content bubble (bubble layout) / the full-width column (plain layout).
15
22
  bubble: ['min-w-0 max-w-full'],
16
23
  // Vertical stack of the rendered parts.
@@ -29,21 +36,29 @@ export const chatMessageVariants = tv({
29
36
  sourcesFooter: ['flex flex-wrap items-center gap-1.5 pt-1'],
30
37
  // streaming placeholder (zero parts)
31
38
  placeholder: ['py-1'],
32
- // status alert
33
- statusAlert: ['mt-1'],
34
- // footer row holding actions + metadata
35
- footer: ['flex items-center gap-2'],
39
+ // status alert — stretches across the column (the bubble hugs its text, an
40
+ // error does not).
41
+ statusAlert: ['mt-1 w-full'],
42
+ // Footer row holding actions + metadata. Its height is set by the metadata
43
+ // line (`min-h-5`), NOT by the action buttons: those are `opacity-0` until
44
+ // hover/focus, so letting them size the row reserved ~28px of blank strip
45
+ // under every single message. `-my-1` below lets them overhang the shorter
46
+ // row instead of growing it.
47
+ footer: ['flex min-h-5 items-center gap-2'],
36
48
  actions: [
37
- 'flex items-center gap-1 opacity-0',
49
+ 'flex items-center gap-1 -my-1 opacity-0',
38
50
  'transition-opacity duration-[var(--blocks-duration-fast)] ease-out',
39
51
  'group-hover/message:opacity-100 group-focus-within/message:opacity-100'
40
52
  ],
53
+ // Renders through CoreIconButton, so this carries only what the core does not
54
+ // supply — no flex centring, cursor or `focus-visible:outline-none` here (the
55
+ // core has no variant engine, so a repeat would resolve by stylesheet order).
41
56
  actionButton: [
42
- 'inline-flex items-center justify-center rounded-modify p-1.5',
43
- 'text-text-tertiary cursor-pointer',
57
+ 'rounded-modify p-1.5',
58
+ 'text-text-tertiary',
44
59
  'transition-[color,background-color] duration-[var(--blocks-duration-fast)] ease-out',
45
60
  'hover:bg-surface-hover hover:text-text-primary',
46
- 'focus-visible:outline-none focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1'
61
+ 'focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1'
47
62
  ],
48
63
  metadata: ['text-xs text-text-tertiary']
49
64
  },
@@ -52,6 +67,7 @@ export const chatMessageVariants = tv({
52
67
  bubble: {},
53
68
  // Document-like: full-width content under an avatar + role header row.
54
69
  plain: {
70
+ column: 'w-full items-stretch',
55
71
  bubble: 'w-full',
56
72
  partsFlow: 'gap-3'
57
73
  }
@@ -64,30 +80,46 @@ export const chatMessageVariants = tv({
64
80
  density: {
65
81
  comfortable: {
66
82
  root: 'gap-1.5',
83
+ column: 'gap-1.5',
67
84
  partsFlow: 'gap-2'
68
85
  },
69
86
  compact: {
70
87
  root: 'gap-1',
88
+ column: 'gap-1',
71
89
  partsFlow: 'gap-1.5'
72
90
  }
73
91
  }
74
92
  },
75
93
  compoundVariants: [
76
94
  // ── bubble layout: role tint + alignment ────────────────────────────────
95
+ // Bubbles ride the BRIDGE tier, not `contain`. `contain` (2 px) is the
96
+ // architectural-panel radius — correct on a 600 px Card, where it reads as
97
+ // a precise edge, but on a ~200 px bubble the same 2 px reads as a plain
98
+ // rectangle. Optical radius scales with the area it turns. `bridge` (6 px)
99
+ // is the middle tier and stays brand-themeable, so a consumer retunes
100
+ // bubbles via `--radius-bridge` instead of overriding this slot.
77
101
  {
78
102
  layout: 'bubble',
79
103
  role: 'user',
80
104
  class: {
81
- // Reverse so the (avatar-less) bubble packs to the right edge.
105
+ // Reverse so the (avatar-less) column packs to the right edge, and
106
+ // right-align its rows so timestamp + citations hang off the same edge
107
+ // as the bubble instead of drifting to the left margin.
82
108
  container: 'flex-row-reverse',
83
- bubble: 'max-w-[85%] rounded-contain bg-primary-subtle'
109
+ column: 'max-w-[85%] items-end',
110
+ bubble: 'rounded-bridge bg-primary-subtle',
111
+ // Metadata comes first in the DOM so it can sit against the bubble edge;
112
+ // reversing here puts that edge on the right for a user message while
113
+ // keeping the action bar on the inside.
114
+ footer: 'flex-row-reverse'
84
115
  }
85
116
  },
86
117
  {
87
118
  layout: 'bubble',
88
119
  role: 'assistant',
89
120
  class: {
90
- bubble: 'max-w-[85%] rounded-contain bg-surface-elevated'
121
+ column: 'max-w-[85%] items-start',
122
+ bubble: 'rounded-bridge bg-surface-elevated'
91
123
  }
92
124
  },
93
125
  {
@@ -95,7 +127,8 @@ export const chatMessageVariants = tv({
95
127
  role: 'system',
96
128
  class: {
97
129
  container: 'justify-center',
98
- bubble: 'max-w-[90%] rounded-contain border border-border-subtle bg-surface-base text-sm text-text-secondary'
130
+ column: 'max-w-[90%] items-center',
131
+ bubble: 'rounded-bridge border border-border-subtle bg-surface-base text-sm text-text-secondary'
99
132
  }
100
133
  },
101
134
  // ── bubble layout: density padding ──────────────────────────────────────
@@ -68,10 +68,22 @@ export interface ChatMessageProps extends Omit<ChatMessageVariants, 'role'>, Omi
68
68
  partRenderers?: ChatPartRenderers;
69
69
  /** Display names per role, shown in the `plain`-layout header. */
70
70
  roleLabels?: Partial<Record<ChatRole, string>>;
71
- /** Accessible label / tooltip for the copy action. @default 'Copy message' */
71
+ /**
72
+ * Accessible label / tooltip for the copy action.
73
+ * @default the `accessibility.copy` translation
74
+ */
72
75
  copyLabel?: string;
73
- /** Accessible label shown briefly after a successful copy. @default 'Copied' */
76
+ /**
77
+ * Label shown briefly after a successful copy.
78
+ * @default the `accessibility.copied` translation
79
+ */
74
80
  copiedLabel?: string;
81
+ /**
82
+ * Label shown briefly after a FAILED copy — e.g. a denied clipboard permission
83
+ * or a non-secure context.
84
+ * @default the `accessibility.copyFailed` translation
85
+ */
86
+ copyFailedLabel?: string;
75
87
  /** Accessible label / tooltip for the regenerate action. @default 'Regenerate' */
76
88
  regenerateLabel?: string;
77
89
  /** Label for the retry button in the error/aborted Alert. @default 'Retry' */
@@ -86,9 +98,12 @@ export interface ChatMessageProps extends Omit<ChatMessageVariants, 'role'>, Omi
86
98
  unstyled?: boolean;
87
99
  /**
88
100
  * Per-slot class overrides. Slots: root | container | header | roleName | avatar |
89
- * bubble | partsFlow | attachment | attachmentIcon | attachmentName | attachmentSize |
90
- * sourcesFooter | placeholder | statusAlert | footer | actions | actionButton |
91
- * metadata. Reasoning and tool-call parts render through ReasoningDisclosure /
101
+ * column | bubble | partsFlow | attachment | attachmentIcon | attachmentName |
102
+ * attachmentSize | sourcesFooter | placeholder | statusAlert | footer | actions |
103
+ * actionButton | metadata. `column` wraps the bubble with everything under it
104
+ * (citations, status alert, footer) and carries the role-dependent alignment —
105
+ * override it to re-align a message's whole stack, not just the bubble.
106
+ * Reasoning and tool-call parts render through ReasoningDisclosure /
92
107
  * ToolCallCard, which take their own `slotClasses`.
93
108
  */
94
109
  slotClasses?: Partial<Record<ChatMessageSlots, string>>;
@@ -3,17 +3,22 @@
3
3
  import { resolveIcon } from '../../../icons';
4
4
  import CopyIconDefault from '../../../icons/CopyIcon.svelte';
5
5
  import CheckIconDefault from '../../../icons/CheckIcon.svelte';
6
+ import { createCopyState } from '../../../internal/copy-state.svelte';
6
7
  import { codeBlockVariants, type CodeBlockVariants } from './code-block.variants';
7
8
  import type { CodeBlockProps } from './index';
8
9
 
9
10
  let {
10
11
  code,
11
12
  lang,
13
+ label,
14
+ variant = 'card',
12
15
  showCopy = true,
13
16
  wrap = false,
14
- copyLabel = 'Copy code',
17
+ copyLabel = 'Copy',
15
18
  copiedLabel = 'Copied',
19
+ copyFailedLabel = 'Copy failed',
16
20
  onCopy,
21
+ onCopyError,
17
22
  actions,
18
23
  class: className,
19
24
  unstyled: unstyledProp = false,
@@ -28,7 +33,16 @@
28
33
  const CopyIcon = resolveIcon('copy', CopyIconDefault);
29
34
  const CheckIcon = resolveIcon('check', CheckIconDefault);
30
35
 
31
- const variantProps: CodeBlockVariants = $derived({ wrap });
36
+ // Deliberately NOT routed through useBlocksI18n(). CodeBlock is a leaf with no
37
+ // other reason to pull the i18n registry, and doing so for three strings costs
38
+ // +5 KB gz here — which StreamingMarkdown and ReasoningDisclosure then inherit
39
+ // by embedding it (+25% each, measured against bundle-size.baseline.json). A
40
+ // consumer localises through the three label props; ChatMessage, which already
41
+ // carries the registry via Alert/Avatar/Button/Tooltip, does use the
42
+ // translations because there it is free. See technical-debt.md — splitting
43
+ // translations/en.ts per area would remove the trade-off.
44
+
45
+ const variantProps: CodeBlockVariants = $derived({ variant, wrap });
32
46
  const styles = $derived(codeBlockVariants(variantProps));
33
47
  const slotClasses = $derived(
34
48
  resolveSlotClasses(blocksConfig, 'CodeBlock', preset, variantProps, slotClassesProp)
@@ -45,54 +59,66 @@
45
59
  });
46
60
  }
47
61
 
48
- const showHeader = $derived(!!lang || showCopy || !!actions);
49
- const regionLabel = $derived(lang ? `${lang} code` : 'Code');
62
+ // `label` wins over `lang` in the header: an embedding parent (ToolCallCard)
63
+ // passes what the payload *is* ("Input"), which says more than the language it
64
+ // happens to be serialised in — and showing both is the same fact twice.
65
+ const headerLabel = $derived(label ?? lang);
66
+ const showHeader = $derived(!!headerLabel || showCopy || !!actions);
67
+ // The accessible name of the scroll region keeps naming the language when
68
+ // there is one: "Input code" is vaguer than "json code" for a screen reader.
69
+ const regionLabel = $derived(lang ? `${lang} code` : label ? `${label} code` : 'Code');
50
70
 
51
- let copied = $state(false);
52
- let resetTimer: ReturnType<typeof setTimeout> | undefined;
71
+ const copyState = createCopyState();
53
72
 
54
- async function copyCode() {
55
- try {
56
- await navigator.clipboard.writeText(code);
57
- copied = true;
58
- if (resetTimer) clearTimeout(resetTimer);
59
- resetTimer = setTimeout(() => {
60
- copied = false;
61
- resetTimer = undefined;
62
- }, 2000);
73
+ async function handleCopy() {
74
+ const result = await copyState.copy(code);
75
+ if (result.ok) {
63
76
  onCopy?.(code);
64
- } catch (err) {
65
- // Never swallow silently — but leave state untouched so the UI does not
66
- // falsely confirm a copy that failed (e.g. denied clipboard permission).
67
- console.error('CodeBlock: failed to copy code', err);
77
+ } else if (onCopyError) {
78
+ onCopyError(result.error);
79
+ } else {
80
+ // Never silent: a copy that did nothing is the outcome most worth
81
+ // reporting, and the consumer opted out of handling it.
82
+ console.error('CodeBlock: failed to copy code', result.error);
68
83
  }
69
84
  }
70
85
 
71
- // Clear a pending reset on unmount so the timer can't fire into a torn-down component.
72
- $effect(() => () => {
73
- if (resetTimer) clearTimeout(resetTimer);
74
- });
86
+ // The button reports the outcome, all three of them. A denied clipboard
87
+ // permission used to leave it looking untouched (console-only).
88
+ const buttonText = $derived(
89
+ copyState.phase === 'copied'
90
+ ? copiedLabel
91
+ : copyState.phase === 'error'
92
+ ? copyFailedLabel
93
+ : copyLabel
94
+ );
75
95
  </script>
76
96
 
77
97
  <div class={cls('root', className)} {...restProps}>
78
98
  {#if showHeader}
79
99
  <div class={cls('header')}>
80
- <span class={cls('langLabel')}>{lang ?? ''}</span>
100
+ <span class={cls('langLabel')}>{headerLabel ?? ''}</span>
81
101
  <div class="flex items-center gap-1">
82
102
  {@render actions?.()}
83
103
  {#if showCopy}
104
+ <!--
105
+ Icon-only: the button sits in a header whose other half already names
106
+ the payload, and a text button there outweighed the caption it was
107
+ supposed to serve. `title` carries the label for pointer users, the
108
+ live region below announces the outcome.
109
+ -->
84
110
  <button
85
111
  type="button"
86
112
  class={cls('copyButton')}
87
- onclick={copyCode}
88
- aria-label={copied ? copiedLabel : copyLabel}
113
+ onclick={handleCopy}
114
+ aria-label={buttonText}
115
+ title={buttonText}
89
116
  >
90
- {#if copied}
117
+ {#if copyState.phase === 'copied'}
91
118
  <CheckIcon size={14} />
92
119
  {:else}
93
120
  <CopyIcon size={14} />
94
121
  {/if}
95
- <span>{copied ? copiedLabel : copyLabel}</span>
96
122
  </button>
97
123
  {/if}
98
124
  </div>
@@ -111,10 +137,10 @@
111
137
  ></pre>
112
138
 
113
139
  <!--
114
- Copy confirmation for screen readers. A label change on the button the user
115
- just activated is not a reliable announcement — a live status region is. It
116
- must exist in the DOM before `copied` flips, so it always renders and only
117
- its text content changes.
140
+ Copy outcome for screen readers. A label change on the button the user just
141
+ activated is not a reliable announcement — a live status region is. It must
142
+ exist in the DOM before the phase flips, so it always renders and only its
143
+ text content changes.
118
144
  -->
119
- <span class="sr-only" role="status">{copied ? copiedLabel : ''}</span>
145
+ <span class="sr-only" role="status">{copyState.phase === 'idle' ? '' : buttonText}</span>
120
146
  </div>
@@ -1,33 +1,40 @@
1
1
  import { type SlotNames, type VariantProps } from '../../../utils/variants.js';
2
2
  export declare const codeBlockVariants: ((props?: {
3
+ variant?: "card" | "plain" | undefined;
3
4
  wrap?: boolean | undefined;
4
5
  } | undefined) => {
5
6
  root: (props?: ({
7
+ variant?: "card" | "plain" | undefined;
6
8
  wrap?: boolean | undefined;
7
9
  } & {
8
10
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
9
11
  }) | undefined) => string;
10
12
  header: (props?: ({
13
+ variant?: "card" | "plain" | undefined;
11
14
  wrap?: boolean | undefined;
12
15
  } & {
13
16
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
14
17
  }) | undefined) => string;
15
18
  langLabel: (props?: ({
19
+ variant?: "card" | "plain" | undefined;
16
20
  wrap?: boolean | undefined;
17
21
  } & {
18
22
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
19
23
  }) | undefined) => string;
20
24
  copyButton: (props?: ({
25
+ variant?: "card" | "plain" | undefined;
21
26
  wrap?: boolean | undefined;
22
27
  } & {
23
28
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
24
29
  }) | undefined) => string;
25
30
  pre: (props?: ({
31
+ variant?: "card" | "plain" | undefined;
26
32
  wrap?: boolean | undefined;
27
33
  } & {
28
34
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;
29
35
  }) | undefined) => string;
30
36
  code: (props?: ({
37
+ variant?: "card" | "plain" | undefined;
31
38
  wrap?: boolean | undefined;
32
39
  } & {
33
40
  class?: string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | (string | number | false | /*elided*/ any | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined)[] | Record<string, boolean | null | undefined> | null | undefined;