@ds-mo/ui 10.0.0 → 11.1.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 (145) hide show
  1. package/dist/.build-stamp +1 -1
  2. package/dist/agent-patterns.json +15 -9
  3. package/dist/agent.json +523 -36
  4. package/dist/angular/ds-button-filled.d.ts +1 -1
  5. package/dist/angular/ds-button-filled.js +3 -3
  6. package/dist/angular/ds-chart-donut.d.ts +1 -1
  7. package/dist/angular/ds-chart-donut.js +3 -3
  8. package/dist/angular/ds-typing-indicator.d.ts +12 -0
  9. package/dist/angular/ds-typing-indicator.js +38 -0
  10. package/dist/angular/index.d.ts +1 -1
  11. package/dist/angular/index.js +2 -1
  12. package/dist/angular/proxies.d.ts +1 -0
  13. package/dist/angular/proxies.js +1 -0
  14. package/dist/components/ds-agent-response.js +1 -1
  15. package/dist/components/ds-agent-response.js.map +1 -1
  16. package/dist/components/ds-agent-tool-call.js +1 -1
  17. package/dist/components/ds-app-shell.js +1 -1
  18. package/dist/components/ds-attachment-list.js +1 -1
  19. package/dist/components/ds-avatar.js +1 -1
  20. package/dist/components/ds-avatar.js.map +1 -1
  21. package/dist/components/ds-bar-nav.js +1 -1
  22. package/dist/components/ds-bar-nav.js.map +1 -1
  23. package/dist/components/ds-button-filled.js +1 -1
  24. package/dist/components/ds-button-unfilled.js +1 -1
  25. package/dist/components/ds-card-data-viz-donut.js +1 -1
  26. package/dist/components/ds-card-data-viz-donut.js.map +1 -1
  27. package/dist/components/ds-card-setting.js +1 -1
  28. package/dist/components/ds-chart-bar.js +1 -1
  29. package/dist/components/ds-chart-donut.js +1 -1
  30. package/dist/components/ds-chart-donut.js.map +1 -1
  31. package/dist/components/ds-chart-legend.js +1 -1
  32. package/dist/components/ds-chart-legend.js.map +1 -1
  33. package/dist/components/ds-checkbox.js +1 -1
  34. package/dist/components/ds-chip.js +1 -1
  35. package/dist/components/ds-chip.js.map +1 -1
  36. package/dist/components/ds-code-block.js +1 -1
  37. package/dist/components/ds-conversation-list-item.js +1 -1
  38. package/dist/components/ds-conversation-list-item.js.map +1 -1
  39. package/dist/components/ds-input.js +1 -1
  40. package/dist/components/ds-input.js.map +1 -1
  41. package/dist/components/ds-markdown.js +1 -1
  42. package/dist/components/ds-menu.js +1 -1
  43. package/dist/components/ds-message-bubble.js +1 -1
  44. package/dist/components/ds-message-bubble.js.map +1 -1
  45. package/dist/components/ds-message-composer.js +1 -1
  46. package/dist/components/ds-message-composer.js.map +1 -1
  47. package/dist/components/ds-message-scroller.js +1 -1
  48. package/dist/components/ds-modal.js +1 -1
  49. package/dist/components/ds-modal.js.map +1 -1
  50. package/dist/components/ds-panel-nav.js +1 -1
  51. package/dist/components/ds-panel-nav.js.map +1 -1
  52. package/dist/components/ds-panel-sub-nav.js +1 -1
  53. package/dist/components/ds-panel-sub-nav.js.map +1 -1
  54. package/dist/components/ds-panel-tool-header.js +1 -1
  55. package/dist/components/ds-panel-tool-search.js +1 -1
  56. package/dist/components/ds-panel-tool-search.js.map +1 -1
  57. package/dist/components/ds-panel-tools.js +1 -1
  58. package/dist/components/ds-panel-tools.js.map +1 -1
  59. package/dist/components/ds-radio.js +1 -1
  60. package/dist/components/ds-radio.js.map +1 -1
  61. package/dist/components/ds-select-multi.js +1 -1
  62. package/dist/components/ds-select-multi.js.map +1 -1
  63. package/dist/components/ds-select.js +1 -1
  64. package/dist/components/ds-select.js.map +1 -1
  65. package/dist/components/ds-skeleton.js +1 -1
  66. package/dist/components/ds-skeleton.js.map +1 -1
  67. package/dist/components/ds-tab-group.js +1 -1
  68. package/dist/components/ds-tab-group.js.map +1 -1
  69. package/dist/components/ds-tag.js +1 -1
  70. package/dist/components/ds-tag.js.map +1 -1
  71. package/dist/components/ds-toast.js +1 -1
  72. package/dist/components/ds-toast.js.map +1 -1
  73. package/dist/components/ds-tooltip-data-viz.js +1 -1
  74. package/dist/components/ds-tooltip-data-viz.js.map +1 -1
  75. package/dist/components/ds-tooltip.js +1 -1
  76. package/dist/components/ds-typing-indicator.d.ts +11 -0
  77. package/dist/components/ds-typing-indicator.js +2 -0
  78. package/dist/components/ds-typing-indicator.js.map +1 -0
  79. package/dist/components/p-BCCt2eT_.js +2 -0
  80. package/dist/components/{p-DM-95BX1.js.map → p-BCCt2eT_.js.map} +1 -1
  81. package/dist/components/{p-CxyjoTN1.js → p-BEWtqFX7.js} +2 -2
  82. package/dist/components/{p-DZAtgSZo.js → p-B_qVPsEU.js} +2 -2
  83. package/dist/components/p-BaZRP4Oo.js +2 -0
  84. package/dist/components/p-BaZRP4Oo.js.map +1 -0
  85. package/dist/components/p-BsnezUUC.js +2 -0
  86. package/dist/components/{p-DhsO0939.js.map → p-BsnezUUC.js.map} +1 -1
  87. package/dist/components/{p-B4RQoNmL.js → p-C1K5QGUZ.js} +2 -2
  88. package/dist/components/p-CjP8sYlo.js +2 -0
  89. package/dist/components/{p-CS12e52R.js.map → p-CjP8sYlo.js.map} +1 -1
  90. package/dist/components/p-CrwU83-g.js +2 -0
  91. package/dist/components/{p-C_0Q8Cnp.js.map → p-CrwU83-g.js.map} +1 -1
  92. package/dist/components/{p-BTo4pNdp.js → p-DJ78wdNv.js} +2 -2
  93. package/dist/components/p-DTqCBjUj.js +2 -0
  94. package/dist/components/{p-CKnoWZd9.js.map → p-DTqCBjUj.js.map} +1 -1
  95. package/dist/components/p-ZTugT4as.js +2 -0
  96. package/dist/components/{p-BJUcsBHt.js.map → p-ZTugT4as.js.map} +1 -1
  97. package/dist/components/p-bNpFhP5L.js +2 -0
  98. package/dist/components/p-bNpFhP5L.js.map +1 -0
  99. package/dist/docs/components.json +215 -30
  100. package/dist/mcp/cli.js +8 -12
  101. package/dist/mcp-data/registry/agent-response.json +5 -5
  102. package/dist/mcp-data/registry/button-filled.json +38 -2
  103. package/dist/mcp-data/registry/card-data-viz-donut.json +73 -4
  104. package/dist/mcp-data/registry/chart-bar.json +67 -3
  105. package/dist/mcp-data/registry/chart-donut.json +94 -8
  106. package/dist/mcp-data/registry/chart-legend.json +73 -3
  107. package/dist/mcp-data/registry/chart-line.json +67 -3
  108. package/dist/mcp-data/registry/conversation-list-item.json +2 -2
  109. package/dist/mcp-data/registry/message-bubble.json +19 -14
  110. package/dist/mcp-data/registry/message-composer.json +5 -3
  111. package/dist/mcp-data/registry/message-scroller.json +1 -1
  112. package/dist/mcp-data/registry/panel-nav.json +3 -2
  113. package/dist/mcp-data/registry/registry.json +525 -35
  114. package/dist/mcp-data/registry/skeleton.json +2 -2
  115. package/dist/mcp-data/registry/tooltip-data-viz.json +68 -4
  116. package/dist/mcp-data/registry/typing-indicator.json +127 -0
  117. package/dist/react/components.d.ts +1 -0
  118. package/dist/react/components.js +1 -0
  119. package/dist/react/ds-typing-indicator.d.ts +9 -0
  120. package/dist/react/ds-typing-indicator.js +12 -0
  121. package/dist/types/components/ButtonFilled/ButtonFilled.d.ts +9 -0
  122. package/dist/types/components/ButtonFilled/index.d.ts +1 -1
  123. package/dist/types/components/CardDataVizDonut/CardDataVizDonut.d.ts +1 -0
  124. package/dist/types/components/ChartDonut/ChartDonut.d.ts +12 -2
  125. package/dist/types/components/MessageBubble/MessageBubble.d.ts +1 -1
  126. package/dist/types/components/TooltipDataViz/TooltipDataViz.d.ts +3 -2
  127. package/dist/types/components/TypingIndicator/TypingIndicator.d.ts +6 -0
  128. package/dist/types/components.d.ts +75 -14
  129. package/package.json +1 -1
  130. package/dist/components/p-BHtg1fh3.js +0 -2
  131. package/dist/components/p-BHtg1fh3.js.map +0 -1
  132. package/dist/components/p-BJUcsBHt.js +0 -2
  133. package/dist/components/p-CKnoWZd9.js +0 -2
  134. package/dist/components/p-CS12e52R.js +0 -2
  135. package/dist/components/p-C_0Q8Cnp.js +0 -2
  136. package/dist/components/p-DBJ2I3mH.js +0 -2
  137. package/dist/components/p-DBJ2I3mH.js.map +0 -1
  138. package/dist/components/p-DM-95BX1.js +0 -2
  139. package/dist/components/p-DhsO0939.js +0 -2
  140. package/dist/components/p-DsbhBb5F.js +0 -2
  141. package/dist/components/p-DsbhBb5F.js.map +0 -1
  142. /package/dist/components/{p-CxyjoTN1.js.map → p-BEWtqFX7.js.map} +0 -0
  143. /package/dist/components/{p-DZAtgSZo.js.map → p-B_qVPsEU.js.map} +0 -0
  144. /package/dist/components/{p-B4RQoNmL.js.map → p-C1K5QGUZ.js.map} +0 -0
  145. /package/dist/components/{p-BTo4pNdp.js.map → p-DJ78wdNv.js.map} +0 -0
@@ -2,15 +2,81 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "chart-donut",
4
4
  "title": "ChartDonut",
5
- "description": "ChartDonut component from @ds-mo/ui.",
5
+ "description": "Part-to-whole donut chart with a center aggregate, responsive fill sizing, local slice tooltip feedback, and externally controlled legend highlighting.",
6
6
  "type": "registry:ui",
7
7
  "meta": {
8
8
  "library": "@ds-mo/ui",
9
9
  "distribution": "npm",
10
10
  "storybook": "https://zainadeel.github.io/compomo/?path=/story/chart-donut",
11
11
  "source": "src/wc/components/ChartDonut/ChartDonut.tsx",
12
- "intentStatus": "migration-pending",
13
- "intent": null,
12
+ "intentStatus": "complete",
13
+ "intent": {
14
+ "audience": "specialized",
15
+ "status": "experimental",
16
+ "summary": "Part-to-whole donut chart with a center aggregate, responsive fill sizing, local slice tooltip feedback, and externally controlled legend highlighting.",
17
+ "useWhen": [
18
+ "Users need to understand a small part-to-whole breakdown with a prominent total or status in the center.",
19
+ "A standalone donut needs pointer and keyboard-focus value feedback through a chart-owned data-viz tooltip.",
20
+ "A donut and visible ChartLegend should cross-highlight by matching stable labels."
21
+ ],
22
+ "avoidWhen": [
23
+ "The data is a trend, time sequence, or independent magnitude comparison.",
24
+ "The category count creates slices too small to understand reliably.",
25
+ "Hover is intended to select, filter, or navigate rather than temporarily highlight data."
26
+ ],
27
+ "alternatives": [
28
+ {
29
+ "when": "The product needs the canonical card, filter action, visible legend, and built-in hover synchronization.",
30
+ "component": "component:ds-card-data-viz-donut",
31
+ "reason": "The dedicated card owns that complete composition and suppresses redundant slice tooltips."
32
+ },
33
+ {
34
+ "when": "Users need to compare independent category magnitudes.",
35
+ "component": "component:ds-chart-bar",
36
+ "reason": "Bars make category magnitude differences easier to compare."
37
+ }
38
+ ],
39
+ "commonlyComposedWith": [
40
+ "component:ds-chart-legend",
41
+ "component:ds-tooltip-data-viz",
42
+ "component:ds-card-data-viz-donut"
43
+ ],
44
+ "accessibility": [
45
+ "Every non-empty slice remains keyboard focusable and exposes its label and value without depending on the visual tooltip.",
46
+ "Use a visible legend when the full set of labels, values, or percentages must remain continuously available.",
47
+ "Provide localized no-data text for the neutral empty ring.",
48
+ "Center text is a visual summary and must not be the only source of essential category detail."
49
+ ],
50
+ "states": [
51
+ "Genuine local pointer or keyboard focus may show a chart-owned tooltip; externally synchronized highlight never opens it.",
52
+ "When a visible legend already owns label and value detail, suppress the redundant donut tooltip. CardDataVizDonut does this automatically.",
53
+ "Local hover and external highlight remain separate while both may dim non-matching slices.",
54
+ "Zero or negative totals render a neutral empty ring without interactive slices.",
55
+ "Center value and caption truncate when they exceed the safe inner-circle measure."
56
+ ],
57
+ "responsiveBehavior": [
58
+ "Prefer container-fill sizing inside data-viz cards and explicit sizing only in layouts that own a stable diameter.",
59
+ "Fill sizing remains centered and constrained to the shared donut size band.",
60
+ "The owner adapts category count and legend placement for available space."
61
+ ],
62
+ "frameworkCaveats": {
63
+ "customElements": [
64
+ "Assign data as a JavaScript property and wire slice-hover detail to a sibling legend only when not using CardDataVizDonut."
65
+ ],
66
+ "react": [
67
+ "Keep synchronized highlight controlled by the composition and disable slice tooltips when a sibling legend continuously shows the same details."
68
+ ],
69
+ "angular": [
70
+ "Keep synchronized highlight controlled by the composition and disable slice tooltips when a sibling legend continuously shows the same details."
71
+ ]
72
+ },
73
+ "references": [
74
+ {
75
+ "label": "Storybook examples",
76
+ "path": "src/wc/components/ChartDonut/ChartDonut.stories.ts"
77
+ }
78
+ ]
79
+ },
14
80
  "api": {
15
81
  "props": {
16
82
  "activeLabel": {
@@ -20,7 +86,7 @@
20
86
  "default": "null",
21
87
  "required": false,
22
88
  "mutable": false,
23
- "description": "Externally controlled highlight, matched by `label` — e.g. drive this from a sibling\n`ds-chart-legend`'s `dsItemHover` event to keep chart and legend hover in sync.\nFalls back to this component's own pointer/focus hover when unset.\nSlice hover dims peer slices and emits `dsSliceHover` for legend sync no data-viz\ntooltip (the legend already surfaces label/value)."
89
+ "description": "Externally controlled highlight, matched by `label` — e.g. drive this from a sibling\n`ds-chart-legend`'s `dsItemHover` event to keep chart and legend hover in sync.\nFalls back to this component's own pointer/focus hover when unset.\nSlice hover dims peer slices and emits `dsSliceHover` for legend sync. External\nhighlight never opens the local tooltip."
24
90
  },
25
91
  "centerCaption": {
26
92
  "type": "string | undefined",
@@ -79,6 +145,15 @@
79
145
  "required": false,
80
146
  "mutable": false
81
147
  },
148
+ "showTooltip": {
149
+ "type": "boolean",
150
+ "resolvedType": "boolean",
151
+ "attribute": "show-tooltip",
152
+ "default": "true",
153
+ "required": false,
154
+ "mutable": false,
155
+ "description": "Show a chart-owned value callout for genuine pointer or keyboard focus.\nDisable when a visible legend already owns persistent label and value detail."
156
+ },
82
157
  "size": {
83
158
  "type": "number | undefined",
84
159
  "resolvedType": "number | undefined",
@@ -118,7 +193,7 @@
118
193
  "default": "null",
119
194
  "required": false,
120
195
  "mutable": false,
121
- "description": "Externally controlled highlight, matched by `label` — e.g. drive this from a sibling\n`ds-chart-legend`'s `dsItemHover` event to keep chart and legend hover in sync.\nFalls back to this component's own pointer/focus hover when unset.\nSlice hover dims peer slices and emits `dsSliceHover` for legend sync no data-viz\ntooltip (the legend already surfaces label/value)."
196
+ "description": "Externally controlled highlight, matched by `label` — e.g. drive this from a sibling\n`ds-chart-legend`'s `dsItemHover` event to keep chart and legend hover in sync.\nFalls back to this component's own pointer/focus hover when unset.\nSlice hover dims peer slices and emits `dsSliceHover` for legend sync. External\nhighlight never opens the local tooltip."
122
197
  },
123
198
  "centerCaption": {
124
199
  "type": "string | undefined",
@@ -177,6 +252,15 @@
177
252
  "required": false,
178
253
  "mutable": false
179
254
  },
255
+ "showTooltip": {
256
+ "type": "boolean",
257
+ "resolvedType": "boolean",
258
+ "attribute": "show-tooltip",
259
+ "default": "true",
260
+ "required": false,
261
+ "mutable": false,
262
+ "description": "Show a chart-owned value callout for genuine pointer or keyboard focus.\nDisable when a visible legend already owns persistent label and value detail."
263
+ },
180
264
  "size": {
181
265
  "type": "number | undefined",
182
266
  "resolvedType": "number | undefined",
@@ -241,16 +325,18 @@
241
325
  "@ds-mo/ui",
242
326
  "@ds-mo/tokens"
243
327
  ],
244
- "registryDependencies": [],
328
+ "registryDependencies": [
329
+ "tooltip-data-viz"
330
+ ],
245
331
  "files": [
246
332
  {
247
333
  "path": "src/wc/components/ChartDonut/ChartDonut.css",
248
- "content": ":host {\n display: inline-block;\n font-family: var(--typography-font-family);\n user-select: none;\n}\n\n:host(.chart-donut--fill) {\n display: block;\n width: 100%;\n height: 100%;\n min-width: 0;\n min-height: 0;\n}\n\n.chart-donut__wrapper {\n position: relative;\n}\n\n:host(.chart-donut--fill) .chart-donut__wrapper {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.chart-donut__svg path {\n cursor: default;\n transition: opacity var(--effect-motion-short-2);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .chart-donut__svg path {\n transition: none;\n }\n}\n\n.chart-donut__svg path:focus-visible {\n outline: none;\n stroke: var(--color-interaction-focus);\n stroke-width: 2;\n}\n\n/* Matches ds-text text-title-large + emphasis (SVG cannot host ds-text). */\n.chart-donut__center-value {\n fill: var(--color-foreground-primary);\n font-family: var(--typography-font-family);\n font-size: var(--typography-fontsize-xl);\n line-height: var(--typography-lineheight-xl);\n font-weight: var(--typography-weight-semibold);\n letter-spacing: var(--typography-letterspacing-negative-double);\n cursor: default;\n}\n\n/* Matches ds-text text-body-medium regular. */\n.chart-donut__center-caption {\n fill: var(--color-foreground-secondary);\n font-family: var(--typography-font-family);\n font-size: var(--typography-fontsize-md);\n line-height: var(--typography-lineheight-md);\n font-weight: var(--typography-weight-regular);\n letter-spacing: var(--typography-letterspacing-negative-half);\n cursor: default;\n}\n",
334
+ "content": ":host {\n display: inline-block;\n font-family: var(--typography-font-family);\n user-select: none;\n}\n\n:host(.chart-donut--fill) {\n display: block;\n width: 100%;\n height: 100%;\n min-width: 0;\n min-height: 0;\n}\n\n.chart-donut__wrapper {\n position: relative;\n overflow: visible;\n}\n\n:host(.chart-donut--fill) .chart-donut__wrapper {\n width: 100%;\n height: 100%;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.chart-donut__svg path {\n cursor: default;\n transition: opacity var(--effect-motion-short-2);\n}\n\n@media (prefers-reduced-motion: reduce) {\n .chart-donut__svg path {\n transition: none;\n }\n}\n\n.chart-donut__svg path:focus-visible {\n outline: none;\n stroke: var(--color-interaction-focus);\n stroke-width: 2;\n}\n\n/* Matches ds-text text-title-large + emphasis (SVG cannot host ds-text). */\n.chart-donut__center-value {\n fill: var(--color-foreground-primary);\n font-family: var(--typography-font-family);\n font-size: var(--typography-fontsize-xl);\n line-height: var(--typography-lineheight-xl);\n font-weight: var(--typography-weight-semibold);\n letter-spacing: var(--typography-letterspacing-negative-double);\n cursor: default;\n pointer-events: none;\n}\n\n/* Matches ds-text text-body-medium regular. */\n.chart-donut__center-caption {\n fill: var(--color-foreground-secondary);\n font-family: var(--typography-font-family);\n font-size: var(--typography-fontsize-md);\n line-height: var(--typography-lineheight-md);\n font-weight: var(--typography-weight-regular);\n letter-spacing: var(--typography-letterspacing-negative-half);\n cursor: default;\n pointer-events: none;\n}\n",
249
335
  "type": "registry:ui"
250
336
  },
251
337
  {
252
338
  "path": "src/wc/components/ChartDonut/ChartDonut.tsx",
253
- "content": "import { Component, Prop, State, Event, EventEmitter, Element, Watch, h, Host } from '@stencil/core';\nimport { arc, pie } from 'd3-shape';\nimport { categoryColor } from '../../utils/chart-colors';\nimport { formatCompactNumber, resolveCssLengthPx, truncateSvgTextToWidth, TOKEN_DEFAULTS } from '../../utils';\nimport type { ChartDatum } from '../../utils/chart-types';\n\nconst DIMMED_OPACITY = 0.25;\n/** Gap between the center value and caption line boxes. */\nconst CENTER_GAP_PX = 0;\n/** Fraction of the inner-circle diameter center text is allowed to use before truncating. */\nconst CENTER_TEXT_SAFE_WIDTH_RATIO = 0.82;\n/** Fallback when filling before the first ResizeObserver measurement (matches fill min). */\nconst FILL_FALLBACK_PX = 128;\n\n@Component({\n tag: 'ds-chart-donut',\n styleUrl: 'ChartDonut.css',\n scoped: true,\n})\nexport class ChartDonut {\n @Prop() locale: string | undefined;\n @Prop() noDataLabel: string = 'No data';\n @Element() el!: HTMLElement;\n\n /** Slices to render. Set as a JS property (not an HTML attribute). */\n @Prop() data: ChartDatum[] = [];\n /**\n * Explicit diameter in px. When unset, the donut sizes to its container\n * (ResizeObserver) clamped between `--dimension-size-base * 16` (128px) and\n * `* 24` (192px), and stays centered in the leftover space. Prefer unset\n * inside card layouts.\n */\n @Prop() size: number | undefined;\n /** Ring thickness — number (px) or TokoMo length. Defaults to `--dimension-size-200` (16px). */\n @Prop() thickness: number | string = TOKEN_DEFAULTS.size200;\n /** Corner radius on each slice — number (px) or TokoMo length. Defaults to `--dimension-radius-025` (2px). */\n @Prop() cornerRadius: number | string = TOKEN_DEFAULTS.radius025;\n /** Gap between slices, in degrees. */\n @Prop() gap: number = 1;\n /** Primary center value; defaults to the sum of `data` values (e.g. \"187\", \"40.9%\"). */\n @Prop() centerValue: string | undefined;\n /** Secondary caption below the center value (e.g. \"Total\", \"Normal\"). Rendered uppercase. */\n @Prop() centerCaption: string | undefined;\n /**\n * Externally controlled highlight, matched by `label` — e.g. drive this from a sibling\n * `ds-chart-legend`'s `dsItemHover` event to keep chart and legend hover in sync.\n * Falls back to this component's own pointer/focus hover when unset.\n * Slice hover dims peer slices and emits `dsSliceHover` for legend sync — no data-viz\n * tooltip (the legend already surfaces label/value).\n */\n @Prop() activeLabel: string | null = null;\n\n @State() private hoveredLabel: string | null = null;\n /** Center text, truncated with an ellipsis if it doesn't fit inside the inner circle. */\n @State() private displayedValueText: string = '';\n @State() private displayedCaptionText: string = '';\n /** Measured fill diameter when `size` is unset. */\n @State() private measuredSize: number = FILL_FALLBACK_PX;\n\n /** Fires with the hovered/focused slice's datum, or `null` on leave/blur. */\n @Event() dsSliceHover!: EventEmitter<ChartDatum | null>;\n\n private valueTextEl?: SVGTextElement;\n private captionTextEl?: SVGTextElement;\n private resizeObserver: ResizeObserver | null = null;\n\n private get isFillMode(): boolean {\n return this.size == null || !Number.isFinite(this.size) || this.size <= 0;\n }\n\n private get fillMinPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.donutFillMin, FILL_FALLBACK_PX);\n }\n\n private get fillMaxPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.donutFillMax, 192);\n }\n\n /** Clamp available square side into the fill min/max band. */\n private clampFillSize(availablePx: number): number {\n const minPx = this.fillMinPx;\n const maxPx = this.fillMaxPx;\n if (!(availablePx > 0)) return minPx;\n return Math.round(Math.min(maxPx, Math.max(minPx, availablePx)));\n }\n\n private get resolvedSize(): number {\n if (!this.isFillMode) return this.size as number;\n return this.clampFillSize(this.measuredSize);\n }\n\n componentDidLoad() {\n this.syncResizeObserver();\n }\n\n @Watch('size')\n onSizePropChange() {\n this.syncResizeObserver();\n }\n\n disconnectedCallback() {\n this.teardownResizeObserver();\n }\n\n private syncResizeObserver() {\n this.teardownResizeObserver();\n if (!this.isFillMode) return;\n // Observe the host (which fills its parent), not the wrapper — the wrapper\n // only wraps the SVG and would collapse to the clamped size, locking fill at min.\n const target = this.el;\n if (!target || typeof ResizeObserver === 'undefined') return;\n\n this.resizeObserver = new ResizeObserver(entries => {\n const entry = entries[0];\n if (!entry) return;\n this.applyAvailableSize(entry.contentRect.width, entry.contentRect.height);\n });\n this.resizeObserver.observe(target);\n this.measureHost();\n }\n\n private measureHost() {\n const rect = this.el.getBoundingClientRect();\n this.applyAvailableSize(rect.width, rect.height);\n }\n\n private applyAvailableSize(width: number, height: number) {\n const available = Math.floor(Math.min(width, height));\n if (available > 0 && available !== this.measuredSize) {\n this.measuredSize = available;\n }\n }\n\n private teardownResizeObserver() {\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n }\n\n private handleHover(datum: ChartDatum | null) {\n this.hoveredLabel = datum?.label ?? null;\n this.dsSliceHover.emit(datum);\n }\n\n componentDidRender() {\n // Re-bind after HMR when DidLoad's observer was dropped.\n if (this.isFillMode && !this.resizeObserver) {\n this.syncResizeObserver();\n }\n\n const size = this.resolvedSize;\n const radius = size / 2;\n const thicknessPx = resolveCssLengthPx(this.thickness, TOKEN_DEFAULTS.size200);\n const innerRadius = radius - thicknessPx;\n const maxWidth = innerRadius * 2 * CENTER_TEXT_SAFE_WIDTH_RATIO;\n const total = this.data.reduce((sum, d) => sum + d.value, 0);\n\n const fullValueText = this.centerValue ?? formatCompactNumber(total, this.locale);\n if (this.valueTextEl) {\n const truncated = truncateSvgTextToWidth(this.valueTextEl, fullValueText, maxWidth);\n if (truncated !== this.displayedValueText) this.displayedValueText = truncated;\n else this.valueTextEl.textContent = truncated;\n }\n\n const fullCaptionText = this.centerCaption ?? '';\n if (this.captionTextEl && fullCaptionText) {\n const truncated = truncateSvgTextToWidth(this.captionTextEl, fullCaptionText, maxWidth);\n if (truncated !== this.displayedCaptionText) this.displayedCaptionText = truncated;\n else this.captionTextEl.textContent = truncated;\n }\n }\n\n render() {\n const size = this.resolvedSize;\n const radius = size / 2;\n const thicknessPx = resolveCssLengthPx(this.thickness, TOKEN_DEFAULTS.size200);\n const cornerRadiusPx = resolveCssLengthPx(this.cornerRadius, TOKEN_DEFAULTS.radius025);\n const innerRadius = radius - thicknessPx;\n const total = this.data.reduce((sum, d) => sum + d.value, 0);\n // d3's pie() divides each value by the total to get an angle — with an all-zero total\n // that's 0/0, producing NaN angles and broken paths. Render a flat neutral ring instead.\n const isEmpty = total <= 0;\n // Highlight/dim from either this component's own hover or an externally-synced label\n // (e.g. a sibling ds-chart-legend row).\n const highlightLabel = this.activeLabel ?? this.hoveredLabel;\n\n const arcGenerator = arc<{ startAngle: number; endAngle: number }>()\n .innerRadius(innerRadius)\n .outerRadius(radius)\n .cornerRadius(cornerRadiusPx)\n .padAngle((this.gap * Math.PI) / 180);\n\n const slices = isEmpty\n ? []\n : pie<ChartDatum>()\n .value(d => d.value)\n .sort(null)(this.data);\n\n const emptyRingPath = isEmpty\n ? arc<{ startAngle: number; endAngle: number }>()\n .innerRadius(innerRadius)\n .outerRadius(radius)({ startAngle: 0, endAngle: Math.PI * 2 })\n : null;\n\n // Precise vertical centering for the two-line center text, using the real line-height of\n // text-title-large (value) and text-body-medium (caption) rather than eyeballed em offsets.\n const valueLineHeight = resolveCssLengthPx(TOKEN_DEFAULTS.lineheightXl, 32);\n const captionLineHeight = resolveCssLengthPx(TOKEN_DEFAULTS.lineheightMd, 20);\n const centerValueY = this.centerCaption ? radius - (CENTER_GAP_PX + captionLineHeight) / 2 : radius;\n const centerCaptionY = radius + (valueLineHeight + CENTER_GAP_PX) / 2;\n\n // Show the full text before the post-render truncation pass has measured it (avoids a blank first paint).\n const fullValueText = this.centerValue ?? formatCompactNumber(total, this.locale);\n const fullCaptionText = this.centerCaption ?? '';\n const fill = this.isFillMode;\n\n return (\n <Host\n class={{\n 'chart-donut': true,\n 'chart-donut--fill': fill,\n 'chart-donut--fixed': !fill,\n }}\n style={!fill ? { width: `${size}px`, height: `${size}px` } : undefined}\n >\n <div\n class=\"chart-donut__wrapper\"\n style={!fill ? { width: `${size}px`, height: `${size}px` } : undefined}\n >\n <svg\n class=\"chart-donut__svg\"\n viewBox={`0 0 ${size} ${size}`}\n width={size}\n height={size}\n role={isEmpty ? 'img' : undefined}\n aria-label={isEmpty ? this.noDataLabel : undefined}\n >\n <g transform={`translate(${radius}, ${radius})`}>\n {isEmpty ? (\n <path d={emptyRingPath ?? undefined} fill=\"var(--color-background-faint-neutral)\" aria-hidden=\"true\" />\n ) : (\n slices.map((slice, i) => {\n const datum = this.data[i];\n const isDimmed = highlightLabel != null && datum.label !== highlightLabel;\n return (\n <path\n key={datum.label}\n d={arcGenerator(slice) ?? undefined}\n fill={datum.color ?? categoryColor(i)}\n opacity={isDimmed ? DIMMED_OPACITY : 1}\n tabindex={0}\n role=\"img\"\n aria-label={`${datum.label}: ${datum.value}`}\n onMouseEnter={() => this.handleHover(datum)}\n onMouseLeave={() => this.handleHover(null)}\n onFocus={() => this.handleHover(datum)}\n onBlur={() => this.handleHover(null)}\n />\n );\n })\n )}\n </g>\n <text\n class=\"chart-donut__center-value\"\n x={radius}\n y={centerValueY}\n text-anchor=\"middle\"\n dominant-baseline=\"central\"\n ref={el => {\n this.valueTextEl = (el as SVGTextElement) ?? undefined;\n }}\n >\n {this.displayedValueText || fullValueText}\n </text>\n {fullCaptionText && (\n <text\n class=\"chart-donut__center-caption\"\n x={radius}\n y={centerCaptionY}\n text-anchor=\"middle\"\n dominant-baseline=\"central\"\n ref={el => {\n this.captionTextEl = (el as SVGTextElement) ?? undefined;\n }}\n >\n {this.displayedCaptionText || fullCaptionText}\n </text>\n )}\n </svg>\n </div>\n </Host>\n );\n }\n}\n",
339
+ "content": "import { Component, Prop, State, Event, EventEmitter, Element, Watch, h, Host } from '@stencil/core';\nimport { arc, pie } from 'd3-shape';\nimport { categoryColor } from '../../utils/chart-colors';\nimport { formatCompactNumber, resolveCssLengthPx, truncateSvgTextToWidth, TOKEN_DEFAULTS } from '../../utils';\nimport type { ChartDatum } from '../../utils/chart-types';\n\nconst DIMMED_OPACITY = 0.25;\n/** Gap between the center value and caption line boxes. */\nconst CENTER_GAP_PX = 0;\n/** Fraction of the inner-circle diameter center text is allowed to use before truncating. */\nconst CENTER_TEXT_SAFE_WIDTH_RATIO = 0.82;\n/** Fallback when filling before the first ResizeObserver measurement (matches fill min). */\nconst FILL_FALLBACK_PX = 128;\n\n@Component({\n tag: 'ds-chart-donut',\n styleUrl: 'ChartDonut.css',\n scoped: true,\n})\nexport class ChartDonut {\n @Prop() locale: string | undefined;\n @Prop() noDataLabel: string = 'No data';\n @Element() el!: HTMLElement;\n\n /** Slices to render. Set as a JS property (not an HTML attribute). */\n @Prop() data: ChartDatum[] = [];\n /**\n * Explicit diameter in px. When unset, the donut sizes to its container\n * (ResizeObserver) clamped between `--dimension-size-base * 16` (128px) and\n * `* 24` (192px), and stays centered in the leftover space. Prefer unset\n * inside card layouts.\n */\n @Prop() size: number | undefined;\n /** Ring thickness — number (px) or TokoMo length. Defaults to `--dimension-size-200` (16px). */\n @Prop() thickness: number | string = TOKEN_DEFAULTS.size200;\n /** Corner radius on each slice — number (px) or TokoMo length. Defaults to `--dimension-radius-025` (2px). */\n @Prop() cornerRadius: number | string = TOKEN_DEFAULTS.radius025;\n /** Gap between slices, in degrees. */\n @Prop() gap: number = 1;\n /**\n * Show a chart-owned value callout for genuine pointer or keyboard focus.\n * Disable when a visible legend already owns persistent label and value detail.\n */\n @Prop() showTooltip: boolean = true;\n /** Primary center value; defaults to the sum of `data` values (e.g. \"187\", \"40.9%\"). */\n @Prop() centerValue: string | undefined;\n /** Secondary caption below the center value (e.g. \"Total\", \"Normal\"). Rendered uppercase. */\n @Prop() centerCaption: string | undefined;\n /**\n * Externally controlled highlight, matched by `label` — e.g. drive this from a sibling\n * `ds-chart-legend`'s `dsItemHover` event to keep chart and legend hover in sync.\n * Falls back to this component's own pointer/focus hover when unset.\n * Slice hover dims peer slices and emits `dsSliceHover` for legend sync. External\n * highlight never opens the local tooltip.\n */\n @Prop() activeLabel: string | null = null;\n\n @State() private hoveredLabel: string | null = null;\n /** Center text, truncated with an ellipsis if it doesn't fit inside the inner circle. */\n @State() private displayedValueText: string = '';\n @State() private displayedCaptionText: string = '';\n /** Measured fill diameter when `size` is unset. */\n @State() private measuredSize: number = FILL_FALLBACK_PX;\n @State() private tooltipX: number = 0;\n @State() private tooltipY: number = 0;\n\n /** Fires with the hovered/focused slice's datum, or `null` on leave/blur. */\n @Event() dsSliceHover!: EventEmitter<ChartDatum | null>;\n\n private valueTextEl?: SVGTextElement;\n private captionTextEl?: SVGTextElement;\n private resizeObserver: ResizeObserver | null = null;\n\n private get isFillMode(): boolean {\n return this.size == null || !Number.isFinite(this.size) || this.size <= 0;\n }\n\n private get fillMinPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.donutFillMin, FILL_FALLBACK_PX);\n }\n\n private get fillMaxPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.donutFillMax, 192);\n }\n\n /** Clamp available square side into the fill min/max band. */\n private clampFillSize(availablePx: number): number {\n const minPx = this.fillMinPx;\n const maxPx = this.fillMaxPx;\n if (!(availablePx > 0)) return minPx;\n return Math.round(Math.min(maxPx, Math.max(minPx, availablePx)));\n }\n\n private get resolvedSize(): number {\n if (!this.isFillMode) return this.size as number;\n return this.clampFillSize(this.measuredSize);\n }\n\n componentDidLoad() {\n this.syncResizeObserver();\n }\n\n @Watch('size')\n onSizePropChange() {\n this.syncResizeObserver();\n }\n\n disconnectedCallback() {\n this.teardownResizeObserver();\n }\n\n private syncResizeObserver() {\n this.teardownResizeObserver();\n if (!this.isFillMode) return;\n // Observe the host (which fills its parent), not the wrapper — the wrapper\n // only wraps the SVG and would collapse to the clamped size, locking fill at min.\n const target = this.el;\n if (!target || typeof ResizeObserver === 'undefined') return;\n\n this.resizeObserver = new ResizeObserver(entries => {\n const entry = entries[0];\n if (!entry) return;\n this.applyAvailableSize(entry.contentRect.width, entry.contentRect.height);\n });\n this.resizeObserver.observe(target);\n this.measureHost();\n }\n\n private measureHost() {\n const rect = this.el.getBoundingClientRect();\n this.applyAvailableSize(rect.width, rect.height);\n }\n\n private applyAvailableSize(width: number, height: number) {\n const available = Math.floor(Math.min(width, height));\n if (available > 0 && available !== this.measuredSize) {\n this.measuredSize = available;\n }\n }\n\n private teardownResizeObserver() {\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n }\n\n private handleHover(datum: ChartDatum | null) {\n this.hoveredLabel = datum?.label ?? null;\n this.dsSliceHover.emit(datum);\n }\n\n private get tooltipDatum(): ChartDatum | null {\n if (!this.showTooltip || this.hoveredLabel == null) return null;\n return this.data.find(datum => datum.label === this.hoveredLabel) ?? null;\n }\n\n private updateTooltipFromPointer(event: MouseEvent) {\n if (!this.showTooltip) return;\n const wrapper = this.el.querySelector<HTMLElement>('.chart-donut__wrapper');\n if (!wrapper) return;\n const rect = wrapper.getBoundingClientRect();\n this.tooltipX = event.clientX - rect.left;\n this.tooltipY = event.clientY - rect.top;\n }\n\n private updateTooltipFromSvgPoint(\n path: SVGPathElement,\n svgX: number,\n svgY: number,\n viewBoxSize: number,\n ) {\n if (!this.showTooltip) return;\n const wrapper = this.el.querySelector<HTMLElement>('.chart-donut__wrapper');\n const svg = path.ownerSVGElement;\n if (!wrapper || !svg) return;\n const wrapperRect = wrapper.getBoundingClientRect();\n const svgRect = svg.getBoundingClientRect();\n this.tooltipX = svgRect.left - wrapperRect.left + svgX * (svgRect.width / viewBoxSize);\n this.tooltipY = svgRect.top - wrapperRect.top + svgY * (svgRect.height / viewBoxSize);\n }\n\n componentDidRender() {\n // Re-bind after HMR when DidLoad's observer was dropped.\n if (this.isFillMode && !this.resizeObserver) {\n this.syncResizeObserver();\n }\n\n const size = this.resolvedSize;\n const radius = size / 2;\n const thicknessPx = resolveCssLengthPx(this.thickness, TOKEN_DEFAULTS.size200);\n const innerRadius = radius - thicknessPx;\n const maxWidth = innerRadius * 2 * CENTER_TEXT_SAFE_WIDTH_RATIO;\n const total = this.data.reduce((sum, d) => sum + d.value, 0);\n\n const fullValueText = this.centerValue ?? formatCompactNumber(total, this.locale);\n if (this.valueTextEl) {\n const truncated = truncateSvgTextToWidth(this.valueTextEl, fullValueText, maxWidth);\n if (truncated !== this.displayedValueText) this.displayedValueText = truncated;\n else this.valueTextEl.textContent = truncated;\n }\n\n const fullCaptionText = this.centerCaption ?? '';\n if (this.captionTextEl && fullCaptionText) {\n const truncated = truncateSvgTextToWidth(this.captionTextEl, fullCaptionText, maxWidth);\n if (truncated !== this.displayedCaptionText) this.displayedCaptionText = truncated;\n else this.captionTextEl.textContent = truncated;\n }\n }\n\n render() {\n const size = this.resolvedSize;\n const radius = size / 2;\n const thicknessPx = resolveCssLengthPx(this.thickness, TOKEN_DEFAULTS.size200);\n const cornerRadiusPx = resolveCssLengthPx(this.cornerRadius, TOKEN_DEFAULTS.radius025);\n const innerRadius = radius - thicknessPx;\n const total = this.data.reduce((sum, d) => sum + d.value, 0);\n // d3's pie() divides each value by the total to get an angle — with an all-zero total\n // that's 0/0, producing NaN angles and broken paths. Render a flat neutral ring instead.\n const isEmpty = total <= 0;\n // Highlight/dim from either this component's own hover or an externally-synced label\n // (e.g. a sibling ds-chart-legend row).\n const highlightLabel = this.activeLabel ?? this.hoveredLabel;\n\n const arcGenerator = arc<{ startAngle: number; endAngle: number }>()\n .innerRadius(innerRadius)\n .outerRadius(radius)\n .cornerRadius(cornerRadiusPx)\n .padAngle((this.gap * Math.PI) / 180);\n\n const slices = isEmpty\n ? []\n : pie<ChartDatum>()\n .value(d => d.value)\n .sort(null)(this.data);\n\n const emptyRingPath = isEmpty\n ? arc<{ startAngle: number; endAngle: number }>()\n .innerRadius(innerRadius)\n .outerRadius(radius)({ startAngle: 0, endAngle: Math.PI * 2 })\n : null;\n\n // Precise vertical centering for the two-line center text, using the real line-height of\n // text-title-large (value) and text-body-medium (caption) rather than eyeballed em offsets.\n const valueLineHeight = resolveCssLengthPx(TOKEN_DEFAULTS.lineheightXl, 32);\n const captionLineHeight = resolveCssLengthPx(TOKEN_DEFAULTS.lineheightMd, 20);\n const centerValueY = this.centerCaption ? radius - (CENTER_GAP_PX + captionLineHeight) / 2 : radius;\n const centerCaptionY = radius + (valueLineHeight + CENTER_GAP_PX) / 2;\n\n // Show the full text before the post-render truncation pass has measured it (avoids a blank first paint).\n const fullValueText = this.centerValue ?? formatCompactNumber(total, this.locale);\n const fullCaptionText = this.centerCaption ?? '';\n const fill = this.isFillMode;\n\n return (\n <Host\n class={{\n 'chart-donut': true,\n 'chart-donut--fill': fill,\n 'chart-donut--fixed': !fill,\n }}\n style={!fill ? { width: `${size}px`, height: `${size}px` } : undefined}\n >\n <div\n class=\"chart-donut__wrapper\"\n style={!fill ? { width: `${size}px`, height: `${size}px` } : undefined}\n >\n <svg\n class=\"chart-donut__svg\"\n viewBox={`0 0 ${size} ${size}`}\n width={size}\n height={size}\n role={isEmpty ? 'img' : undefined}\n aria-label={isEmpty ? this.noDataLabel : undefined}\n >\n <g transform={`translate(${radius}, ${radius})`}>\n {isEmpty ? (\n <path d={emptyRingPath ?? undefined} fill=\"var(--color-background-faint-neutral)\" aria-hidden=\"true\" />\n ) : (\n slices.map((slice, i) => {\n const datum = this.data[i];\n const isDimmed = highlightLabel != null && datum.label !== highlightLabel;\n const [centroidX, centroidY] = arcGenerator.centroid(slice);\n return (\n <path\n key={datum.label}\n d={arcGenerator(slice) ?? undefined}\n fill={datum.color ?? categoryColor(i)}\n opacity={isDimmed ? DIMMED_OPACITY : 1}\n tabindex={0}\n role=\"img\"\n aria-label={`${datum.label}: ${datum.value}`}\n onMouseEnter={(event: MouseEvent) => {\n this.updateTooltipFromPointer(event);\n this.handleHover(datum);\n }}\n onMouseMove={(event: MouseEvent) => this.updateTooltipFromPointer(event)}\n onMouseLeave={() => this.handleHover(null)}\n onFocus={(event: FocusEvent) => {\n this.updateTooltipFromSvgPoint(\n event.currentTarget as SVGPathElement,\n radius + centroidX,\n radius + centroidY,\n size,\n );\n this.handleHover(datum);\n }}\n onBlur={() => this.handleHover(null)}\n />\n );\n })\n )}\n </g>\n <text\n class=\"chart-donut__center-value\"\n x={radius}\n y={centerValueY}\n text-anchor=\"middle\"\n dominant-baseline=\"central\"\n ref={el => {\n this.valueTextEl = (el as SVGTextElement) ?? undefined;\n }}\n >\n {this.displayedValueText || fullValueText}\n </text>\n {fullCaptionText && (\n <text\n class=\"chart-donut__center-caption\"\n x={radius}\n y={centerCaptionY}\n text-anchor=\"middle\"\n dominant-baseline=\"central\"\n ref={el => {\n this.captionTextEl = (el as SVGTextElement) ?? undefined;\n }}\n >\n {this.displayedCaptionText || fullCaptionText}\n </text>\n )}\n </svg>\n {this.tooltipDatum && (\n <ds-tooltip-data-viz\n label={this.tooltipDatum.label}\n value={formatCompactNumber(this.tooltipDatum.value, this.locale)}\n x={this.tooltipX}\n y={this.tooltipY}\n />\n )}\n </div>\n </Host>\n );\n }\n}\n",
254
340
  "type": "registry:ui"
255
341
  }
256
342
  ]
@@ -2,15 +2,85 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "chart-legend",
4
4
  "title": "ChartLegend",
5
- "description": "Base legend for `ds-chart-*` components. Webapp's Overview widgets each style\nlegends differently (list w/ values, compact chips, bare swatches, external\nstat callouts) — this covers the common \"swatch + label + optional value\" case.\nConsumers who need a different treatment skip this component entirely and\nrender their own markup against the same `ChartDatum[]` data.",
5
+ "description": "Display-first chart legend with swatches, labels, optional values and percentages, explicit deep links, and highlight-only chart synchronization.",
6
6
  "type": "registry:ui",
7
7
  "meta": {
8
8
  "library": "@ds-mo/ui",
9
9
  "distribution": "npm",
10
10
  "storybook": "https://zainadeel.github.io/compomo/?path=/story/chart-legend",
11
11
  "source": "src/wc/components/ChartLegend/ChartLegend.tsx",
12
- "intentStatus": "migration-pending",
13
- "intent": null,
12
+ "intentStatus": "complete",
13
+ "intent": {
14
+ "audience": "specialized",
15
+ "status": "experimental",
16
+ "summary": "Display-first chart legend with swatches, labels, optional values and percentages, explicit deep links, and highlight-only chart synchronization.",
17
+ "useWhen": [
18
+ "A chart needs the standard swatch, label, and optional numeric-detail legend.",
19
+ "Donut slices or line series need a continuously visible key beside or below the visualization.",
20
+ "A category has an explicit deep-link destination and should render as an accessible link."
21
+ ],
22
+ "avoidWhen": [
23
+ "The design calls for chips, bare swatches, external stat callouts, or another custom legend treatment.",
24
+ "Rows need to toggle visibility, filter data, or represent selection.",
25
+ "The content is a command menu or navigation list unrelated to chart data."
26
+ ],
27
+ "alternatives": [
28
+ {
29
+ "when": "The product needs a nonstandard legend treatment.",
30
+ "component": "component:ds-text",
31
+ "reason": "Compose product-owned semantic markup from the same chart data rather than forcing it into this row recipe."
32
+ },
33
+ {
34
+ "when": "The donut belongs in the canonical dashboard card.",
35
+ "component": "component:ds-card-data-viz-donut",
36
+ "reason": "The dedicated card places the legend and handles bidirectional highlight synchronization."
37
+ }
38
+ ],
39
+ "commonlyComposedWith": [
40
+ "component:ds-chart-donut",
41
+ "component:ds-chart-line",
42
+ "component:ds-card-data-viz-donut",
43
+ "component:ds-card-shell-data-viz"
44
+ ],
45
+ "accessibility": [
46
+ "Rows remain non-interactive unless the item supplies an actual link destination.",
47
+ "Synced highlight changes opacity only and never moves focus or announces selection.",
48
+ "Keep full labels available when visible text truncates, and retain stable tabular numeric columns.",
49
+ "Do not rely on swatch color alone; every entry requires a meaningful visible label."
50
+ ],
51
+ "states": [
52
+ "Local pointer or focus interaction and external chart highlight remain separate; both may dim peers, but only a real link row receives interactive affordances.",
53
+ "Hover synchronization is temporary emphasis, never filtering, toggling, or selected state.",
54
+ "Percentage values use one consistent precision across the complete legend.",
55
+ "The list owns pointer leave so moving across row gaps does not clear and reapply highlight."
56
+ ],
57
+ "responsiveBehavior": [
58
+ "Vertical legends let the label yield width and truncate before right-aligned value and percentage columns.",
59
+ "Horizontal legends wrap complete entries and suit compact series keys below a chart.",
60
+ "The parent constrains width and decides when a custom condensed treatment is necessary."
61
+ ],
62
+ "frameworkCaveats": {
63
+ "customElements": [
64
+ "Assign items as a JavaScript property and set href only for genuine navigation."
65
+ ],
66
+ "react": [
67
+ "Intercept the item-click event only when application routing must replace native link navigation."
68
+ ],
69
+ "angular": [
70
+ "Intercept the item-click event only when application routing must replace native link navigation."
71
+ ]
72
+ },
73
+ "references": [
74
+ {
75
+ "label": "Storybook examples",
76
+ "path": "src/wc/components/ChartLegend/ChartLegend.stories.ts"
77
+ },
78
+ {
79
+ "label": "Rendered interaction tests",
80
+ "path": "tests/e2e/chart-legend.spec.ts"
81
+ }
82
+ ]
83
+ },
14
84
  "api": {
15
85
  "props": {
16
86
  "activeLabel": {
@@ -2,15 +2,79 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "chart-line",
4
4
  "title": "ChartLine",
5
- "description": "ChartLine component from @ds-mo/ui.",
5
+ "description": "Static single- or multi-series line chart for trends across ordered, evenly spaced categories, including preformatted time-period labels.",
6
6
  "type": "registry:ui",
7
7
  "meta": {
8
8
  "library": "@ds-mo/ui",
9
9
  "distribution": "npm",
10
10
  "storybook": "https://zainadeel.github.io/compomo/?path=/story/chart-line",
11
11
  "source": "src/wc/components/ChartLine/ChartLine.tsx",
12
- "intentStatus": "migration-pending",
13
- "intent": null,
12
+ "intentStatus": "complete",
13
+ "intent": {
14
+ "audience": "specialized",
15
+ "status": "experimental",
16
+ "summary": "Static single- or multi-series line chart for trends across ordered, evenly spaced categories, including preformatted time-period labels.",
17
+ "useWhen": [
18
+ "Users need to follow one or more numeric trends across a shared ordered x-axis.",
19
+ "Time periods are already grouped into equally spaced labels such as months, weeks, or days.",
20
+ "A horizontal ChartLegend should identify multiple series."
21
+ ],
22
+ "avoidWhen": [
23
+ "Pixel position must reflect irregular elapsed time or continuous timestamps.",
24
+ "The primary task is comparing unrelated category magnitudes rather than following a trend.",
25
+ "The chart needs built-in zoom, brushing, selection, or drill-down.",
26
+ "Series lengths do not align with the shared category list."
27
+ ],
28
+ "alternatives": [
29
+ {
30
+ "when": "Users need to compare one measure across discrete categories or time buckets.",
31
+ "component": "component:ds-chart-bar",
32
+ "reason": "Bars emphasize independent bucket magnitudes."
33
+ },
34
+ {
35
+ "when": "The data communicates parts of a total.",
36
+ "component": "component:ds-chart-donut",
37
+ "reason": "Donut provides the part-to-whole and center-summary treatment."
38
+ }
39
+ ],
40
+ "commonlyComposedWith": [
41
+ "component:ds-chart-legend",
42
+ "component:ds-card-shell-data-viz",
43
+ "component:ds-tooltip-data-viz"
44
+ ],
45
+ "accessibility": [
46
+ "Provide a visible legend or adjacent textual summary when exact series values are important; the chart image name alone is not a complete data table.",
47
+ "Do not rely on series color alone to communicate meaning.",
48
+ "A future point tooltip must supplement rather than replace accessible series and value context."
49
+ ],
50
+ "states": [
51
+ "The chart is currently static and does not own hover, selection, loading, or empty-state policy.",
52
+ "Point markers may be shown or hidden without changing the underlying series meaning.",
53
+ "Authored series colors override the shared categorical palette."
54
+ ],
55
+ "responsiveBehavior": [
56
+ "The owner supplies dimensions and updates them when its layout changes.",
57
+ "Category positions are always equally spaced, even when labels represent time.",
58
+ "Reduce or pre-format dense labels before passing them; the chart renders authored labels without a continuous time scale."
59
+ ],
60
+ "frameworkCaveats": {
61
+ "customElements": [
62
+ "Assign series and categories as JavaScript properties and keep every series aligned to the category count."
63
+ ],
64
+ "react": [
65
+ "Pass stable series and category arrays with dimensions derived from the containing layout."
66
+ ],
67
+ "angular": [
68
+ "Bind series and categories as properties with dimensions derived from the containing layout."
69
+ ]
70
+ },
71
+ "references": [
72
+ {
73
+ "label": "Storybook examples",
74
+ "path": "src/wc/components/ChartLine/ChartLine.stories.ts"
75
+ }
76
+ ]
77
+ },
14
78
  "api": {
15
79
  "props": {
16
80
  "categories": {
@@ -39,7 +39,7 @@
39
39
  "Busy and error are generic channel-neutral states; the owning product supplies suitable status wording. A busy row pairs its body-small status with the sm 16px Loader at an 8px gap.",
40
40
  "Any positive unreadCount renders one dot on the title row rather than a visible counter; the accessible row name may still announce the exact unread quantity.",
41
41
  "Hover and press use the shared interaction hover and pressed overlays. Only the selected conversation uses the persistent active fill.",
42
- "The title uses body-medium emphasis, not title-small, and the body-small preview truncates after one line. Unread titles use primary foreground; read titles use secondary foreground.",
42
+ "The title uses body-medium, not title-small, and the body-small preview truncates after one line. Unread titles use emphasis with primary foreground; read titles use regular weight with secondary foreground.",
43
43
  "The actions slot overlays the row at an 8px right inset without changing title or preview measure, and paints above the row interaction wash while that wash remains active."
44
44
  ],
45
45
  "responsiveBehavior": [
@@ -276,7 +276,7 @@
276
276
  },
277
277
  {
278
278
  "path": "src/wc/components/ConversationListItem/ConversationListItem.tsx",
279
- "content": "import { Component, Element, Event, EventEmitter, h, Host, Prop } from '@stencil/core';\nimport type { ConversationItemState } from '../conversation-types';\n\n@Component({\n tag: 'ds-conversation-list-item',\n styleUrl: 'ConversationListItem.css',\n scoped: true,\n})\nexport class ConversationListItem {\n @Element() el!: HTMLElement;\n\n @Prop() conversationId: string = '';\n /** Visible conversation title. Named to avoid the native HTMLElement.title contract. */\n @Prop() conversationTitle: string = '';\n @Prop() preview: string = '';\n /** ISO timestamp for semantic activity metadata. */\n @Prop() updatedAt: string = '';\n @Prop() selected: boolean = false;\n @Prop() state: ConversationItemState = 'default';\n @Prop() statusLabel: string = '';\n @Prop() unreadCount: number = 0;\n /** Keep the contextual-actions surface visible while its popup is open or closing. */\n @Prop({ reflect: true }) actionsOpen: boolean = false;\n\n @Event() dsSelect!: EventEmitter<{ id: string }>;\n\n private handleClick = () => this.dsSelect.emit({ id: this.conversationId });\n\n private handleActionsSlotChange = (event: Event) => {\n const slot = event.target as HTMLSlotElement;\n this.el.classList.toggle(\n 'conversation-list-item--has-actions',\n slot.assignedElements({ flatten: true }).length > 0\n );\n };\n\n render() {\n const busy = this.state === 'busy';\n const subtext = busy && this.statusLabel ? this.statusLabel : this.preview;\n const accessibleName = [\n this.conversationTitle,\n subtext,\n this.unreadCount > 0 ? `${this.unreadCount} unread` : '',\n ]\n .filter(Boolean)\n .join('. ');\n\n return (\n <Host\n role=\"listitem\"\n class={{ 'conversation-list-item--actions-open': this.actionsOpen }}\n >\n <div class=\"conversation-list-item__row\">\n <button\n type=\"button\"\n class={{\n 'conversation-list-item': true,\n 'ds-choice-item': true,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': this.selected,\n 'conversation-list-item--error': this.state === 'error',\n }}\n aria-label={accessibleName}\n aria-current={this.selected ? 'true' : undefined}\n aria-busy={busy ? 'true' : undefined}\n onClick={this.handleClick}\n >\n <span class=\"conversation-list-item__leading ds-choice-item__icon ds-interaction-fill__content\">\n <slot name=\"leading\" />\n </span>\n <span class=\"conversation-list-item__content ds-choice-item__content ds-interaction-fill__content\">\n <span class=\"conversation-list-item__title-row\">\n <ds-text\n class=\"conversation-list-item__title ds-choice-item__label\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis\n color={this.unreadCount > 0 ? 'primary' : 'secondary'}\n lineTruncation={1}\n >\n {this.conversationTitle}\n </ds-text>\n {this.unreadCount > 0 ? (\n <span class=\"conversation-list-item__unread\">\n <ds-badge variant=\"dot\" surface=\"secondary\" />\n </span>\n ) : null}\n </span>\n <span class=\"conversation-list-item__preview-row\">\n {busy ? <ds-loader size=\"sm\" color=\"secondary\" /> : null}\n <ds-text\n class=\"conversation-list-item__preview ds-choice-item__subtext\"\n as=\"span\"\n variant=\"text-body-small\"\n color={this.state === 'error' ? 'negative' : 'secondary'}\n lineTruncation={1}\n >\n {subtext}\n </ds-text>\n </span>\n {this.updatedAt ? (\n <time class=\"conversation-list-item__time\" dateTime={this.updatedAt}>\n {this.updatedAt}\n </time>\n ) : null}\n </span>\n <span class=\"conversation-list-item__trailing ds-interaction-fill__content\">\n <slot name=\"trailing\" />\n </span>\n </button>\n <div class=\"conversation-list-item__actions\">\n <slot name=\"actions\" onSlotchange={this.handleActionsSlotChange} />\n </div>\n </div>\n </Host>\n );\n }\n}\n",
279
+ "content": "import { Component, Element, Event, EventEmitter, h, Host, Prop } from '@stencil/core';\nimport type { ConversationItemState } from '../conversation-types';\n\n@Component({\n tag: 'ds-conversation-list-item',\n styleUrl: 'ConversationListItem.css',\n scoped: true,\n})\nexport class ConversationListItem {\n @Element() el!: HTMLElement;\n\n @Prop() conversationId: string = '';\n /** Visible conversation title. Named to avoid the native HTMLElement.title contract. */\n @Prop() conversationTitle: string = '';\n @Prop() preview: string = '';\n /** ISO timestamp for semantic activity metadata. */\n @Prop() updatedAt: string = '';\n @Prop() selected: boolean = false;\n @Prop() state: ConversationItemState = 'default';\n @Prop() statusLabel: string = '';\n @Prop() unreadCount: number = 0;\n /** Keep the contextual-actions surface visible while its popup is open or closing. */\n @Prop({ reflect: true }) actionsOpen: boolean = false;\n\n @Event() dsSelect!: EventEmitter<{ id: string }>;\n\n private handleClick = () => this.dsSelect.emit({ id: this.conversationId });\n\n private handleActionsSlotChange = (event: Event) => {\n const slot = event.target as HTMLSlotElement;\n this.el.classList.toggle(\n 'conversation-list-item--has-actions',\n slot.assignedElements({ flatten: true }).length > 0\n );\n };\n\n render() {\n const busy = this.state === 'busy';\n const subtext = busy && this.statusLabel ? this.statusLabel : this.preview;\n const accessibleName = [\n this.conversationTitle,\n subtext,\n this.unreadCount > 0 ? `${this.unreadCount} unread` : '',\n ]\n .filter(Boolean)\n .join('. ');\n\n return (\n <Host\n role=\"listitem\"\n class={{ 'conversation-list-item--actions-open': this.actionsOpen }}\n >\n <div class=\"conversation-list-item__row\">\n <button\n type=\"button\"\n class={{\n 'conversation-list-item': true,\n 'ds-choice-item': true,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': this.selected,\n 'conversation-list-item--error': this.state === 'error',\n }}\n aria-label={accessibleName}\n aria-current={this.selected ? 'true' : undefined}\n aria-busy={busy ? 'true' : undefined}\n onClick={this.handleClick}\n >\n <span class=\"conversation-list-item__leading ds-choice-item__icon ds-interaction-fill__content\">\n <slot name=\"leading\" />\n </span>\n <span class=\"conversation-list-item__content ds-choice-item__content ds-interaction-fill__content\">\n <span class=\"conversation-list-item__title-row\">\n <ds-text\n class=\"conversation-list-item__title ds-choice-item__label\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis={this.unreadCount > 0}\n color={this.unreadCount > 0 ? 'primary' : 'secondary'}\n lineTruncation={1}\n >\n {this.conversationTitle}\n </ds-text>\n {this.unreadCount > 0 ? (\n <span class=\"conversation-list-item__unread\">\n <ds-badge variant=\"dot\" surface=\"secondary\" />\n </span>\n ) : null}\n </span>\n <span class=\"conversation-list-item__preview-row\">\n {busy ? <ds-loader size=\"sm\" color=\"secondary\" /> : null}\n <ds-text\n class=\"conversation-list-item__preview ds-choice-item__subtext\"\n as=\"span\"\n variant=\"text-body-small\"\n color={this.state === 'error' ? 'negative' : 'secondary'}\n lineTruncation={1}\n >\n {subtext}\n </ds-text>\n </span>\n {this.updatedAt ? (\n <time class=\"conversation-list-item__time\" dateTime={this.updatedAt}>\n {this.updatedAt}\n </time>\n ) : null}\n </span>\n <span class=\"conversation-list-item__trailing ds-interaction-fill__content\">\n <slot name=\"trailing\" />\n </span>\n </button>\n <div class=\"conversation-list-item__actions\">\n <slot name=\"actions\" onSlotchange={this.handleActionsSlotChange} />\n </div>\n </div>\n </Host>\n );\n }\n}\n",
280
280
  "type": "registry:ui"
281
281
  }
282
282
  ]
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "message-bubble",
4
4
  "title": "MessageBubble",
5
- "description": "Visible message content surface with a body-medium default, separated from message-row direction and metadata.",
5
+ "description": "Visible plain-message surface with a body-medium default, separated from message-row direction and metadata.",
6
6
  "type": "registry:ui",
7
7
  "meta": {
8
8
  "library": "@ds-mo/ui",
@@ -13,18 +13,18 @@
13
13
  "intent": {
14
14
  "audience": "general",
15
15
  "status": "experimental",
16
- "summary": "Visible message content surface with a body-medium default, separated from message-row direction and metadata.",
16
+ "summary": "Visible plain-message surface with a body-medium default, separated from message-row direction and metadata.",
17
17
  "useWhen": [
18
- "Message content needs a primary, secondary, or ghost surface within a Message row.",
19
- "Plain incoming or outgoing message copy should use the shared body-medium regular typography recipe without application styling."
18
+ "Plain user-sent or received person-to-person message copy needs a visible surface within a Message row.",
19
+ "Plain conversation copy should use the shared body-medium regular typography recipe without application styling."
20
20
  ],
21
21
  "avoidWhen": [
22
22
  "The surface is a general card, callout, or form container.",
23
- "The application wants to promote ordinary conversation copy to body-large; message typography stays body-medium unless a distinct authored rich-content element owns its own complete type recipe."
23
+ "The content is a rich agent response; compose AgentResponse directly in Message.",
24
+ "The state only indicates that another participant is typing; use TypingIndicator."
24
25
  ],
25
26
  "commonlyComposedWith": [
26
- "component:ds-message",
27
- "component:ds-markdown"
27
+ "component:ds-message"
28
28
  ],
29
29
  "patterns": [
30
30
  "pattern:conversation"
@@ -32,8 +32,13 @@
32
32
  "accessibility": [
33
33
  "Surface styling does not add semantics; failed delivery belongs in Message metadata below the unchanged bubble rather than in an error bubble treatment."
34
34
  ],
35
+ "states": [
36
+ "The user variant represents user-sent copy in agent and person-to-person channels and uses secondary foreground.",
37
+ "The received variant represents ordinary incoming person-to-person copy.",
38
+ "Typing and rich agent output are not MessageBubble variants."
39
+ ],
35
40
  "responsiveBehavior": [
36
- "The bubble wraps content and allows wide ghost responses to use more transcript width."
41
+ "The bubble wraps plain message copy within the transcript width."
37
42
  ],
38
43
  "references": [
39
44
  {
@@ -46,9 +51,9 @@
46
51
  "props": {
47
52
  "variant": {
48
53
  "type": "MessageBubbleVariant",
49
- "resolvedType": "\"ghost\" | \"primary\" | \"secondary\"",
54
+ "resolvedType": "\"received\" | \"user\"",
50
55
  "attribute": "variant",
51
- "default": "'secondary'",
56
+ "default": "'received'",
52
57
  "required": false,
53
58
  "mutable": false
54
59
  }
@@ -60,9 +65,9 @@
60
65
  "props": {
61
66
  "variant": {
62
67
  "type": "MessageBubbleVariant",
63
- "resolvedType": "\"ghost\" | \"primary\" | \"secondary\"",
68
+ "resolvedType": "\"received\" | \"user\"",
64
69
  "attribute": "variant",
65
- "default": "'secondary'",
70
+ "default": "'received'",
66
71
  "required": false,
67
72
  "mutable": false
68
73
  }
@@ -107,12 +112,12 @@
107
112
  "files": [
108
113
  {
109
114
  "path": "src/wc/components/MessageBubble/MessageBubble.css",
110
- "content": ":host {\n display: inline-flex;\n max-width: 80%;\n box-sizing: border-box;\n -webkit-user-select: text;\n user-select: text;\n}\n\n:host(.message-bubble--ghost) {\n display: block;\n max-width: 100%;\n}\n\n.message-bubble {\n min-width: 0;\n padding-block: var(--dimension-space-100);\n padding-inline: calc(var(--dimension-space-100) + var(--dimension-space-025));\n border-radius: var(--dimension-radius-050);\n background: transparent;\n color: var(--color-foreground-primary);\n font-family: var(--typography-font-family);\n font-size: var(--typography-fontsize-md);\n line-height: var(--typography-lineheight-md);\n font-weight: var(--typography-weight-regular);\n letter-spacing: var(--typography-letterspacing-negative-half);\n box-sizing: border-box;\n overflow-wrap: anywhere;\n}\n\n:host(.message-bubble--primary) .message-bubble {\n background: var(--color-translucent-translucent);\n color: var(--color-foreground-primary);\n box-shadow: inset 0 0 0 var(--dimension-stroke-width-012) var(--color-border-tertiary);\n}\n\n:host(.message-bubble--secondary) .message-bubble {\n box-shadow: inset 0 0 0 var(--dimension-stroke-width-012) var(--color-border-tertiary);\n}\n\n:host(.message-bubble--ghost) .message-bubble {\n padding-inline: 0;\n background: transparent;\n border-radius: 0;\n}\n",
115
+ "content": ":host {\n display: inline-flex;\n max-width: 80%;\n box-sizing: border-box;\n -webkit-user-select: text;\n user-select: text;\n}\n\n.message-bubble {\n min-width: 0;\n padding-block: var(--dimension-space-100);\n padding-inline: calc(var(--dimension-space-100) + var(--dimension-space-025));\n border-radius: var(--dimension-radius-050);\n background: transparent;\n color: var(--color-foreground-primary);\n font-family: var(--typography-font-family);\n font-size: var(--typography-fontsize-md);\n line-height: var(--typography-lineheight-md);\n font-weight: var(--typography-weight-regular);\n letter-spacing: var(--typography-letterspacing-negative-half);\n box-sizing: border-box;\n overflow-wrap: anywhere;\n}\n\n:host(.message-bubble--user) .message-bubble {\n background: var(--color-translucent-translucent);\n color: var(--color-foreground-secondary);\n box-shadow: inset 0 0 0 var(--dimension-stroke-width-012) var(--color-border-tertiary);\n}\n\n:host(.message-bubble--received) .message-bubble {\n box-shadow: inset 0 0 0 var(--dimension-stroke-width-012) var(--color-border-tertiary);\n}\n",
111
116
  "type": "registry:ui"
112
117
  },
113
118
  {
114
119
  "path": "src/wc/components/MessageBubble/MessageBubble.tsx",
115
- "content": "import { Component, h, Host, Prop } from '@stencil/core';\n\nexport type MessageBubbleVariant = 'primary' | 'secondary' | 'ghost';\n\n@Component({\n tag: 'ds-message-bubble',\n styleUrl: 'MessageBubble.css',\n scoped: true,\n})\nexport class MessageBubble {\n @Prop() variant: MessageBubbleVariant = 'secondary';\n\n render() {\n return (\n <Host class={`message-bubble--${this.variant}`}>\n <div class=\"message-bubble\"><slot /></div>\n </Host>\n );\n }\n}\n",
120
+ "content": "import { Component, h, Host, Prop } from '@stencil/core';\n\nexport type MessageBubbleVariant = 'user' | 'received';\n\n@Component({\n tag: 'ds-message-bubble',\n styleUrl: 'MessageBubble.css',\n scoped: true,\n})\nexport class MessageBubble {\n @Prop() variant: MessageBubbleVariant = 'received';\n\n render() {\n return (\n <Host class={`message-bubble--${this.variant}`}>\n <div class=\"message-bubble\"><slot /></div>\n </Host>\n );\n }\n}\n",
116
121
  "type": "registry:ui"
117
122
  }
118
123
  ]
@@ -35,7 +35,9 @@
35
35
  ],
36
36
  "states": [
37
37
  "The owner controls draft value and transport state; the composer emits intent without performing transport.",
38
- "Use the tools slot for leading actions such as add and the actions slot for trailing actions such as dictation; the built-in submit or stop action remains last.",
38
+ "Use the tools slot for leading actions such as add and the actions slot for trailing actions such as dictation; the built-in submit or stop action remains last with an 8px gap from the preceding action. These icon buttons use the standard non-rounded control radius.",
39
+ "The composer surface uses the 10px --dimension-radius-125 token.",
40
+ "Mouse or keyboard focus on the textarea gives the complete composer surface the same bold-brand inset stroke width and color as an active Input; the native textarea never draws a separate outline.",
39
41
  "The submit action defaults to brand for both agent and person-to-person conversations; change submitIntent only when the action consequence has a different semantic intent."
40
42
  ],
41
43
  "responsiveBehavior": [
@@ -253,12 +255,12 @@
253
255
  "files": [
254
256
  {
255
257
  "path": "src/wc/components/MessageComposer/MessageComposer.css",
256
- "content": ":host {\n display: block;\n width: 100%;\n box-sizing: border-box;\n -webkit-user-select: none;\n user-select: none;\n}\n\n.message-composer {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-050);\n width: 100%;\n margin: 0;\n}\n\n.message-composer__attachments:empty,\n.message-composer__tools:empty {\n display: none;\n}\n\n.message-composer__field {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-050);\n padding: var(--dimension-space-100);\n border-radius: var(--dimension-radius-050);\n background: var(--color-background-primary);\n box-shadow: var(--effect-elevation-elevated-md);\n overflow: hidden;\n}\n\n.message-composer--error .message-composer__field {\n box-shadow: var(--effect-elevation-elevated-md),\n inset 0 0 0 var(--dimension-stroke-width-025) var(--color-border-bold-negative);\n}\n\ntextarea {\n width: 100%;\n min-height: calc(var(--typography-lineheight-md) * 2 + var(--dimension-space-150));\n max-height: calc(var(--typography-lineheight-md) * 6 + var(--dimension-space-150));\n padding: var(--dimension-space-075);\n border: none;\n outline: none;\n resize: none;\n overflow-y: auto;\n box-sizing: border-box;\n background: transparent;\n color: var(--color-foreground-primary);\n font-family: var(--typography-font-family);\n font-size: var(--typography-fontsize-md);\n line-height: var(--typography-lineheight-md);\n font-weight: var(--typography-weight-regular);\n letter-spacing: var(--typography-letterspacing-negative-half);\n -webkit-user-select: text;\n user-select: text;\n}\n\ntextarea::placeholder {\n color: var(--color-foreground-secondary);\n}\n\ntextarea:focus::placeholder {\n color: var(--color-foreground-quaternary);\n}\n\n.message-composer__tools {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n min-height: var(--dimension-size-400);\n}\n\n.message-composer__controls {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--dimension-space-050);\n width: 100%;\n min-width: 0;\n}\n\n.message-composer__actions {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n flex: 0 0 auto;\n margin-inline-start: auto;\n}\n",
258
+ "content": ":host {\n display: block;\n width: 100%;\n box-sizing: border-box;\n -webkit-user-select: none;\n user-select: none;\n}\n\n.message-composer {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-050);\n width: 100%;\n margin: 0;\n}\n\n.message-composer__attachments:empty,\n.message-composer__tools:empty {\n display: none;\n}\n\n.message-composer__field {\n display: flex;\n flex-direction: column;\n gap: var(--dimension-space-050);\n padding: var(--dimension-space-100);\n border-radius: var(--dimension-radius-125);\n background: var(--color-background-primary);\n box-shadow: var(--effect-elevation-elevated-md);\n overflow: hidden;\n}\n\n.message-composer:not(.message-composer--error) .message-composer__field:focus-within {\n box-shadow: var(--effect-elevation-elevated-md),\n inset 0 0 0 var(--dimension-stroke-width-018) var(--color-border-bold-brand);\n}\n\n.message-composer--error .message-composer__field {\n box-shadow: var(--effect-elevation-elevated-md),\n inset 0 0 0 var(--dimension-stroke-width-025) var(--color-border-bold-negative);\n}\n\ntextarea {\n width: 100%;\n min-height: calc(var(--typography-lineheight-md) * 2 + var(--dimension-space-150));\n max-height: calc(var(--typography-lineheight-md) * 6 + var(--dimension-space-150));\n padding: var(--dimension-space-075);\n border: none;\n outline: none;\n resize: none;\n overflow-y: auto;\n box-sizing: border-box;\n background: transparent;\n color: var(--color-foreground-primary);\n font-family: var(--typography-font-family);\n font-size: var(--typography-fontsize-md);\n line-height: var(--typography-lineheight-md);\n font-weight: var(--typography-weight-regular);\n letter-spacing: var(--typography-letterspacing-negative-half);\n -webkit-user-select: text;\n user-select: text;\n}\n\ntextarea::placeholder {\n color: var(--color-foreground-secondary);\n}\n\ntextarea:focus::placeholder {\n color: var(--color-foreground-quaternary);\n}\n\ntextarea:focus,\ntextarea:focus-visible {\n outline: none;\n}\n\n.message-composer__tools {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n min-height: var(--dimension-size-400);\n}\n\n.message-composer__controls {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--dimension-space-050);\n width: 100%;\n min-width: 0;\n}\n\n.message-composer__actions {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-100);\n flex: 0 0 auto;\n margin-inline-start: auto;\n}\n",
257
259
  "type": "registry:ui"
258
260
  },
259
261
  {
260
262
  "path": "src/wc/components/MessageComposer/MessageComposer.tsx",
261
- "content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n Watch,\n} from '@stencil/core';\nimport type { ButtonFilledIntent } from '../ButtonFilled/ButtonFilled';\nimport type { MessageComposerStatus } from '../conversation-types';\n\n@Component({\n tag: 'ds-message-composer',\n styleUrl: 'MessageComposer.css',\n scoped: true,\n})\nexport class MessageComposer {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) value: string = '';\n @Prop() placeholder: string = 'Write a message';\n @Prop() label: string = 'Message';\n @Prop() status: MessageComposerStatus = 'ready';\n @Prop() isInactive: boolean = false;\n @Prop() submitIntent: ButtonFilledIntent = 'brand';\n\n @Event() dsInput!: EventEmitter<string>;\n @Event() dsSubmit!: EventEmitter<{ text: string }>;\n @Event() dsStop!: EventEmitter<void>;\n\n private textarea?: HTMLTextAreaElement;\n\n componentDidLoad() {\n this.resize();\n }\n\n @Watch('value')\n onValueChange() {\n requestAnimationFrame(() => this.resize());\n }\n\n @Method()\n async setFocus() {\n this.textarea?.focus();\n }\n\n private get streaming(): boolean {\n return this.status === 'submitted' || this.status === 'streaming';\n }\n\n private resize() {\n if (!this.textarea) return;\n this.textarea.style.height = 'auto';\n this.textarea.style.height = `${this.textarea.scrollHeight}px`;\n }\n\n private handleInput = (event: Event) => {\n this.value = (event.target as HTMLTextAreaElement).value;\n this.resize();\n this.dsInput.emit(this.value);\n };\n\n private submit() {\n const text = this.value.trim();\n if (!text || this.isInactive || this.streaming) return;\n this.dsSubmit.emit({ text });\n }\n\n private handleKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;\n event.preventDefault();\n this.submit();\n };\n\n private handleAction = () => {\n if (this.streaming) this.dsStop.emit();\n else this.submit();\n };\n\n render() {\n const error = this.status === 'error';\n return (\n <Host>\n <form\n class={{ 'message-composer': true, 'message-composer--error': error }}\n aria-busy={this.streaming ? 'true' : undefined}\n onSubmit={(event: Event) => {\n event.preventDefault();\n this.submit();\n }}\n >\n <div class=\"message-composer__attachments\">\n <slot name=\"attachments\" />\n </div>\n <div class=\"message-composer__field\">\n <textarea\n ref={element => {\n this.textarea = element;\n }}\n value={this.value}\n placeholder={this.placeholder}\n aria-label={this.label}\n aria-invalid={error ? 'true' : undefined}\n disabled={this.isInactive}\n rows={2}\n onInput={this.handleInput}\n onKeyDown={this.handleKeyDown}\n />\n <div class=\"message-composer__controls\">\n <div class=\"message-composer__tools\">\n <slot name=\"tools\" />\n </div>\n <div class=\"message-composer__actions\">\n <slot name=\"actions\" />\n <ds-button-filled\n class=\"message-composer__action\"\n variant=\"icon\"\n icon={this.streaming ? 'SquareFilled' : 'ArrowUp'}\n intent={this.streaming ? 'neutral' : this.submitIntent}\n size=\"md\"\n rounded\n aria-label={this.streaming ? 'Stop response' : 'Send message'}\n isInactive={this.isInactive || (!this.streaming && !this.value.trim())}\n onDsClick={this.handleAction}\n />\n </div>\n </div>\n </div>\n </form>\n </Host>\n );\n }\n}\n",
263
+ "content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n Watch,\n} from '@stencil/core';\nimport type { ButtonFilledIntent } from '../ButtonFilled/ButtonFilled';\nimport type { MessageComposerStatus } from '../conversation-types';\n\n@Component({\n tag: 'ds-message-composer',\n styleUrl: 'MessageComposer.css',\n scoped: true,\n})\nexport class MessageComposer {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) value: string = '';\n @Prop() placeholder: string = 'Write a message';\n @Prop() label: string = 'Message';\n @Prop() status: MessageComposerStatus = 'ready';\n @Prop() isInactive: boolean = false;\n @Prop() submitIntent: ButtonFilledIntent = 'brand';\n\n @Event() dsInput!: EventEmitter<string>;\n @Event() dsSubmit!: EventEmitter<{ text: string }>;\n @Event() dsStop!: EventEmitter<void>;\n\n private textarea?: HTMLTextAreaElement;\n\n componentDidLoad() {\n this.resize();\n }\n\n @Watch('value')\n onValueChange() {\n requestAnimationFrame(() => this.resize());\n }\n\n @Method()\n async setFocus() {\n this.textarea?.focus();\n }\n\n private get streaming(): boolean {\n return this.status === 'submitted' || this.status === 'streaming';\n }\n\n private resize() {\n if (!this.textarea) return;\n this.textarea.style.height = 'auto';\n this.textarea.style.height = `${this.textarea.scrollHeight}px`;\n }\n\n private handleInput = (event: Event) => {\n this.value = (event.target as HTMLTextAreaElement).value;\n this.resize();\n this.dsInput.emit(this.value);\n };\n\n private submit() {\n const text = this.value.trim();\n if (!text || this.isInactive || this.streaming) return;\n this.dsSubmit.emit({ text });\n }\n\n private handleKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return;\n event.preventDefault();\n this.submit();\n };\n\n private handleAction = () => {\n if (this.streaming) this.dsStop.emit();\n else this.submit();\n };\n\n render() {\n const error = this.status === 'error';\n return (\n <Host>\n <form\n class={{ 'message-composer': true, 'message-composer--error': error }}\n aria-busy={this.streaming ? 'true' : undefined}\n onSubmit={(event: Event) => {\n event.preventDefault();\n this.submit();\n }}\n >\n <div class=\"message-composer__attachments\">\n <slot name=\"attachments\" />\n </div>\n <div class=\"message-composer__field\">\n <textarea\n ref={element => {\n this.textarea = element;\n }}\n value={this.value}\n placeholder={this.placeholder}\n aria-label={this.label}\n aria-invalid={error ? 'true' : undefined}\n disabled={this.isInactive}\n rows={2}\n onInput={this.handleInput}\n onKeyDown={this.handleKeyDown}\n />\n <div class=\"message-composer__controls\">\n <div class=\"message-composer__tools\">\n <slot name=\"tools\" />\n </div>\n <div class=\"message-composer__actions\">\n <slot name=\"actions\" />\n <ds-button-filled\n class=\"message-composer__action\"\n variant=\"icon\"\n icon={this.streaming ? 'SquareFilled' : 'ArrowUp'}\n intent={this.streaming ? 'neutral' : this.submitIntent}\n size=\"md\"\n aria-label={this.streaming ? 'Stop response' : 'Send message'}\n isInactive={this.isInactive || (!this.streaming && !this.value.trim())}\n onDsClick={this.handleAction}\n />\n </div>\n </div>\n </div>\n </form>\n </Host>\n );\n }\n}\n",
262
264
  "type": "registry:ui"
263
265
  }
264
266
  ]
@@ -36,7 +36,7 @@
36
36
  "Selection and transcript interaction release automatic following.",
37
37
  "Opening saved history, message jumps, and prepended history preserve deliberate position.",
38
38
  "The scroll-to-latest action is absent at the live edge, appears only after the reader moves away, and disappears again after returning to the newest message.",
39
- "Place a composer container in the overlay slot so messages scroll behind it; the scroller measures its live height, reserves matching transcript clearance, and keeps scroll-to-latest above it."
39
+ "Place a composer container with 8px padding on every side in the overlay slot so messages scroll behind it; the scroller measures its live height, reserves matching transcript clearance, and keeps scroll-to-latest above it."
40
40
  ],
41
41
  "responsiveBehavior": [
42
42
  "Transcript width is readable while the scroll region fills available height."