@ds-mo/ui 12.8.0 → 12.9.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 (143) hide show
  1. package/dist/.build-stamp +1 -1
  2. package/dist/agent-patterns.json +1 -1
  3. package/dist/agent.json +21 -6
  4. package/dist/angular/ds-card-overview.d.ts +1 -1
  5. package/dist/angular/ds-card-overview.js +3 -3
  6. package/dist/components/ds-agent-activity.js +1 -1
  7. package/dist/components/ds-agent-response.js +1 -1
  8. package/dist/components/ds-agent-source-list.js +1 -1
  9. package/dist/components/ds-agent-tool-call.js +1 -1
  10. package/dist/components/ds-attachment-list.js +1 -1
  11. package/dist/components/ds-badge.js +1 -1
  12. package/dist/components/ds-bar-nav.js +1 -1
  13. package/dist/components/ds-bar-title.js +1 -1
  14. package/dist/components/ds-bar-workflow.js +1 -1
  15. package/dist/components/ds-breadcrumb.js +1 -1
  16. package/dist/components/ds-button-filled.js +1 -1
  17. package/dist/components/ds-button-unfilled.js +1 -1
  18. package/dist/components/ds-card-data-viz-bar.js +1 -1
  19. package/dist/components/ds-card-data-viz-donut.js +1 -1
  20. package/dist/components/ds-card-data-viz-line.js +1 -1
  21. package/dist/components/ds-card-overview.js +1 -1
  22. package/dist/components/ds-card-overview.js.map +1 -1
  23. package/dist/components/ds-card-setting.js +1 -1
  24. package/dist/components/ds-card-shell-data-viz.js +1 -1
  25. package/dist/components/ds-chart-bar.js +1 -1
  26. package/dist/components/ds-chart-donut.js +1 -1
  27. package/dist/components/ds-chart-legend.js +1 -1
  28. package/dist/components/ds-chart-line.js +1 -1
  29. package/dist/components/ds-checkbox.js +1 -1
  30. package/dist/components/ds-chip.js +1 -1
  31. package/dist/components/ds-code-block.js +1 -1
  32. package/dist/components/ds-conversation-list-item.js +1 -1
  33. package/dist/components/ds-conversation-list-section.js +1 -1
  34. package/dist/components/ds-empty-state.js +1 -1
  35. package/dist/components/ds-field.js +1 -1
  36. package/dist/components/ds-input.js +1 -1
  37. package/dist/components/ds-markdown.js +1 -1
  38. package/dist/components/ds-menu.js +1 -1
  39. package/dist/components/ds-message-composer.js +1 -1
  40. package/dist/components/ds-message-scroller.js +1 -1
  41. package/dist/components/ds-message.js +1 -1
  42. package/dist/components/ds-mobile-bar-nav.js +1 -1
  43. package/dist/components/ds-mobile-header.js +1 -1
  44. package/dist/components/ds-mobile-section-switcher.js +1 -1
  45. package/dist/components/ds-mobile-sheet-nav.js +1 -1
  46. package/dist/components/ds-modal.js +1 -1
  47. package/dist/components/ds-panel-nav.js +1 -1
  48. package/dist/components/ds-panel-sub-nav.js +1 -1
  49. package/dist/components/ds-panel-tool-header.js +1 -1
  50. package/dist/components/ds-panel-tool-search.js +1 -1
  51. package/dist/components/ds-panel-tools.js +1 -1
  52. package/dist/components/ds-radio.js +1 -1
  53. package/dist/components/ds-select-multi.js +1 -1
  54. package/dist/components/ds-select-multi.js.map +1 -1
  55. package/dist/components/ds-select.js +1 -1
  56. package/dist/components/ds-select.js.map +1 -1
  57. package/dist/components/ds-shell-app.js +1 -1
  58. package/dist/components/ds-shell-app.js.map +1 -1
  59. package/dist/components/ds-shell-gradient-picker.js +1 -1
  60. package/dist/components/ds-shell-gradient-swatch.js +1 -1
  61. package/dist/components/ds-shell-page.js +1 -1
  62. package/dist/components/ds-shell-tools.js +1 -1
  63. package/dist/components/ds-slider.js +1 -1
  64. package/dist/components/ds-swatch-picker.js +1 -1
  65. package/dist/components/ds-tab-group.js +1 -1
  66. package/dist/components/ds-tag.js +1 -1
  67. package/dist/components/ds-text.js +1 -1
  68. package/dist/components/ds-toast.js +1 -1
  69. package/dist/components/ds-tooltip-data-viz.js +1 -1
  70. package/dist/components/ds-tooltip.js +1 -1
  71. package/dist/components/ds-typing-indicator.js +1 -1
  72. package/dist/components/{p-huw1oRbt.js → p-8GK7B7K4.js} +2 -2
  73. package/dist/components/{p-DzM3SzPz.js → p-B5tMlca-.js} +2 -2
  74. package/dist/components/{p-D0e77tkd.js → p-B88n0pO3.js} +2 -2
  75. package/dist/components/p-BBxcMVqk.js +2 -0
  76. package/dist/components/p-BBxcMVqk.js.map +1 -0
  77. package/dist/components/{p-BlHzmszE.js → p-BDKtu6Wb.js} +2 -2
  78. package/dist/components/{p-DhLmoCe_.js → p-BOCfICln.js} +2 -2
  79. package/dist/components/{p-CNgPwd11.js → p-BhV5e6vF.js} +2 -2
  80. package/dist/components/{p-ukB-dPOK.js → p-C8HU4W8q.js} +2 -2
  81. package/dist/components/{p-D9u1_OEZ.js → p-CChcuL04.js} +2 -2
  82. package/dist/components/{p-CsX3ICz7.js → p-CCzG5xDD.js} +2 -2
  83. package/dist/components/{p-B6vE3ujd.js → p-CGVXbXv9.js} +2 -2
  84. package/dist/components/p-CGVXbXv9.js.map +1 -0
  85. package/dist/components/{p-BFRMwl93.js → p-CIiBpjCn.js} +2 -2
  86. package/dist/components/{p-DB9XJm2c.js → p-CgEYdMee.js} +2 -2
  87. package/dist/components/{p-CPwpgUWT.js → p-Cr3tEx7o.js} +2 -2
  88. package/dist/components/p-Cr3tEx7o.js.map +1 -0
  89. package/dist/components/{p-Vdufoj7G.js → p-Cuih680v.js} +2 -2
  90. package/dist/components/{p-BXaieghV.js → p-Cuqxh-Tq.js} +2 -2
  91. package/dist/components/{p-Utr23NC-.js → p-DCvA3Dc9.js} +2 -2
  92. package/dist/components/{p-G3qIxpQj.js → p-DLZm_-sU.js} +2 -2
  93. package/dist/components/{p-38PfdMkF.js → p-DRnIuCiT.js} +2 -2
  94. package/dist/components/p-DjKkSqrG.js +2 -0
  95. package/dist/components/p-DjKkSqrG.js.map +1 -0
  96. package/dist/components/{p-DIFUsoM-.js → p-DlscFn2m.js} +2 -2
  97. package/dist/components/{p-CNjOyUQu.js → p-DxkEC0hR.js} +2 -2
  98. package/dist/components/{p-CU2673IJ.js → p-XMZCaXs7.js} +2 -2
  99. package/dist/components/{p-69lHhJiX.js → p-YR1XeuZf.js} +2 -2
  100. package/dist/components/{p-DmAHufd5.js → p-dyUp3Adm.js} +2 -2
  101. package/dist/components/{p-B6NvVdeO.js → p-nkYN0Bub.js} +2 -2
  102. package/dist/docs/components.json +95 -3
  103. package/dist/mcp-data/registry/card-overview.json +54 -11
  104. package/dist/mcp-data/registry/menu.json +1 -1
  105. package/dist/mcp-data/registry/registry.json +20 -5
  106. package/dist/mcp-data/registry/select-multi.json +1 -1
  107. package/dist/mcp-data/registry/select.json +1 -1
  108. package/dist/mcp-data/registry/text.json +2 -2
  109. package/dist/types/components/CardOverview/CardOverview.d.ts +8 -3
  110. package/dist/types/components/CardOverview/card-overview-types.d.ts +2 -0
  111. package/dist/types/components/CardOverview/index.d.ts +1 -1
  112. package/dist/types/components/Menu/Menu.d.ts +0 -1
  113. package/dist/types/components.d.ts +28 -6
  114. package/dist/types/utils/anchored-position-controller.d.ts +9 -0
  115. package/package.json +1 -1
  116. package/dist/components/p-B6vE3ujd.js.map +0 -1
  117. package/dist/components/p-By_s1wNN.js +0 -2
  118. package/dist/components/p-By_s1wNN.js.map +0 -1
  119. package/dist/components/p-CPwpgUWT.js.map +0 -1
  120. package/dist/components/p-D1bNvIpb.js +0 -2
  121. package/dist/components/p-D1bNvIpb.js.map +0 -1
  122. /package/dist/components/{p-huw1oRbt.js.map → p-8GK7B7K4.js.map} +0 -0
  123. /package/dist/components/{p-DzM3SzPz.js.map → p-B5tMlca-.js.map} +0 -0
  124. /package/dist/components/{p-D0e77tkd.js.map → p-B88n0pO3.js.map} +0 -0
  125. /package/dist/components/{p-BlHzmszE.js.map → p-BDKtu6Wb.js.map} +0 -0
  126. /package/dist/components/{p-DhLmoCe_.js.map → p-BOCfICln.js.map} +0 -0
  127. /package/dist/components/{p-CNgPwd11.js.map → p-BhV5e6vF.js.map} +0 -0
  128. /package/dist/components/{p-ukB-dPOK.js.map → p-C8HU4W8q.js.map} +0 -0
  129. /package/dist/components/{p-D9u1_OEZ.js.map → p-CChcuL04.js.map} +0 -0
  130. /package/dist/components/{p-CsX3ICz7.js.map → p-CCzG5xDD.js.map} +0 -0
  131. /package/dist/components/{p-BFRMwl93.js.map → p-CIiBpjCn.js.map} +0 -0
  132. /package/dist/components/{p-DB9XJm2c.js.map → p-CgEYdMee.js.map} +0 -0
  133. /package/dist/components/{p-Vdufoj7G.js.map → p-Cuih680v.js.map} +0 -0
  134. /package/dist/components/{p-BXaieghV.js.map → p-Cuqxh-Tq.js.map} +0 -0
  135. /package/dist/components/{p-Utr23NC-.js.map → p-DCvA3Dc9.js.map} +0 -0
  136. /package/dist/components/{p-G3qIxpQj.js.map → p-DLZm_-sU.js.map} +0 -0
  137. /package/dist/components/{p-38PfdMkF.js.map → p-DRnIuCiT.js.map} +0 -0
  138. /package/dist/components/{p-DIFUsoM-.js.map → p-DlscFn2m.js.map} +0 -0
  139. /package/dist/components/{p-CNjOyUQu.js.map → p-DxkEC0hR.js.map} +0 -0
  140. /package/dist/components/{p-CU2673IJ.js.map → p-XMZCaXs7.js.map} +0 -0
  141. /package/dist/components/{p-69lHhJiX.js.map → p-YR1XeuZf.js.map} +0 -0
  142. /package/dist/components/{p-DmAHufd5.js.map → p-dyUp3Adm.js.map} +0 -0
  143. /package/dist/components/{p-B6NvVdeO.js.map → p-nkYN0Bub.js.map} +0 -0
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "card-overview",
4
4
  "title": "CardOverview",
5
- "description": "Page-level summary bar pairing one headline score with a reporting period, an application-owned period control, and a responsive grid of comparable measures.",
5
+ "description": "Page-level summary card pairing one headline score with a reporting period, an application-owned period control, and a responsive grid of comparable measures, with an optional compact 48px presentation.",
6
6
  "type": "registry:ui",
7
7
  "meta": {
8
8
  "library": "@ds-mo/ui",
@@ -13,7 +13,7 @@
13
13
  "intent": {
14
14
  "audience": "general",
15
15
  "status": "experimental",
16
- "summary": "Page-level summary bar pairing one headline score with a reporting period, an application-owned period control, and a responsive grid of comparable measures.",
16
+ "summary": "Page-level summary card pairing one headline score with a reporting period, an application-owned period control, and a responsive grid of comparable measures, with an optional compact 48px presentation.",
17
17
  "useWhen": [
18
18
  "A page opens with one headline figure that the surrounding measures explain.",
19
19
  "Every measure is compared against the same period, so one caption describes the comparison for all of them.",
@@ -57,13 +57,20 @@
57
57
  "Omit a trend when there is nothing to report; resolveMetricTrend returns null in that case so no zero change is rendered.",
58
58
  "Omit score to render the bar without a headline figure. Set scoreErrorMessage to report that the figure could not be resolved while the measures remain readable.",
59
59
  "Loading replaces the score and the measures with skeletons and does not alter the slotted period control.",
60
- "The period control is slotted rather than owned, because the choice of stepper, select, or date range varies by product surface."
60
+ "At most six measures are shown. Keep larger collections outside the summary bar rather than turning it into a dense dashboard.",
61
+ "The period control is slotted rather than owned, because the choice of stepper, select, or date range varies by product surface.",
62
+ "compact is exactly 48px high and keeps only the score label, score value, trend, reporting copy, and slotted period control. It omits the score band, measures, and footer."
61
63
  ],
62
64
  "responsiveBehavior": [
63
65
  "The measure grid reflows to fewer columns and finally stacks, driven by the minimum measure width alone. There is no measurement and no horizontal scrolling.",
66
+ "Every occupied grid track has the same width. When a final row is incomplete, its measures keep the same width as the row above and the unused tracks remain empty.",
64
67
  "Set metricMinWidth to change the width a measure may shrink to before the grid drops a column.",
65
68
  "The score block hugs its content and never shrinks, so the headline figure holds its size while the grid absorbs the remaining width.",
66
- "The score moves above the body once the bar can no longer fit both side by side."
69
+ "Once the card reaches its narrow stacked state, one summary block holds the score label, score, trend, score band, reporting period, and the application-owned period control above the single-column measures.",
70
+ "In that stacked summary, the score and trend share one line at the start, while the score band is hidden to preserve space. The period control stays at the upper end, while the current period and comparison sit on separate right-aligned lines beside the score.",
71
+ "Every measure occupies a full-width row in the stacked state.",
72
+ "Set layout to stacked when the owning page shell enters a responsive mode before the card's own container reaches its narrow threshold.",
73
+ "CardOverview does not observe scroll or choose its own presentation. The page owner may use compact as sticky summary chrome on supported breakpoints."
67
74
  ],
68
75
  "frameworkCaveats": {
69
76
  "customElements": [
@@ -99,7 +106,7 @@
99
106
  "default": "''",
100
107
  "required": false,
101
108
  "mutable": false,
102
- "description": "Comparison caption, for example `vs. previous score period`."
109
+ "description": "Comparison caption, for example `vs Previous period`."
103
110
  },
104
111
  "isLoading": {
105
112
  "type": "boolean",
@@ -110,6 +117,15 @@
110
117
  "mutable": false,
111
118
  "description": "Replace the score and metrics with skeletons while data resolves."
112
119
  },
120
+ "layout": {
121
+ "type": "CardOverviewLayout",
122
+ "resolvedType": "\"auto\" | \"stacked\"",
123
+ "attribute": "layout",
124
+ "default": "'auto'",
125
+ "required": false,
126
+ "mutable": false,
127
+ "description": "Intrinsic responsive layout, or a page-owned single-column stack."
128
+ },
113
129
  "metricMinWidth": {
114
130
  "type": "string",
115
131
  "resolvedType": "string",
@@ -125,7 +141,7 @@
125
141
  "default": "[]",
126
142
  "required": false,
127
143
  "mutable": false,
128
- "description": "Measures rendered in the responsive grid."
144
+ "description": "Measures rendered in the responsive grid. Only the first six are shown."
129
145
  },
130
146
  "overviewLabel": {
131
147
  "type": "string",
@@ -159,6 +175,15 @@
159
175
  "required": false,
160
176
  "mutable": false,
161
177
  "description": "Message shown in place of the score when its figure cannot be resolved."
178
+ },
179
+ "variant": {
180
+ "type": "CardOverviewVariant",
181
+ "resolvedType": "\"compact\" | \"default\"",
182
+ "attribute": "variant",
183
+ "default": "'default'",
184
+ "required": false,
185
+ "mutable": false,
186
+ "description": "Full summary card, or the condensed 48px summary bar."
162
187
  }
163
188
  },
164
189
  "events": [
@@ -182,7 +207,7 @@
182
207
  "default": "''",
183
208
  "required": false,
184
209
  "mutable": false,
185
- "description": "Comparison caption, for example `vs. previous score period`."
210
+ "description": "Comparison caption, for example `vs Previous period`."
186
211
  },
187
212
  "isLoading": {
188
213
  "type": "boolean",
@@ -193,6 +218,15 @@
193
218
  "mutable": false,
194
219
  "description": "Replace the score and metrics with skeletons while data resolves."
195
220
  },
221
+ "layout": {
222
+ "type": "CardOverviewLayout",
223
+ "resolvedType": "\"auto\" | \"stacked\"",
224
+ "attribute": "layout",
225
+ "default": "'auto'",
226
+ "required": false,
227
+ "mutable": false,
228
+ "description": "Intrinsic responsive layout, or a page-owned single-column stack."
229
+ },
196
230
  "metricMinWidth": {
197
231
  "type": "string",
198
232
  "resolvedType": "string",
@@ -208,7 +242,7 @@
208
242
  "default": "[]",
209
243
  "required": false,
210
244
  "mutable": false,
211
- "description": "Measures rendered in the responsive grid."
245
+ "description": "Measures rendered in the responsive grid. Only the first six are shown."
212
246
  },
213
247
  "overviewLabel": {
214
248
  "type": "string",
@@ -242,6 +276,15 @@
242
276
  "required": false,
243
277
  "mutable": false,
244
278
  "description": "Message shown in place of the score when its figure cannot be resolved."
279
+ },
280
+ "variant": {
281
+ "type": "CardOverviewVariant",
282
+ "resolvedType": "\"compact\" | \"default\"",
283
+ "attribute": "variant",
284
+ "default": "'default'",
285
+ "required": false,
286
+ "mutable": false,
287
+ "description": "Full summary card, or the condensed 48px summary bar."
245
288
  }
246
289
  },
247
290
  "events": [
@@ -300,17 +343,17 @@
300
343
  "files": [
301
344
  {
302
345
  "path": "src/wc/components/CardOverview/CardOverview.css",
303
- "content": "@import '../../utils/interaction-fill.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/control-density.css';\n@import '../../utils/control-parts.css';\n@import '../../styles/control-elevation.css';\n\n/*\n * A permanently dark surface, independent of the page theme, so the summary bar\n * reads the same on every product surface. Every foreground, border, and\n * interaction token below therefore comes from the always-dark family rather\n * than the theme-reactive one.\n */\n\n:host {\n display: flex;\n flex-wrap: wrap;\n align-items: stretch;\n box-sizing: border-box;\n /* Drives the narrow layout below from the card's own width, not the viewport's. */\n container-type: inline-size;\n /* A summary is read, not copied; stray selection while clicking cells is noise. */\n -webkit-user-select: none;\n user-select: none;\n /* Width a metric may shrink to before the grid drops a column. */\n --ds-card-overview-metric-min: calc(var(--dimension-size-400) * 5);\n --ds-card-overview-divider: var(--dimension-stroke-width-012) solid\n var(--color-always-dark-border-tertiary);\n border-radius: var(--dimension-radius-050);\n background: var(--color-always-dark-background);\n color: var(--color-always-dark-foreground-primary);\n /*\n * Clips the square-cornered metric washes to the card radius. The outer\n * elevation shadow is painted outside the border box and is unaffected, and\n * the inset highlight is meant to sit exactly on this edge.\n */\n overflow: hidden;\n}\n\n/* ── Score ────────────────────────────────────────────────────────────────── */\n\n/*\n * Hugs its content and never shrinks, so the headline figure keeps its size and\n * the metric grid absorbs the remaining width.\n */\n/*\n * Top aligned rather than centred, and padded to match the header, so the score\n * label sits in the same 32px box at the same offset as the period text beside\n * it. Both use the same type variant, so their baselines land together.\n */\n.card-overview__score {\n display: flex;\n flex: 0 0 auto;\n flex-direction: column;\n gap: 0;\n min-width: 0;\n padding: var(--dimension-space-100) var(--dimension-space-200);\n border-inline-end: var(--ds-card-overview-divider);\n}\n\n/* The control-height box that aligns this label with the period text. */\n.card-overview__score-label-row {\n display: flex;\n align-items: center;\n min-height: var(--ds-control-height);\n}\n\n.card-overview__score-label {\n color: var(--color-always-dark-foreground-secondary);\n}\n\n/*\n * The score is its own column, so its rows cannot baseline-align with the measure\n * rows through flex or grid — those only align items that share a line. These two\n * offsets pull the column onto the measure baselines instead.\n *\n * They are compensation for how much lower a display-scale figure sits in its line\n * box than the body text it aligns to, so they are coupled to the type pairing:\n * change the score or measure variants and both need re-measuring.\n */\n.card-overview__score-figure {\n display: flex;\n align-items: baseline;\n gap: var(--dimension-space-050);\n min-width: 0;\n margin-block-start: calc(-1 * (var(--dimension-space-150) + var(--dimension-space-012)));\n}\n\n/* Second half of the baseline compensation described above the score figure. */\n.card-overview__score-band {\n margin-block-start: calc(-1 * var(--dimension-space-025));\n color: var(--color-always-dark-foreground-secondary);\n}\n\n/*\n * A skeleton bar is a solid block, so in a baseline-aligned row it aligns on its\n * bottom edge while the text it stands in for aligns on its baseline. Next to a\n * display-scale figure that difference is the descent under the figure's\n * baseline, which drops the bar below where the resolved trend sits.\n *\n * The offset is applied with relative positioning rather than a margin: a margin\n * is not part of a synthesized baseline, so it grows the figure instead of moving\n * the bar, which then pushes the band below it out of place.\n *\n * Only needed beside the score figure; the measure rows pair body variants whose\n * bottom edge and baseline already agree.\n */\n.card-overview__skeleton-baseline {\n position: relative;\n inset-block-start: calc(-1 * var(--dimension-space-150));\n}\n\n.card-overview__score--error {\n flex-direction: row;\n align-items: center;\n gap: var(--dimension-space-050);\n color: var(--color-always-dark-foreground-negative);\n}\n\n/* ── Body ─────────────────────────────────────────────────────────────────── */\n\n/*\n * Takes the remaining inline space. The minimum is what makes the host wrap: once\n * the score and roughly two measure columns no longer fit side by side, the body\n * drops below the score instead of squeezing it. `min()` keeps that from becoming\n * an overflow floor on very narrow viewports.\n */\n.card-overview__body {\n display: flex;\n flex: 1 1 0;\n flex-direction: column;\n min-width: min(100%, calc(var(--ds-card-overview-metric-min) * 2));\n}\n\n/*\n * One control row: 8px of padding on every side around a 32px md control, so the\n * bar is 8 + 32 + 8. Both ends of the bar are control-shaped boxes — the period\n * text on the left and the slotted control on the right — so the row reads\n * balanced rather than text on one side and a control on the other.\n */\n.card-overview__header {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-100);\n min-width: 0;\n padding: var(--dimension-space-100);\n border-block-end: var(--ds-card-overview-divider);\n box-sizing: border-box;\n}\n\n/*\n * Matches the control's box on the opposite end, including its inline padding, so\n * the text lands at the same optical inset the slotted control does:\n * 8 from the bar + 6 from this box + 2 from the label inset = 16.\n *\n * Nowrap keeps the row at the control height; the period truncates rather than\n * growing the bar to two lines.\n */\n.card-overview__period {\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n gap: var(--dimension-space-050);\n min-height: var(--ds-control-height);\n min-width: 0;\n padding-inline: var(--ds-control-padding-inline);\n overflow: hidden;\n white-space: nowrap;\n}\n\n.card-overview__period-current {\n color: var(--color-always-dark-foreground-primary);\n}\n\n.card-overview__period-comparison {\n color: var(--color-always-dark-foreground-secondary);\n}\n\n/* The period control is slotted, so only reserve space for it. */\n.card-overview__filter {\n display: flex;\n flex: 0 0 auto;\n align-items: center;\n}\n\n/* ── Metrics ──────────────────────────────────────────────────────────────── */\n\n/*\n * Reflows to fewer columns and finally stacks from `auto-fit` alone. No observer\n * and no horizontal scroll: a metric never compresses past its minimum, it moves\n * to the next row instead.\n */\n/*\n * Wrapping flex rather than an auto-fit grid, because a grid gives the final row's\n * items their column width and leaves a gap. Flex lets the last row's items grow\n * to fill, so a measure left on its own spans the full width.\n *\n * Dividers are trailing, not leading: a leading divider on the first cell would\n * land exactly on the score's own trailing border and paint it twice. The two\n * negative margins pull the final column's and final row's dividers past the card\n * edge, where the card's `overflow: hidden` clips them, so separators appear\n * between cells without needing to detect which cell ends a row.\n *\n * `flex: 1 1 auto` on the container makes it absorb whatever height the body has\n * left, so cells reach the card bottom and their hover wash has no dead strip.\n */\n.card-overview__metrics {\n display: flex;\n flex: 1 1 auto;\n flex-wrap: wrap;\n align-content: flex-start;\n min-width: 0;\n margin-inline-end: calc(-1 * var(--dimension-stroke-width-012));\n margin-block-end: calc(-1 * var(--dimension-stroke-width-012));\n}\n\n/*\n * Square by design. Cells tile edge to edge against their dividers, so a radius\n * would break the seam; the card's own radius clips the outer corners instead.\n *\n * Padding is plain rather than control-derived: a cell is a block of content, not\n * a control box that has to optically match something on the far side.\n */\n.card-overview__metric {\n display: flex;\n position: relative;\n flex: 1 1 var(--ds-card-overview-metric-min);\n flex-direction: column;\n gap: var(--dimension-space-050);\n min-width: 0;\n padding: var(--dimension-space-175) var(--dimension-space-200);\n border-radius: 0;\n border-inline-end: var(--ds-card-overview-divider);\n border-block-end: var(--ds-card-overview-divider);\n}\n\n.card-overview__metric[role='button'] {\n cursor: default;\n -webkit-user-select: none;\n user-select: none;\n}\n\n.card-overview__metric-label {\n min-width: 0;\n color: var(--color-always-dark-foreground-secondary);\n}\n\n.card-overview__metric-value {\n color: var(--color-always-dark-foreground-primary);\n}\n\n.card-overview__metric-figure {\n display: flex;\n align-items: baseline;\n gap: var(--dimension-space-100);\n min-width: 0;\n}\n\n/* ── Trend ────────────────────────────────────────────────────────────────── */\n\n/*\n * Tone arrives resolved from the caller: the arrow direction and the sentiment\n * are independent, so a falling metric can read positive or neutral. The arrow is\n * a glyph inside this text node, so it always matches the surrounding weight.\n */\n.card-overview__trend {\n flex: 0 0 auto;\n white-space: nowrap;\n}\n\n.card-overview__trend--positive {\n color: var(--color-always-dark-foreground-positive);\n}\n\n.card-overview__trend--negative {\n color: var(--color-always-dark-foreground-negative);\n}\n\n/*\n * Secondary rather than tertiary: the change value is content, not decoration,\n * and tertiary does not hold a readable contrast at this size.\n */\n.card-overview__trend--neutral {\n color: var(--color-always-dark-foreground-secondary);\n}\n\n/* ── Narrow layout ────────────────────────────────────────────────────────── */\n\n/*\n * Below roughly two measure columns the card stops being a bar with a leading\n * score and becomes a stack. The score turns into its own full-width row reading\n * left to right, so its divider moves from the trailing edge to the underside and\n * the period bar spans the full width beneath it.\n */\n/*\n * Selectors are host-qualified purely for specificity: the compiled sheet places\n * these rules ahead of the base ones, so a plain class here would only ever add\n * properties and never override them.\n */\n@container (max-width: 480px) {\n :host .card-overview__score {\n flex-direction: row;\n align-items: baseline;\n gap: var(--dimension-space-100);\n width: 100%;\n padding-block: var(--dimension-space-150);\n border-inline-end: none;\n border-block-end: var(--ds-card-overview-divider);\n }\n\n /* Pushes the figure and band to the trailing edge, opposite the label. */\n :host .card-overview__score-label-row {\n flex: 1 1 auto;\n min-height: 0;\n }\n\n /* The vertical rhythm that aligned these to the measure rows no longer applies. */\n :host .card-overview__score-band,\n :host .card-overview__score-figure {\n margin-block-start: 0;\n }\n\n /*\n * Here the band joins the baseline-aligned row, so the band skeleton needs the\n * same lift the trend skeleton gets: a solid bar synthesizes its baseline from\n * its bottom edge and would otherwise hang below the figure's baseline. The\n * column layout aligns the band by margin instead, so this is narrow-only.\n */\n :host .card-overview__score-band--loading {\n position: relative;\n inset-block-start: calc(-1 * (var(--dimension-space-150) + var(--dimension-space-012)));\n }\n\n /*\n * The period gets a full-width row of its own here, but not enough of it for\n * both parts on one line. Wrapping keeps the comparison readable; holding the\n * single-line rule instead collides the two runs of text.\n */\n :host .card-overview__header {\n align-items: flex-start;\n }\n\n :host .card-overview__period {\n flex-wrap: wrap;\n min-height: 0;\n overflow: visible;\n white-space: normal;\n }\n}\n",
346
+ "content": "@import '../../utils/interaction-fill.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/control-density.css';\n@import '../../utils/control-parts.css';\n@import '../../styles/control-elevation.css';\n\n/*\n * A permanently dark surface, independent of the page theme, so the summary bar\n * reads the same on every product surface. Every foreground, border, and\n * interaction token below therefore comes from the always-dark family rather\n * than the theme-reactive one.\n */\n\n:host {\n display: block;\n box-sizing: border-box;\n /* Drives the narrow layout below from the card's own width, not the viewport's. */\n container-type: inline-size;\n /* A summary is read, not copied; stray selection while clicking cells is noise. */\n -webkit-user-select: none;\n user-select: none;\n /* Width a metric may shrink to before the grid drops a column. */\n --ds-card-overview-metric-min: calc(var(--dimension-size-400) * 5);\n --ds-card-overview-divider-width: var(--dimension-stroke-width-012);\n --ds-card-overview-divider-color: var(--color-always-dark-border-tertiary);\n --ds-card-overview-divider: var(--ds-card-overview-divider-width) solid\n var(--ds-card-overview-divider-color);\n border-radius: var(--dimension-radius-050);\n background: var(--color-always-dark-background);\n color: var(--color-always-dark-foreground-primary);\n /*\n * Clips the square-cornered metric washes to the card radius. The outer\n * elevation shadow is painted outside the border box and is unaffected, and\n * the inset highlight is meant to sit exactly on this edge.\n */\n overflow: hidden;\n}\n\n.card-overview__layout {\n display: grid;\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n align-items: stretch;\n min-width: 0;\n}\n\n.card-overview__layout--has-score {\n grid-template-columns: auto minmax(0, 1fr);\n}\n\n/*\n * Transparent at regular widths so the score and header participate in the\n * inner layout grid directly. At the narrow breakpoint it becomes the unified\n * summary block above the metrics.\n */\n.card-overview__summary {\n display: contents;\n}\n\n/* ── Score ────────────────────────────────────────────────────────────────── */\n\n/*\n * Hugs its content and never shrinks, so the headline figure keeps its size and\n * the metric grid absorbs the remaining width.\n */\n/*\n * Top aligned rather than centred, and padded to match the header, so the score\n * label sits in the same 32px box at the same offset as the period text beside\n * it. Both use the same type variant, so their baselines land together.\n */\n.card-overview__score {\n display: flex;\n grid-row: 1 / span 2;\n grid-column: 1;\n flex: 0 0 auto;\n flex-direction: column;\n gap: 0;\n min-width: 0;\n padding: var(--dimension-space-100) var(--dimension-space-200);\n border-inline-end: var(--ds-card-overview-divider);\n}\n\n.card-overview__score--resolved {\n min-inline-size: max-content;\n}\n\n/* The control-height box that aligns this label with the period text. */\n.card-overview__score-label-row {\n display: flex;\n align-items: center;\n min-height: var(--ds-control-height);\n}\n\n/*\n * The score is its own column, so its rows cannot baseline-align with the measure\n * rows through flex or grid — those only align items that share a line. These two\n * offsets pull the column onto the measure baselines instead.\n *\n * They are compensation for how much lower a display-scale figure sits in its line\n * box than the body text it aligns to, so they are coupled to the type pairing:\n * change the score or measure variants and both need re-measuring.\n */\n.card-overview__score-figure {\n display: flex;\n align-items: baseline;\n gap: var(--dimension-space-050);\n min-width: 0;\n margin-block-start: calc(-1 * (var(--dimension-space-150) + var(--dimension-space-012)));\n}\n\n.card-overview__score-value {\n flex: 0 0 auto;\n}\n\n/* Second half of the baseline compensation described above the score figure. */\n.card-overview__score-band {\n margin-block-start: calc(-1 * var(--dimension-space-025));\n}\n\n/*\n * A skeleton bar is a solid block, so in a baseline-aligned row it aligns on its\n * bottom edge while the text it stands in for aligns on its baseline. Next to a\n * display-scale figure that difference is the descent under the figure's\n * baseline, which drops the bar below where the resolved trend sits.\n *\n * The offset is applied with relative positioning rather than a margin: a margin\n * is not part of a synthesized baseline, so it grows the figure instead of moving\n * the bar, which then pushes the band below it out of place.\n *\n * Only needed beside the score figure; the measure rows pair body variants whose\n * bottom edge and baseline already agree.\n */\n.card-overview__skeleton-baseline {\n position: relative;\n inset-block-start: calc(-1 * var(--dimension-space-150));\n}\n\n.card-overview__score--error {\n flex-direction: row;\n align-items: center;\n gap: var(--dimension-space-050);\n color: var(--color-always-dark-foreground-negative);\n}\n\n/* ── Body ─────────────────────────────────────────────────────────────────── */\n\n/*\n * Takes the remaining inline space. The minimum is what makes the host wrap: once\n * the score and roughly two measure columns no longer fit side by side, the body\n * drops below the score instead of squeezing it. `min()` keeps that from becoming\n * an overflow floor on very narrow viewports.\n */\n.card-overview__body {\n display: flex;\n grid-row: 2;\n grid-column: 1;\n flex-direction: column;\n min-width: 0;\n}\n\n.card-overview__body--has-score {\n grid-column: 2;\n}\n\n/*\n * One control row: 8px of padding on every side around a 32px md control, so the\n * bar is 8 + 32 + 8. Both ends of the bar are control-shaped boxes — the period\n * text on the left and the slotted control on the right — so the row reads\n * balanced rather than text on one side and a control on the other.\n */\n.card-overview__header {\n display: flex;\n grid-row: 1;\n grid-column: 1;\n align-items: center;\n gap: var(--dimension-space-100);\n min-width: 0;\n padding: var(--dimension-space-100);\n border-block-end: var(--ds-card-overview-divider);\n box-sizing: border-box;\n}\n\n.card-overview__header--has-score {\n grid-column: 2;\n}\n\n/*\n * Matches the control's box on the opposite end, including its inline padding, so\n * the text lands at the same optical inset the slotted control does:\n * 8 from the bar + 6 from this box + 2 from the label inset = 16.\n *\n * Nowrap keeps the row at the control height; the period truncates rather than\n * growing the bar to two lines.\n */\n.card-overview__period {\n display: flex;\n flex: 1 1 auto;\n align-items: center;\n gap: var(--dimension-space-050);\n min-height: var(--ds-control-height);\n min-width: 0;\n padding-inline: var(--ds-control-padding-inline);\n overflow: hidden;\n white-space: nowrap;\n}\n\n/* The period control is slotted, so only reserve space for it. */\n.card-overview__filter {\n display: flex;\n flex: 0 0 auto;\n align-items: center;\n}\n\n/* ── Metrics ──────────────────────────────────────────────────────────────── */\n\n/*\n * `auto-fit` gives every occupied track an equal share while all metrics fit on\n * one row. Once wrapping creates another row, its cells stay on those same\n * tracks, so a partial final row leaves empty space instead of stretching one\n * measure wider than its peers.\n *\n * Dividers are painted outward from each cell and clipped by this grid. That\n * leaves only the lines between cells: anything emitted from the final column or\n * final row lands outside the grid and is clipped without needing to detect the\n * number of tracks.\n *\n * `flex: 1 1 auto` makes the grid absorb whatever height the body has\n * left, so cells reach the card bottom and their hover wash has no dead strip.\n */\n.card-overview__metrics {\n display: grid;\n flex: 1 1 auto;\n grid-template-columns: repeat(\n auto-fit,\n minmax(min(100%, var(--ds-card-overview-metric-min)), 1fr)\n );\n align-content: stretch;\n min-width: 0;\n overflow: clip;\n}\n\n/*\n * Square by design. Cells tile edge to edge against their dividers, so a radius\n * would break the seam; the card's own radius clips the outer corners instead.\n *\n * Padding is plain rather than control-derived: a cell is a block of content, not\n * a control box that has to optically match something on the far side.\n */\n.card-overview__metric {\n display: flex;\n position: relative;\n flex-direction: column;\n gap: var(--dimension-space-050);\n min-width: 0;\n padding: var(--dimension-space-175) var(--dimension-space-200);\n border-radius: 0;\n box-shadow:\n var(--ds-card-overview-divider-width) 0 0 0 var(--ds-card-overview-divider-color),\n 0 var(--ds-card-overview-divider-width) 0 0 var(--ds-card-overview-divider-color);\n}\n\n.card-overview__metric[role='button'] {\n cursor: default;\n -webkit-user-select: none;\n user-select: none;\n}\n\n.card-overview__metric-label {\n min-width: 0;\n}\n\n.card-overview__metric-figure {\n display: flex;\n align-items: baseline;\n gap: var(--dimension-space-100);\n min-width: 0;\n}\n\n/* ── Trend ────────────────────────────────────────────────────────────────── */\n\n/*\n * Tone arrives resolved from the caller: the arrow direction and the sentiment\n * are independent, so a falling metric can read positive or neutral. The arrow is\n * a glyph inside this text node, so it always matches the surrounding weight.\n */\n.card-overview__trend {\n flex: 0 0 auto;\n white-space: nowrap;\n}\n\n/* ── Narrow layout ────────────────────────────────────────────────────────── */\n\n/*\n * Below roughly two measure columns the card becomes a full stack. The summary\n * wrapper owns one block above the metrics: score label, figure, and trend stay\n * together at the start; the period control sits at the upper end; and the period\n * copy follows the score instead of becoming a separate divided row.\n */\n/*\n * Selectors are host-qualified purely for specificity: the compiled sheet places\n * these rules ahead of the base ones, so a plain class here would only ever add\n * properties and never override them.\n */\n@container (max-width: 480px) {\n :host .card-overview__layout {\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n }\n\n :host .card-overview__summary {\n display: grid;\n grid-row: 1;\n grid-column: 1;\n grid-template-columns: minmax(0, 1fr) auto;\n grid-template-rows: auto;\n border-block-end: var(--ds-card-overview-divider);\n }\n\n :host .card-overview__score {\n display: grid;\n grid-row: 1;\n grid-column: 1;\n grid-template-rows: auto auto;\n grid-template-columns: auto auto;\n justify-content: start;\n column-gap: var(--dimension-space-100);\n width: auto;\n padding-block-end: var(--dimension-space-100);\n padding-inline-end: var(--dimension-space-100);\n border-inline-end: none;\n }\n\n :host .card-overview__score-label-row {\n grid-row: 1;\n grid-column: 1 / -1;\n min-height: var(--ds-control-height);\n }\n\n /*\n * The wide score uses optical offsets to meet the neighbouring metric\n * baselines. In the stacked summary those neighbours are gone, so natural\n * document flow keeps the label, figure, band, and period clearly ordered.\n */\n :host .card-overview__score-band,\n :host .card-overview__score-figure {\n grid-row: 2;\n margin-block-start: 0;\n }\n\n :host .card-overview__score-figure {\n grid-column: 1;\n align-self: baseline;\n gap: var(--dimension-space-100);\n }\n\n :host .card-overview__score-band {\n grid-column: 2;\n align-self: baseline;\n display: none;\n }\n\n :host .card-overview__header {\n display: grid;\n grid-row: 1;\n grid-column: 2;\n grid-template-rows: auto auto;\n align-content: start;\n gap: 0;\n padding: 0;\n border-block-end: none;\n }\n\n :host .card-overview__filter {\n grid-row: 1;\n grid-column: 1;\n align-self: start;\n justify-self: end;\n padding: var(--dimension-space-100) var(--dimension-space-100) 0 0;\n }\n\n :host .card-overview__period {\n grid-row: 2;\n grid-column: 1;\n flex-direction: column;\n align-items: flex-end;\n justify-self: stretch;\n gap: 0;\n min-height: 0;\n padding-block: var(--dimension-space-150) var(--dimension-space-100);\n padding-inline: var(--dimension-space-100) var(--dimension-space-200);\n overflow: visible;\n text-align: end;\n white-space: nowrap;\n }\n\n :host .card-overview__body {\n grid-row: 2;\n grid-column: 1;\n }\n}\n\n/*\n * Page shells may enter a responsive mode before this card itself becomes\n * narrow. The explicit stacked layout applies the same composition without\n * coupling CardOverview to a viewport breakpoint.\n */\n:host(.card-overview--stacked) .card-overview__layout {\n grid-template-rows: auto minmax(0, 1fr);\n grid-template-columns: minmax(0, 1fr);\n}\n\n:host(.card-overview--stacked) .card-overview__summary {\n display: grid;\n grid-row: 1;\n grid-column: 1;\n grid-template-columns: minmax(0, 1fr) auto;\n grid-template-rows: auto;\n border-block-end: var(--ds-card-overview-divider);\n}\n\n:host(.card-overview--stacked) .card-overview__score {\n display: grid;\n grid-row: 1;\n grid-column: 1;\n grid-template-rows: auto auto;\n grid-template-columns: auto auto;\n justify-content: start;\n column-gap: var(--dimension-space-100);\n width: auto;\n padding-block-end: var(--dimension-space-100);\n padding-inline-end: var(--dimension-space-100);\n border-inline-end: none;\n}\n\n:host(.card-overview--stacked) .card-overview__score-label-row {\n grid-row: 1;\n grid-column: 1 / -1;\n min-height: var(--ds-control-height);\n}\n\n:host(.card-overview--stacked) .card-overview__score-band,\n:host(.card-overview--stacked) .card-overview__score-figure {\n grid-row: 2;\n margin-block-start: 0;\n}\n\n:host(.card-overview--stacked) .card-overview__score-figure {\n grid-column: 1;\n align-self: baseline;\n gap: var(--dimension-space-100);\n}\n\n:host(.card-overview--stacked) .card-overview__score-band {\n grid-column: 2;\n align-self: baseline;\n display: none;\n}\n\n:host(.card-overview--stacked) .card-overview__header {\n display: grid;\n grid-row: 1;\n grid-column: 2;\n grid-template-rows: auto auto;\n align-content: start;\n gap: 0;\n padding: 0;\n border-block-end: none;\n}\n\n:host(.card-overview--stacked) .card-overview__filter {\n grid-row: 1;\n grid-column: 1;\n align-self: start;\n justify-self: end;\n padding: var(--dimension-space-100) var(--dimension-space-100) 0 0;\n}\n\n:host(.card-overview--stacked) .card-overview__period {\n grid-row: 2;\n grid-column: 1;\n flex-direction: column;\n align-items: flex-end;\n justify-self: stretch;\n gap: 0;\n min-height: 0;\n padding-block: var(--dimension-space-150) var(--dimension-space-100);\n padding-inline: var(--dimension-space-100) var(--dimension-space-200);\n overflow: visible;\n text-align: end;\n white-space: nowrap;\n}\n\n:host(.card-overview--stacked) .card-overview__body {\n grid-row: 2;\n grid-column: 1;\n}\n\n:host(.card-overview--stacked) .card-overview__metrics {\n grid-template-columns: minmax(0, 1fr);\n}\n\n/* ── Compact presentation ───────────────────────────────────────────────── */\n\n/*\n * Matches the shared 48px bar contract: one 32px control row with 8px above\n * and below. The score remains a distinct leading cell, while reporting copy\n * absorbs the available width and the application-owned control never shrinks.\n */\n:host(.card-overview--compact) {\n height: var(--dimension-size-600);\n min-height: var(--dimension-size-600);\n}\n\n:host(.card-overview--compact) .card-overview__layout,\n:host(.card-overview--compact) .card-overview__summary {\n display: flex;\n width: 100%;\n height: 100%;\n min-width: 0;\n}\n\n:host(.card-overview--compact) .card-overview__score {\n display: flex;\n flex: 0 0 auto;\n flex-direction: row;\n align-items: center;\n gap: var(--dimension-space-100);\n width: auto;\n height: 100%;\n min-width: 0;\n padding: var(--dimension-space-100) var(--dimension-space-200);\n border-inline-end: var(--ds-card-overview-divider);\n box-sizing: border-box;\n}\n\n:host(.card-overview--compact) .card-overview__score-label-row {\n display: contents;\n min-height: 0;\n}\n\n:host(.card-overview--compact) .card-overview__score-label,\n:host(.card-overview--compact) .card-overview__score-figure,\n:host(.card-overview--compact) .card-overview__trend {\n flex: 0 0 auto;\n}\n\n:host(.card-overview--compact) .card-overview__score-figure {\n display: flex;\n align-self: center;\n align-items: baseline;\n gap: var(--dimension-space-050);\n margin-block-start: 0;\n}\n\n:host(.card-overview--compact) .card-overview__score-band {\n display: none;\n}\n\n:host(.card-overview--compact) .card-overview__header {\n display: flex;\n flex: 1 1 auto;\n grid-row: auto;\n grid-column: auto;\n align-items: center;\n gap: var(--dimension-space-100);\n height: 100%;\n min-width: 0;\n padding: var(--dimension-space-100);\n border-block-end: none;\n box-sizing: border-box;\n}\n\n:host(.card-overview--compact) .card-overview__period {\n display: flex;\n flex: 1 1 auto;\n flex-direction: row;\n align-items: center;\n gap: var(--dimension-space-050);\n min-width: 0;\n min-height: var(--ds-control-height);\n padding-inline: var(--ds-control-padding-inline);\n overflow: hidden;\n text-align: start;\n white-space: nowrap;\n}\n\n:host(.card-overview--compact) .card-overview__period-current,\n:host(.card-overview--compact) .card-overview__period-comparison {\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n:host(.card-overview--compact) .card-overview__period-current {\n flex: 0 1 auto;\n}\n\n:host(.card-overview--compact) .card-overview__period-comparison {\n flex: 1 1 auto;\n}\n\n:host(.card-overview--compact) .card-overview__filter {\n display: flex;\n flex: 0 0 auto;\n align-items: center;\n padding: 0;\n}\n",
304
347
  "type": "registry:ui"
305
348
  },
306
349
  {
307
350
  "path": "src/wc/components/CardOverview/CardOverview.tsx",
308
- "content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Prop,\n State,\n} from '@stencil/core';\nimport type { TextVariant } from '../Text/text-types';\nimport type { MetricTrend } from '../../utils/metric-change';\nimport type { OverviewMetric, OverviewScore } from './card-overview-types';\n\n/** Metric cells below this width drop a grid column rather than compress further. */\nconst DEFAULT_METRIC_MIN_WIDTH = 'var(--dimension-menu-width-xs)';\n\n/** Skeleton cells rendered while loading with no metrics supplied yet. */\nconst LOADING_PLACEHOLDER_COUNT = 4;\n\n@Component({\n tag: 'ds-card-overview',\n styleUrl: 'CardOverview.css',\n scoped: true,\n})\nexport class CardOverview {\n @Element() el!: HTMLElement;\n\n /** Leading summary block. Omit to render the bar without a headline figure. */\n @Prop() score: OverviewScore | undefined;\n\n /** Current reporting period, for example `Jun 29, 2026 – Jul 26, 2026`. */\n @Prop() periodLabel: string = '';\n\n /** Comparison caption, for example `vs. previous score period`. */\n @Prop() comparisonLabel: string = '';\n\n /** Measures rendered in the responsive grid. */\n @Prop() metrics: OverviewMetric[] = [];\n\n /**\n * Width a metric cell may shrink to before the grid drops a column. The grid\n * reflows and then stacks from this alone, so no measurement is required.\n */\n @Prop() metricMinWidth: string = DEFAULT_METRIC_MIN_WIDTH;\n\n /** Replace the score and metrics with skeletons while data resolves. */\n @Prop() isLoading: boolean = false;\n\n /** Message shown in place of the score when its figure cannot be resolved. */\n @Prop() scoreErrorMessage: string | undefined;\n\n /** Accessible name for the region. */\n @Prop() overviewLabel: string = 'Overview';\n\n /** Emitted when a metric that is not inactive is activated. */\n @Event() dsMetricSelect!: EventEmitter<OverviewMetric>;\n\n /** Roving tab stop across selectable metrics. */\n @State() private focusedMetricIndex: number = 0;\n\n private get hasScore(): boolean {\n return this.score !== undefined || this.scoreErrorMessage !== undefined || this.isLoading;\n }\n\n /** Index of the roving tab stop, skipping inactive metrics. */\n private get rovingIndex(): number {\n const first = this.metrics.findIndex(metric => !metric.isInactive);\n if (first < 0) return -1;\n const current = this.metrics[this.focusedMetricIndex];\n return current && !current.isInactive ? this.focusedMetricIndex : first;\n }\n\n private selectMetric(metric: OverviewMetric, index: number) {\n if (metric.isInactive) return;\n this.focusedMetricIndex = index;\n this.dsMetricSelect.emit(metric);\n }\n\n /** Move the roving tab stop, wrapping and skipping inactive metrics. */\n private moveFocus(from: number, step: number) {\n const count = this.metrics.length;\n if (count === 0) return;\n\n for (let offset = 1; offset <= count; offset += 1) {\n // Positive modulo so a backwards step from the first metric wraps to the last.\n const next = (((from + step * offset) % count) + count) % count;\n if (this.metrics[next]?.isInactive) continue;\n\n this.focusedMetricIndex = next;\n const cells = this.el.querySelectorAll<HTMLElement>('.card-overview__metric');\n cells[next]?.focus();\n return;\n }\n }\n\n private handleMetricKeyDown = (event: KeyboardEvent, index: number) => {\n switch (event.key) {\n case 'ArrowRight':\n case 'ArrowDown':\n event.preventDefault();\n this.moveFocus(index, 1);\n break;\n case 'ArrowLeft':\n case 'ArrowUp':\n event.preventDefault();\n this.moveFocus(index, -1);\n break;\n default:\n break;\n }\n };\n\n /**\n * A skeleton bar matching the type metrics of the text it stands in for, so the\n * loading state holds the same shape and rhythm as the resolved content.\n *\n * `background` is always passed: the default base is the theme's quaternary\n * foreground, which is black and therefore invisible on this permanently dark\n * surface.\n */\n private bar(textVariant: TextVariant, width: string, className?: string) {\n return (\n <ds-skeleton\n class={className}\n variant=\"text\"\n textVariant={textVariant}\n width={width}\n background=\"always-dark\"\n />\n );\n }\n\n /**\n * Arrow and change in one text node.\n *\n * The arrow is a typeface glyph rather than an icon so it inherits the exact\n * size, weight, and baseline of the value beside it; an icon would need its\n * own size scale and would drift whenever the type scale changed.\n *\n * Tone is supplied by the caller and never inferred here.\n */\n private renderTrend(trend: MetricTrend | undefined, variant: TextVariant, emphasis = false) {\n if (!trend) return null;\n return (\n <ds-text\n as=\"span\"\n class={{\n 'card-overview__trend': true,\n [`card-overview__trend--${trend.tone}`]: true,\n }}\n variant={variant}\n emphasis={emphasis}\n color=\"inherit\"\n fontFeature=\"tabular-nums\"\n >\n {trend.direction === 'up' ? '↑' : '↓'} {trend.value}\n </ds-text>\n );\n }\n\n private renderScore() {\n if (this.isLoading) {\n return (\n <div class=\"card-overview__score ds-control--md\" part=\"score\">\n {/*\n * The label row is kept but left empty. It carries the control-height\n * box that aligns the score block with the period control, so dropping\n * it would shift the whole block up; a bar inside it would collide with\n * the figure, which sits under the label by design.\n */}\n <div class=\"card-overview__score-label-row\" />\n <div class=\"card-overview__score-figure\">\n {this.bar('text-display-medium', '56px')}\n {this.bar('text-body-small', '24px', 'card-overview__skeleton-baseline')}\n </div>\n {/*\n * Class goes on the skeleton itself, matching the resolved band. In a\n * wrapper the bar picks up inline leading and drops below the resolved\n * band position.\n */}\n {this.bar('text-caption', '80px', 'card-overview__score-band card-overview__score-band--loading')}\n </div>\n );\n }\n\n if (this.scoreErrorMessage) {\n return (\n <div class=\"card-overview__score card-overview__score--error\" part=\"score\" role=\"alert\">\n <ds-icon name=\"CircleExclamation\" size=\"sm\" color=\"inherit\" aria-hidden=\"true\" />\n <ds-text as=\"span\" variant=\"text-body-medium\" color=\"inherit\">\n {this.scoreErrorMessage}\n </ds-text>\n </div>\n );\n }\n\n const score = this.score;\n if (!score) return null;\n\n return (\n <div class=\"card-overview__score ds-control--md\" part=\"score\">\n <div class=\"card-overview__score-label-row\">\n <ds-text\n as=\"span\"\n class=\"card-overview__score-label\"\n variant=\"text-body-medium\"\n emphasis\n color=\"inherit\"\n >\n {score.label}\n </ds-text>\n </div>\n <div class=\"card-overview__score-figure\">\n <ds-text as=\"span\" variant=\"text-display-medium\" emphasis color=\"inherit\" fontFeature=\"tabular-nums\">\n {score.value}\n </ds-text>\n {this.renderTrend(score.trend, 'text-body-small', true)}\n </div>\n {score.band && (\n <ds-text as=\"span\" class=\"card-overview__score-band\" variant=\"text-caption\" color=\"inherit\">\n {score.band}\n </ds-text>\n )}\n </div>\n );\n }\n\n private renderMetric(metric: OverviewMetric, index: number) {\n const selectable = !metric.isInactive;\n const label = (\n <ds-text as=\"span\" class=\"card-overview__metric-label\" variant=\"text-body-small\" color=\"inherit\">\n {metric.label}\n </ds-text>\n );\n\n return (\n <div\n class={{\n 'card-overview__metric': true,\n 'card-overview__metric--inactive': !selectable,\n // Selection targets get the shared wash; press scaling is not used here\n // so the grid keeps its columns aligned. See docs/control-press-policy.md.\n 'ds-interaction-fill': selectable,\n // The card is permanently dark, so interaction tokens come from that family.\n 'ds-interaction-fill--on-always-dark': selectable,\n 'ds-focus-ring-inset': selectable,\n }}\n part=\"metric\"\n role={selectable ? 'button' : undefined}\n tabIndex={selectable && index === this.rovingIndex ? 0 : -1}\n onClick={() => this.selectMetric(metric, index)}\n onKeyDown={event => this.handleMetricKeyDown(event, index)}\n onFocusin={() => {\n if (selectable) this.focusedMetricIndex = index;\n }}\n >\n {metric.labelTooltip\n ? <ds-tooltip label={metric.labelTooltip} side=\"top\" size=\"sm\">{label}</ds-tooltip>\n : label}\n <div class=\"card-overview__metric-figure\">\n <ds-text as=\"span\" class=\"card-overview__metric-value\" variant=\"text-body-medium\" color=\"inherit\" fontFeature=\"tabular-nums\">\n {metric.value}\n </ds-text>\n {this.renderTrend(metric.trend, 'text-body-medium')}\n </div>\n </div>\n );\n }\n\n private renderMetrics() {\n if (this.isLoading && this.metrics.length === 0) {\n return Array.from({ length: LOADING_PLACEHOLDER_COUNT }, (_, index) => (\n <div class=\"card-overview__metric\" key={`loading-${index}`}>\n {this.bar('text-body-small', '70%', 'card-overview__metric-label')}\n <div class=\"card-overview__metric-figure\">\n {this.bar('text-body-medium', '36px')}\n {/* text-body-medium, matching renderTrend for a metric — a smaller\n * variant here leaves the trend bar short of the value beside it. */}\n {this.bar('text-body-medium', '40px')}\n </div>\n </div>\n ));\n }\n\n return this.metrics.map((metric, index) => this.renderMetric(metric, index));\n }\n\n render() {\n const hasHeader = Boolean(this.periodLabel || this.comparisonLabel);\n\n return (\n <Host\n class={{\n 'card-overview': true,\n 'card-overview--has-score': this.hasScore,\n // Split shadow and inset highlight, so the opaque surface cannot cover\n // the highlight the way a combined elevation shadow would.\n 'ds-control-elevation': true,\n 'ds-control-elevation--sm': true,\n }}\n role=\"region\"\n aria-label={this.overviewLabel}\n aria-busy={this.isLoading ? 'true' : undefined}\n style={{ '--ds-card-overview-metric-min': this.metricMinWidth }}\n >\n {this.hasScore && this.renderScore()}\n\n <div class=\"card-overview__body\">\n {/* `ds-control--md` supplies the 32px height and the label inset below. */}\n <div class=\"card-overview__header ds-control--md\">\n {this.isLoading ? (\n <div class=\"card-overview__period ds-control-label-box\">\n {this.bar('text-body-medium', '184px')}\n {this.bar('text-body-medium', '248px')}\n </div>\n ) : hasHeader && (\n <div class=\"card-overview__period ds-control-label-box\">\n {this.periodLabel && (\n <ds-text as=\"span\" class=\"card-overview__period-current ds-control-label-box\" variant=\"text-body-medium\" emphasis color=\"inherit\">\n {this.periodLabel}\n </ds-text>\n )}\n {this.comparisonLabel && (\n <ds-text as=\"span\" class=\"card-overview__period-comparison ds-control-label-box\" variant=\"text-body-medium\" color=\"inherit\">\n {this.comparisonLabel}\n </ds-text>\n )}\n </div>\n )}\n <div class=\"card-overview__filter\">\n {/*\n Period control is application owned: it varies by product surface.\n While loading it is stood in for by a control-shaped skeleton so the\n header keeps its resolved proportions.\n */}\n {this.isLoading\n ? <ds-skeleton variant=\"control\" controlSize=\"md\" width=\"128px\" background=\"always-dark\" />\n : <slot name=\"filter\" />}\n </div>\n </div>\n\n <div class=\"card-overview__metrics\" part=\"metrics\">\n {this.renderMetrics()}\n </div>\n\n <slot name=\"footer\" />\n </div>\n </Host>\n );\n }\n}\n",
351
+ "content": "import { Component, Element, Event, EventEmitter, h, Host, Prop, State } from '@stencil/core';\nimport type { TextColor, TextVariant } from '../Text/text-types';\nimport type { MetricTrend } from '../../utils/metric-change';\nimport type {\n CardOverviewLayout,\n CardOverviewVariant,\n OverviewMetric,\n OverviewScore,\n} from './card-overview-types';\n\n/** Metric cells below this width drop a grid column rather than compress further. */\nconst DEFAULT_METRIC_MIN_WIDTH = 'var(--dimension-menu-width-xs)';\n\n/** Skeleton cells rendered while loading with no metrics supplied yet. */\nconst LOADING_PLACEHOLDER_COUNT = 4;\n\n/** Maximum number of comparable measures the summary bar presents at once. */\nconst MAX_METRIC_COUNT = 6;\n\nconst TREND_COLORS: Record<MetricTrend['tone'], TextColor> = {\n positive: 'var(--color-always-dark-foreground-positive)',\n negative: 'var(--color-always-dark-foreground-negative)',\n neutral: 'var(--color-always-dark-foreground-secondary)',\n};\n\nconst ALWAYS_DARK_PRIMARY: TextColor = 'var(--color-always-dark-foreground-primary)';\nconst ALWAYS_DARK_SECONDARY: TextColor = 'var(--color-always-dark-foreground-secondary)';\n\n@Component({\n tag: 'ds-card-overview',\n styleUrl: 'CardOverview.css',\n scoped: true,\n})\nexport class CardOverview {\n @Element() el!: HTMLElement;\n\n /** Full summary card, or the condensed 48px summary bar. */\n @Prop() variant: CardOverviewVariant = 'default';\n\n /** Intrinsic responsive layout, or a page-owned single-column stack. */\n @Prop() layout: CardOverviewLayout = 'auto';\n\n /** Leading summary block. Omit to render the bar without a headline figure. */\n @Prop() score: OverviewScore | undefined;\n\n /** Current reporting period, for example `Jun 29, 2026 – Jul 26, 2026`. */\n @Prop() periodLabel: string = '';\n\n /** Comparison caption, for example `vs Previous period`. */\n @Prop() comparisonLabel: string = '';\n\n /** Measures rendered in the responsive grid. Only the first six are shown. */\n @Prop() metrics: OverviewMetric[] = [];\n\n /**\n * Width a metric cell may shrink to before the grid drops a column. The grid\n * reflows and then stacks from this alone, so no measurement is required.\n */\n @Prop() metricMinWidth: string = DEFAULT_METRIC_MIN_WIDTH;\n\n /** Replace the score and metrics with skeletons while data resolves. */\n @Prop() isLoading: boolean = false;\n\n /** Message shown in place of the score when its figure cannot be resolved. */\n @Prop() scoreErrorMessage: string | undefined;\n\n /** Accessible name for the region. */\n @Prop() overviewLabel: string = 'Overview';\n\n /** Emitted when a metric that is not inactive is activated. */\n @Event() dsMetricSelect!: EventEmitter<OverviewMetric>;\n\n /** Roving tab stop across selectable metrics. */\n @State() private focusedMetricIndex: number = 0;\n\n private get hasScore(): boolean {\n return this.score !== undefined || this.scoreErrorMessage !== undefined || this.isLoading;\n }\n\n private get visibleMetrics(): OverviewMetric[] {\n return this.metrics.slice(0, MAX_METRIC_COUNT);\n }\n\n /** Index of the roving tab stop, skipping inactive metrics. */\n private get rovingIndex(): number {\n const first = this.visibleMetrics.findIndex(metric => !metric.isInactive);\n if (first < 0) return -1;\n const current = this.visibleMetrics[this.focusedMetricIndex];\n return current && !current.isInactive ? this.focusedMetricIndex : first;\n }\n\n private selectMetric(metric: OverviewMetric, index: number) {\n if (metric.isInactive) return;\n this.focusedMetricIndex = index;\n this.dsMetricSelect.emit(metric);\n }\n\n /** Move the roving tab stop, wrapping and skipping inactive metrics. */\n private moveFocus(from: number, step: number) {\n const metrics = this.visibleMetrics;\n const count = metrics.length;\n if (count === 0) return;\n\n for (let offset = 1; offset <= count; offset += 1) {\n // Positive modulo so a backwards step from the first metric wraps to the last.\n const next = (((from + step * offset) % count) + count) % count;\n if (metrics[next]?.isInactive) continue;\n\n this.focusedMetricIndex = next;\n const cells = this.el.querySelectorAll<HTMLElement>('.card-overview__metric');\n cells[next]?.focus();\n return;\n }\n }\n\n private handleMetricKeyDown = (event: KeyboardEvent, index: number) => {\n switch (event.key) {\n case 'ArrowRight':\n case 'ArrowDown':\n event.preventDefault();\n this.moveFocus(index, 1);\n break;\n case 'ArrowLeft':\n case 'ArrowUp':\n event.preventDefault();\n this.moveFocus(index, -1);\n break;\n default:\n break;\n }\n };\n\n /**\n * A skeleton bar matching the type metrics of the text it stands in for, so the\n * loading state holds the same shape and rhythm as the resolved content.\n *\n * `background` is always passed: the default base is the theme's quaternary\n * foreground, which is black and therefore invisible on this permanently dark\n * surface.\n */\n private bar(textVariant: TextVariant, width: string, className?: string) {\n return (\n <ds-skeleton\n class={className}\n variant=\"text\"\n textVariant={textVariant}\n width={width}\n background=\"always-dark\"\n />\n );\n }\n\n /**\n * Arrow and change in one text node.\n *\n * The arrow is a typeface glyph rather than an icon so it inherits the exact\n * size, weight, and baseline of the value beside it; an icon would need its\n * own size scale and would drift whenever the type scale changed.\n *\n * Tone is supplied by the caller and never inferred here.\n */\n private renderTrend(trend: MetricTrend | undefined, variant: TextVariant, emphasis = false) {\n if (!trend) return null;\n return (\n <ds-text\n as=\"span\"\n class={{\n 'card-overview__trend': true,\n [`card-overview__trend--${trend.tone}`]: true,\n }}\n variant={variant}\n emphasis={emphasis}\n color={TREND_COLORS[trend.tone]}\n fontFeature=\"tabular-nums\"\n >\n {trend.direction === 'up' ? '↑' : '↓'} {trend.value}\n </ds-text>\n );\n }\n\n private renderScore(compact: boolean) {\n if (this.isLoading) {\n if (compact) {\n return (\n <div class=\"card-overview__score ds-control--md\" part=\"score\">\n {this.bar('text-body-medium', '40px', 'card-overview__score-label')}\n <div class=\"card-overview__score-figure\">\n {this.bar('text-body-medium', '24px')}\n {this.bar('text-body-medium', '32px')}\n </div>\n </div>\n );\n }\n\n return (\n <div class=\"card-overview__score ds-control--md\" part=\"score\">\n {/*\n * The label row is kept but left empty. It carries the control-height\n * box that aligns the score block with the period control, so dropping\n * it would shift the whole block up; a bar inside it would collide with\n * the figure, which sits under the label by design.\n */}\n <div class=\"card-overview__score-label-row\" />\n <div class=\"card-overview__score-figure\">\n {this.bar('text-display-medium', '56px')}\n {this.bar('text-body-small', '24px', 'card-overview__skeleton-baseline')}\n </div>\n {/*\n * Class goes on the skeleton itself, matching the resolved band. In a\n * wrapper the bar picks up inline leading and drops below the resolved\n * band position.\n */}\n {this.bar(\n 'text-caption',\n '80px',\n 'card-overview__score-band card-overview__score-band--loading'\n )}\n </div>\n );\n }\n\n if (this.scoreErrorMessage) {\n return (\n <div class=\"card-overview__score card-overview__score--error\" part=\"score\" role=\"alert\">\n <ds-icon name=\"CircleExclamation\" size=\"sm\" color=\"inherit\" aria-hidden=\"true\" />\n <ds-text as=\"span\" variant=\"text-body-medium\" color=\"inherit\">\n {this.scoreErrorMessage}\n </ds-text>\n </div>\n );\n }\n\n const score = this.score;\n if (!score) return null;\n\n return (\n <div class=\"card-overview__score card-overview__score--resolved ds-control--md\" part=\"score\">\n <div class=\"card-overview__score-label-row\">\n <ds-text\n as=\"span\"\n class=\"card-overview__score-label\"\n variant=\"text-body-medium\"\n emphasis={!compact}\n color={ALWAYS_DARK_SECONDARY}\n >\n {score.label}\n </ds-text>\n </div>\n <div class=\"card-overview__score-figure\">\n <ds-text\n as=\"span\"\n class=\"card-overview__score-value\"\n variant={compact ? 'text-body-medium' : 'text-display-medium'}\n emphasis\n color={ALWAYS_DARK_PRIMARY}\n fontFeature=\"tabular-nums\"\n >\n {score.value}\n </ds-text>\n {this.renderTrend(\n score.trend,\n compact ? 'text-body-medium' : 'text-body-small',\n !compact\n )}\n </div>\n {score.band && !compact && (\n <ds-text\n as=\"span\"\n class=\"card-overview__score-band\"\n variant=\"text-caption\"\n color={ALWAYS_DARK_SECONDARY}\n >\n {score.band}\n </ds-text>\n )}\n </div>\n );\n }\n\n private renderMetric(metric: OverviewMetric, index: number) {\n const selectable = !metric.isInactive;\n const label = (\n <ds-text\n as=\"span\"\n class=\"card-overview__metric-label\"\n variant=\"text-body-small\"\n color={ALWAYS_DARK_SECONDARY}\n >\n {metric.label}\n </ds-text>\n );\n\n return (\n <div\n class={{\n 'card-overview__metric': true,\n 'card-overview__metric--inactive': !selectable,\n // Selection targets get the shared wash; press scaling is not used here\n // so the grid keeps its columns aligned. See docs/control-press-policy.md.\n 'ds-interaction-fill': selectable,\n // The card is permanently dark, so interaction tokens come from that family.\n 'ds-interaction-fill--on-always-dark': selectable,\n 'ds-focus-ring-inset': selectable,\n }}\n part=\"metric\"\n role={selectable ? 'button' : undefined}\n tabIndex={selectable && index === this.rovingIndex ? 0 : -1}\n onClick={() => this.selectMetric(metric, index)}\n onKeyDown={event => this.handleMetricKeyDown(event, index)}\n onFocusin={() => {\n if (selectable) this.focusedMetricIndex = index;\n }}\n >\n {metric.labelTooltip ? (\n <ds-tooltip label={metric.labelTooltip} side=\"top\" size=\"sm\">\n {label}\n </ds-tooltip>\n ) : (\n label\n )}\n <div class=\"card-overview__metric-figure\">\n <ds-text\n as=\"span\"\n class=\"card-overview__metric-value\"\n variant=\"text-body-medium\"\n color={ALWAYS_DARK_PRIMARY}\n fontFeature=\"tabular-nums\"\n >\n {metric.value}\n </ds-text>\n {this.renderTrend(metric.trend, 'text-body-medium')}\n </div>\n </div>\n );\n }\n\n private renderMetrics() {\n if (this.isLoading && this.metrics.length === 0) {\n return Array.from({ length: LOADING_PLACEHOLDER_COUNT }, (_, index) => (\n <div class=\"card-overview__metric\" key={`loading-${index}`}>\n {this.bar('text-body-small', '70%', 'card-overview__metric-label')}\n <div class=\"card-overview__metric-figure\">\n {this.bar('text-body-medium', '36px')}\n {/* text-body-medium, matching renderTrend for a metric — a smaller\n * variant here leaves the trend bar short of the value beside it. */}\n {this.bar('text-body-medium', '40px')}\n </div>\n </div>\n ));\n }\n\n return this.visibleMetrics.map((metric, index) => this.renderMetric(metric, index));\n }\n\n render() {\n const hasHeader = Boolean(this.periodLabel || this.comparisonLabel);\n const compact = this.variant === 'compact';\n\n return (\n <Host\n class={{\n 'card-overview': true,\n 'card-overview--has-score': this.hasScore,\n 'card-overview--compact': compact,\n 'card-overview--stacked': !compact && this.layout === 'stacked',\n // Split shadow and inset highlight, so the opaque surface cannot cover\n // the highlight the way a combined elevation shadow would.\n 'ds-control-elevation': true,\n 'ds-control-elevation--sm': !compact,\n 'ds-control-elevation--floating': compact,\n }}\n role=\"region\"\n aria-label={this.overviewLabel}\n aria-busy={this.isLoading ? 'true' : undefined}\n style={{ '--ds-card-overview-metric-min': this.metricMinWidth }}\n >\n <div\n class={{\n 'card-overview__layout': true,\n 'card-overview__layout--has-score': this.hasScore,\n }}\n >\n <div class=\"card-overview__summary\">\n {this.hasScore && this.renderScore(compact)}\n\n {/* `ds-control--md` supplies the 32px height and the label inset below. */}\n <div\n class={{\n 'card-overview__header': true,\n 'card-overview__header--has-score': this.hasScore,\n 'ds-control--md': true,\n }}\n >\n {this.isLoading ? (\n <div class=\"card-overview__period ds-control-label-box\">\n {this.bar('text-body-medium', '184px')}\n {this.bar('text-body-medium', '248px')}\n </div>\n ) : (\n hasHeader && (\n <div class=\"card-overview__period ds-control-label-box\">\n {this.periodLabel && (\n <ds-text\n as=\"span\"\n class=\"card-overview__period-current ds-control-label-box\"\n variant=\"text-body-medium\"\n emphasis\n color={ALWAYS_DARK_PRIMARY}\n >\n {this.periodLabel}\n </ds-text>\n )}\n {this.comparisonLabel && (\n <ds-text\n as=\"span\"\n class=\"card-overview__period-comparison ds-control-label-box\"\n variant=\"text-body-medium\"\n color={ALWAYS_DARK_SECONDARY}\n >\n {this.comparisonLabel}\n </ds-text>\n )}\n </div>\n )\n )}\n <div class=\"card-overview__filter\">\n {/*\n Period control is application owned: it varies by product surface.\n While loading it is stood in for by a control-shaped skeleton so the\n header keeps its resolved proportions.\n */}\n {this.isLoading ? (\n <ds-skeleton\n variant=\"control\"\n controlSize=\"md\"\n width=\"128px\"\n background=\"always-dark\"\n />\n ) : (\n <slot name=\"filter\" />\n )}\n </div>\n </div>\n </div>\n\n {!compact && (\n <div\n class={{\n 'card-overview__body': true,\n 'card-overview__body--has-score': this.hasScore,\n }}\n >\n <div class=\"card-overview__metrics\" part=\"metrics\">\n {this.renderMetrics()}\n </div>\n\n <slot name=\"footer\" />\n </div>\n )}\n </div>\n </Host>\n );\n }\n}\n",
309
352
  "type": "registry:ui"
310
353
  },
311
354
  {
312
355
  "path": "src/wc/components/CardOverview/card-overview-types.ts",
313
- "content": "import type { MetricTrend } from '../../utils/metric-change';\n\n/**\n * Leading summary block — the headline figure the rest of the bar contextualises.\n *\n * Derive `trend` with `resolveMetricTrend` so whether a rise reads well stays a\n * product decision rather than a design-system one.\n */\nexport interface OverviewScore {\n /** Category above the figure, for example `Safety score`. */\n label: string;\n /** Display-ready headline figure. */\n value: string | number;\n /** Change against the comparison period. Omit when there is nothing to report. */\n trend?: MetricTrend;\n /** Qualitative band beneath the figure, for example `Good (67–83)`. */\n band?: string;\n}\n\n/** One measure in the overview grid. */\nexport interface OverviewMetric {\n /** Stable identity for keyed rendering and selection events. */\n id: string;\n label: string;\n /** Display-ready value; format compactly before passing it in. */\n value: string | number;\n /** Change against the comparison period. Omit when there is nothing to report. */\n trend?: MetricTrend;\n /** Present the measure without making it selectable. */\n isInactive?: boolean;\n /** Supplementary explanation for the label. */\n labelTooltip?: string;\n}\n",
356
+ "content": "import type { MetricTrend } from '../../utils/metric-change';\n\nexport type CardOverviewVariant = 'default' | 'compact';\nexport type CardOverviewLayout = 'auto' | 'stacked';\n\n/**\n * Leading summary block — the headline figure the rest of the bar contextualises.\n *\n * Derive `trend` with `resolveMetricTrend` so whether a rise reads well stays a\n * product decision rather than a design-system one.\n */\nexport interface OverviewScore {\n /** Category above the figure, for example `Safety score`. */\n label: string;\n /** Display-ready headline figure. */\n value: string | number;\n /** Change against the comparison period. Omit when there is nothing to report. */\n trend?: MetricTrend;\n /** Qualitative band beneath the figure, for example `Good (67–83)`. */\n band?: string;\n}\n\n/** One measure in the overview grid. */\nexport interface OverviewMetric {\n /** Stable identity for keyed rendering and selection events. */\n id: string;\n label: string;\n /** Display-ready value; format compactly before passing it in. */\n value: string | number;\n /** Change against the comparison period. Omit when there is nothing to report. */\n trend?: MetricTrend;\n /** Present the measure without making it selectable. */\n isInactive?: boolean;\n /** Supplementary explanation for the label. */\n labelTooltip?: string;\n}\n",
314
357
  "type": "registry:ui"
315
358
  }
316
359
  ]
@@ -494,7 +494,7 @@
494
494
  },
495
495
  {
496
496
  "path": "src/wc/components/Menu/Menu.tsx",
497
- "content": "import {\n Component,\n Prop,\n State,\n Event,\n EventEmitter,\n Element,\n Watch,\n Listen,\n h,\n Host,\n} from '@stencil/core';\nimport {\n choicePopupMinWidth,\n CONTROL_SUPPORTING_TEXT_VARIANT,\n CONTROL_TEXT_VARIANT,\n resolveChoicePopupAlignOffset,\n resolveCssLengthPx,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n type ChoicePopupAnchorAlignment,\n type ControlSize,\n} from '../../utils';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\nimport type { MenuAlign, MenuSide } from './menu-position';\nimport type { MenuItemData, MenuSection } from './menu-types';\n\nexport type MenuSelectionMode = 'none' | 'single';\nexport type MenuSize = ControlSize;\nimport {\n isMenuGradientPickerSection,\n isMenuPickerSection,\n isMenuSwatchPickerSection,\n} from './menu-types';\nimport {\n shellGradientPickerSections,\n type ShellGradientPreset,\n} from '../../shell/shell-gradient-presets';\n\nconst MENU_FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',');\n\nfunction snapshotMenuSections(sections: MenuSection[]): MenuSection[] {\n return sections.map(section => {\n if (isMenuGradientPickerSection(section)) return { ...section };\n if (isMenuSwatchPickerSection(section)) {\n return {\n ...section,\n options: section.options?.map(option => ({ ...option })),\n sections: section.sections?.map(swatchSection => ({\n ...swatchSection,\n options: swatchSection.options.map(option => ({ ...option })),\n })),\n };\n }\n return { ...section, items: section.items.map(item => ({ ...item })) };\n });\n}\n\n@Component({\n tag: 'ds-menu',\n styleUrl: 'Menu.css',\n scoped: true,\n})\nexport class Menu {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) open: boolean = false;\n @Prop() items: MenuItemData[] = [];\n /** Choice-row density. */\n @Prop() size: MenuSize = 'md';\n /** Give ordinary rows mutually-exclusive menu semantics using isSelected. */\n @Prop() selectionMode: MenuSelectionMode = 'none';\n @Prop() sections: MenuSection[] = [];\n /** Preferred side; placement flips to the opposite side when that offers a better fit. */\n @Prop() side: MenuSide = 'bottom';\n @Prop() align: MenuAlign = 'start';\n /** Align choice-row edges to the anchor by default; use `popup-frame` only for custom frame geometry. */\n @Prop() anchorAlignment: ChoicePopupAnchorAlignment = 'choice-cell';\n /** Gap between anchor and menu — number (px) or TokoMo length (`var(--dimension-space-050)`, etc.). */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number (px) or TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n @Prop() menuWidth: string | undefined;\n @Prop() minWidth: string | undefined;\n /** External trigger element to position against. Set via JS: menuEl.anchor = buttonEl */\n @Prop() anchor: HTMLElement | undefined;\n /** ID of the external trigger element for positioning */\n @Prop() anchorId: string | undefined;\n /** Show a visible ring on the initially focused menu item. Use only when the opener was keyboard-driven. */\n @Prop() initialFocusVisible: boolean = false;\n /** Accessible name for the popup menu. */\n @Prop() menuLabel: string = 'Menu';\n\n @State() private shouldRender: boolean = false;\n @State() private closing: boolean = false;\n @State() private pos: { x: number; y: number } = { x: 0, y: 0 };\n @State() private focusedIndex: number = 0;\n @State() private positionReady: boolean = false;\n @State() private focusRingVisible: boolean = false;\n\n @Event() dsClose!: EventEmitter<void>;\n /** Emitted after the popup's exit motion is complete and its rendered content is removed. */\n @Event() dsAfterClose!: EventEmitter<void>;\n @Event() dsSelect!: EventEmitter<MenuItemData>;\n /** Emitted when a `gradient-picker` section swatch is chosen. */\n @Event() dsGradientSelect!: EventEmitter<ShellGradientPreset>;\n /** Emitted when a generic `swatch-picker` section option is chosen. */\n @Event() dsSwatchSelect!: EventEmitter<string>;\n\n private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n /**\n * Anchored placement. The choice-cell measurement stays here on purpose: the\n * inner-cell align offset and the `--dimension-menu-width-xs` floor are Menu\n * semantics, not shared geometry.\n */\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.resolvedAnchor ?? null,\n getPopup: () => this.el.querySelector<HTMLElement>('.menu-popup'),\n measure: (anchorEl, popup) => {\n if (!this.open) return null;\n\n const anchorRect = anchorEl.getBoundingClientRect();\n const sectionInsetPx = this.viewportPadPx;\n if (!this.minWidth) {\n if (\n this.anchorAlignment === 'choice-cell' &&\n (this.side === 'top' || this.side === 'bottom')\n ) {\n popup.style.minWidth = `max(var(--dimension-menu-width-xs), ${choicePopupMinWidth(\n anchorRect.width,\n sectionInsetPx\n )}px)`;\n } else {\n popup.style.removeProperty('min-width');\n }\n }\n\n return {\n anchorRect,\n popupWidth: popup.offsetWidth || this.popupFallbackWidthPx,\n popupHeight: popup.offsetHeight || this.popupFallbackHeightPx,\n side: this.side,\n align: this.align,\n sideOffsetPx: this.sideOffsetPx,\n alignOffsetPx: resolveChoicePopupAlignOffset({\n align: this.align,\n alignOffsetPx: this.alignOffsetPx,\n sectionInsetPx,\n anchorAlignment: this.anchorAlignment,\n }),\n viewportPadPx: this.viewportPadPx,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n };\n },\n apply: ({ x, y }) => {\n this.pos = { x, y };\n },\n onReady: () => {\n this.positionReady = true;\n },\n liveUpdate: 'double-frame',\n });\n /** Last content actually painted while open; retained unchanged through exit motion. */\n private lastRenderedSections: MenuSection[] = [];\n private closingSections: MenuSection[] | null = null;\n\n componentDidLoad() {\n if (this.open) this.onOpenChange(true);\n }\n\n componentDidRender() {\n if (!this.shouldRender) return;\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (!popup || typeof popup.showPopover !== 'function') return;\n if (!popup.matches(':popover-open')) popup.showPopover();\n }\n\n disconnectedCallback() {\n this.cancelPositionRetry();\n this.cancelLivePositionUpdate();\n this.teardownListeners();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.teardownListeners();\n this.closingSections = null;\n this.shouldRender = true;\n this.closing = false;\n this.positionReady = false;\n this.focusRingVisible = this.initialFocusVisible;\n this.setupListeners();\n this.schedulePositionUpdate(() => {\n this.focusInitialItem();\n });\n } else if (this.shouldRender) {\n this.cancelPositionRetry();\n this.captureClosingSections();\n this.closing = true;\n this.teardownListeners();\n const closeAnimationMs = this.closeAnimationMs;\n if (closeAnimationMs <= 0) {\n this.finishClose();\n return;\n }\n this.closeTimer = setTimeout(() => {\n this.closeTimer = null;\n this.finishClose();\n }, closeAnimationMs);\n }\n }\n\n @Watch('anchor')\n @Watch('anchorId')\n onAnchorChange() {\n if (this.open) this.schedulePositionUpdate();\n }\n\n @Watch('side')\n @Watch('align')\n @Watch('anchorAlignment')\n @Watch('sideOffset')\n @Watch('alignOffset')\n onPositionPropsChange() {\n if (this.open) this.schedulePositionUpdate();\n }\n\n private get viewportPadPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get sideOffsetPx(): number {\n return resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050);\n }\n\n private get alignOffsetPx(): number {\n return resolveCssLengthPx(this.alignOffset, 0);\n }\n\n private get popupFallbackWidthPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuWidthXs, TOKEN_DEFAULTS.menuWidthXs);\n }\n\n private get popupFallbackHeightPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuFallbackHeight, TOKEN_DEFAULTS.menuFallbackHeight);\n }\n\n private get closeAnimationMs(): number {\n return resolveMotionTimeMs(TOKEN_DEFAULTS.motionShort2, TOKEN_DEFAULTS.animationDurationShort3);\n }\n\n private finishClose() {\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (popup?.matches(':popover-open')) popup.hidePopover();\n this.shouldRender = false;\n this.closing = false;\n this.lastRenderedSections = [];\n this.closingSections = null;\n this.dsAfterClose.emit();\n }\n\n private captureClosingSections() {\n if (this.closingSections) return;\n const visibleSections = this.lastRenderedSections.length\n ? this.lastRenderedSections\n : this.activeSections;\n this.closingSections = snapshotMenuSections(visibleSections);\n }\n\n private get resolvedAnchor(): HTMLElement | null {\n if (this.anchor) return this.anchor;\n if (this.anchorId) return document.getElementById(this.anchorId);\n return null;\n }\n\n private get anchorFocusTarget(): HTMLElement | null {\n const anchor = this.resolvedAnchor;\n if (!anchor) return null;\n if (anchor.matches(MENU_FOCUSABLE_SELECTOR)) return anchor;\n return anchor.querySelector<HTMLElement>(MENU_FOCUSABLE_SELECTOR) ?? anchor;\n }\n\n private focusAnchor() {\n const anchor = this.resolvedAnchor as\n | (HTMLElement & { setFocus?: () => Promise<void> | void })\n | null;\n if (anchor?.setFocus) {\n anchor.setFocus();\n return;\n }\n this.anchorFocusTarget?.focus();\n }\n\n private moveFocusAfterTab(backwards: boolean) {\n const anchor = this.anchorFocusTarget;\n if (!anchor) return;\n if (backwards) {\n anchor.focus();\n return;\n }\n\n const candidates = Array.from(\n document.querySelectorAll<HTMLElement>(MENU_FOCUSABLE_SELECTOR)\n ).filter(\n element =>\n !element.closest('.menu-popup') &&\n !element.closest('[inert]') &&\n element.getClientRects().length > 0\n );\n const index = candidates.indexOf(anchor);\n (candidates[index + 1] ?? anchor).focus();\n }\n\n private compositeTabLeavesPopup(event: KeyboardEvent): boolean {\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (!popup) return true;\n const focusables = Array.from(\n popup.querySelectorAll<HTMLElement>(MENU_FOCUSABLE_SELECTOR)\n ).filter(element => element.getClientRects().length > 0);\n const currentIndex = focusables.indexOf(event.target as HTMLElement);\n if (currentIndex < 0) return true;\n return event.shiftKey ? currentIndex === 0 : currentIndex === focusables.length - 1;\n }\n\n private get activeSections(): MenuSection[] {\n if (this.sections.length > 0) return this.sections;\n if (this.items.length > 0) return [{ items: this.items }];\n return [];\n }\n\n private get flatItems(): MenuItemData[] {\n return this.activeSections.flatMap(section =>\n isMenuPickerSection(section) ? [] : section.items\n );\n }\n\n /** Rich preference content is a non-modal dialog, not an ARIA action menu. */\n private get hasCompositeSections(): boolean {\n return this.activeSections.some(isMenuPickerSection);\n }\n\n private cancelPositionRetry() {\n this.position.cancel();\n }\n\n private cancelLivePositionUpdate() {\n this.position.cancel();\n }\n\n /** Reposition after the current scroll/layout frame without hiding the open popup. */\n private scheduleLivePositionUpdate() {\n this.position.scheduleLiveUpdate();\n }\n\n /** Retry until anchor + popup exist — do not reveal at 0,0 on a failed first pass. */\n private schedulePositionUpdate(onReady?: () => void) {\n if (!this.open) return;\n this.positionReady = false;\n this.position.schedule(onReady);\n }\n\n /** @returns `true` when anchor and popup were found and `pos` was updated. */\n private calculatePosition(): boolean {\n return this.position.update();\n }\n\n /** Focus the selected item when present, otherwise the first enabled item. */\n private focusInitialItem() {\n if (this.hasCompositeSections) {\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>('.menu-popup [tabindex=\"0\"]')?.focus();\n });\n return;\n }\n\n const flat = this.flatItems;\n const selectedIdx = flat.findIndex(it => it.isSelected && !it.isInactive);\n const firstEnabledIdx = flat.findIndex(it => !it.isInactive);\n this.focusedIndex = selectedIdx >= 0 ? selectedIdx : firstEnabledIdx >= 0 ? firstEnabledIdx : 0;\n\n requestAnimationFrame(() => {\n const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');\n btns[this.focusedIndex]?.focus();\n });\n }\n\n private setupListeners() {\n this.clickOutsideHandler = (e: MouseEvent) => {\n const t = e.target as Node;\n const popup = this.el.querySelector('.menu-popup');\n const anchorEl = this.resolvedAnchor;\n if (popup?.contains(t) || anchorEl?.contains(t)) return;\n this.close();\n };\n\n document.addEventListener('mousedown', this.clickOutsideHandler, true);\n this.position.observe();\n }\n\n private teardownListeners() {\n this.position.unobserve();\n if (this.clickOutsideHandler) {\n document.removeEventListener('mousedown', this.clickOutsideHandler, true);\n this.clickOutsideHandler = null;\n }\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private close(restoreFocus = true) {\n this.captureClosingSections();\n if (restoreFocus) this.focusAnchor();\n this.dsClose.emit();\n this.open = false;\n this.onOpenChange(false);\n }\n\n @Listen('keydown')\n handleKeyDown(e: KeyboardEvent) {\n if (!this.shouldRender || this.closing) return;\n\n if (e.key === 'Escape') {\n e.preventDefault();\n this.close();\n return;\n }\n\n if (e.key === 'Tab') {\n if (this.hasCompositeSections && !this.compositeTabLeavesPopup(e)) return;\n e.preventDefault();\n this.close(false);\n this.moveFocusAfterTab(e.shiftKey);\n return;\n }\n\n if (this.hasCompositeSections) return;\n\n const flat = this.flatItems;\n const enabled = flat\n .map((it, i) => ({ it, i }))\n .filter(({ it }) => !it.isInactive)\n .map(({ i }) => i);\n if (!enabled.length) return;\n\n const cur = enabled.indexOf(this.focusedIndex);\n const safe = cur < 0 ? 0 : cur;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[(safe + 1) % enabled.length];\n this.focusItem(this.focusedIndex);\n break;\n case 'ArrowUp':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[(safe - 1 + enabled.length) % enabled.length];\n this.focusItem(this.focusedIndex);\n break;\n case 'Home':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[0];\n this.focusItem(this.focusedIndex);\n break;\n case 'End':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[enabled.length - 1];\n this.focusItem(this.focusedIndex);\n break;\n }\n }\n\n private focusItem(idx: number) {\n const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');\n btns[idx]?.focus();\n }\n\n private handleItemClick(item: MenuItemData) {\n if (item.isInactive) return;\n // Consumers commonly change the item source while handling selection.\n // Capture first so the exit animation never paints the next menu context.\n this.captureClosingSections();\n this.dsSelect.emit(item);\n }\n\n private handleGradientSelect(preset: ShellGradientPreset) {\n this.dsGradientSelect.emit(preset);\n }\n\n private handleSwatchSelect(value: string) {\n this.dsSwatchSelect.emit(value);\n }\n\n render() {\n if (!this.shouldRender) return <Host style={{ display: 'contents' }} />;\n\n const sections = this.closing\n ? (this.closingSections ?? this.lastRenderedSections)\n : this.activeSections;\n const hasCompositeSections = sections.some(isMenuPickerSection);\n if (!this.closing) {\n this.lastRenderedSections = snapshotMenuSections(sections);\n this.closingSections = null;\n }\n let flatIdx = 0;\n\n const popupStyle: Record<string, string> = {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(this.pos.x)}px, ${Math.round(this.pos.y)}px)`,\n zIndex: '9998',\n visibility: this.positionReady ? 'visible' : 'hidden',\n };\n\n if (this.menuWidth) popupStyle['width'] = this.menuWidth;\n if (this.minWidth) popupStyle['min-width'] = this.minWidth;\n\n return (\n <Host style={{ display: 'contents' }}>\n <div\n popover=\"manual\"\n class={{\n 'menu-popup': true,\n 'menu-popup--closing': this.closing,\n 'ds-choice-popup': true,\n 'ds-choice-popup--closing': this.closing,\n }}\n style={popupStyle}\n role={hasCompositeSections ? 'dialog' : 'menu'}\n aria-label={this.menuLabel}\n aria-orientation={hasCompositeSections ? undefined : 'vertical'}\n >\n <div class=\"ds-choice-list\">\n {sections.map((section, si) => (\n <div\n key={si}\n class={{\n 'menu-section': true,\n 'menu-section--divided': si < sections.length - 1,\n 'menu-section--gradient-picker': isMenuPickerSection(section),\n 'ds-choice-section': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--sm': true,\n 'ds-choice-section--divided': si < sections.length - 1,\n }}\n role={section.header ? 'group' : undefined}\n aria-label={section.header}\n >\n {section.header && (\n <ds-text\n class={`section-header ds-choice-section__header ds-control--${this.size}`}\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n emphasis\n color=\"primary\"\n aria-hidden=\"true\"\n >\n {section.header}\n </ds-text>\n )}\n {isMenuGradientPickerSection(section) ? (\n <ds-swatch-picker\n value={section.value}\n groupLabel={section.header ?? 'Shell gradient theme'}\n sections={shellGradientPickerSections()}\n onDsChange={(e: CustomEvent<string>) => {\n e.stopPropagation();\n this.handleGradientSelect(e.detail as ShellGradientPreset);\n }}\n />\n ) : isMenuSwatchPickerSection(section) ? (\n <ds-swatch-picker\n value={section.value}\n groupLabel={section.groupLabel ?? section.header ?? 'Swatch options'}\n options={section.options ?? []}\n sections={section.sections ?? []}\n onDsChange={(e: CustomEvent<string>) => {\n e.stopPropagation();\n this.handleSwatchSelect(e.detail);\n }}\n />\n ) : (\n section.items.map(item => {\n const idx = flatIdx++;\n const isFocused = this.focusedIndex === idx;\n const isSingleSelectionItem =\n !hasCompositeSections &&\n this.selectionMode === 'single' &&\n !item.showSwitch;\n return (\n <button\n key={idx}\n type=\"button\"\n class={{\n 'menu-item': true,\n 'ds-choice-item': true,\n 'ds-control-frame': true,\n [`ds-control--${this.size}`]: true,\n 'ds-focus-ring-inset': true,\n 'ds-focus-ring--visible': isFocused && this.focusRingVisible,\n 'ds-interaction-fill': !item.isInactive,\n 'ds-interaction-fill--selected': !!item.isSelected && !item.isInactive,\n 'menu-item--selected': !!item.isSelected,\n 'ds-control-inactive': !!item.isInactive,\n 'menu-item--destructive': !!item.isDestructive,\n 'menu-item--focused': isFocused,\n }}\n role={\n hasCompositeSections\n ? undefined\n : item.showSwitch\n ? 'menuitemcheckbox'\n : isSingleSelectionItem\n ? 'menuitemradio'\n : 'menuitem'\n }\n aria-checked={\n !hasCompositeSections && item.showSwitch\n ? String(!!item.switchValue)\n : isSingleSelectionItem\n ? String(!!item.isSelected)\n : undefined\n }\n aria-pressed={\n hasCompositeSections\n ? String(item.showSwitch ? !!item.switchValue : !!item.isSelected)\n : undefined\n }\n aria-current={\n !hasCompositeSections &&\n !item.showSwitch &&\n !isSingleSelectionItem &&\n item.isSelected\n ? 'true'\n : undefined\n }\n disabled={item.isInactive}\n tabIndex={hasCompositeSections ? 0 : isFocused ? 0 : -1}\n onMouseDown={() => {\n this.focusRingVisible = false;\n }}\n onClick={() => this.handleItemClick(item)}\n onFocus={() => {\n this.focusedIndex = idx;\n }}\n >\n <div class=\"menu-item__content ds-choice-item__content ds-interaction-fill__content\">\n <ds-text\n class=\"menu-item__label ds-choice-item__label ds-control-label-box\"\n as=\"span\"\n variant={CONTROL_TEXT_VARIANT[this.size]}\n color={item.isSelected ? 'primary' : 'secondary'}\n >\n {item.label}\n </ds-text>\n {item.subtext && (\n <ds-text\n class=\"menu-item__subtext ds-choice-item__subtext ds-control-label-box\"\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n color=\"secondary\"\n >\n {item.subtext}\n </ds-text>\n )}\n </div>\n {item.dot && (\n <span\n class=\"menu-item__dot-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-badge\n class=\"menu-item__dot\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n />\n </span>\n )}\n {item.showSwitch && (\n <ds-switch\n class=\"menu-item__switch ds-interaction-fill__content\"\n size={this.size}\n checked={!!item.switchValue}\n presentation\n />\n )}\n </button>\n );\n })\n )}\n </div>\n ))}\n </div>\n </div>\n </Host>\n );\n }\n}\n",
497
+ "content": "import {\n Component,\n Prop,\n State,\n Event,\n EventEmitter,\n Element,\n Watch,\n Listen,\n h,\n Host,\n} from '@stencil/core';\nimport {\n choicePopupMinWidth,\n CONTROL_SUPPORTING_TEXT_VARIANT,\n CONTROL_TEXT_VARIANT,\n resolveChoicePopupAlignOffset,\n resolveCssLengthPx,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n type ChoicePopupAnchorAlignment,\n type ControlSize,\n} from '../../utils';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\nimport type { MenuAlign, MenuSide } from './menu-position';\nimport type { MenuItemData, MenuSection } from './menu-types';\n\nexport type MenuSelectionMode = 'none' | 'single';\nexport type MenuSize = ControlSize;\nimport {\n isMenuGradientPickerSection,\n isMenuPickerSection,\n isMenuSwatchPickerSection,\n} from './menu-types';\nimport {\n shellGradientPickerSections,\n type ShellGradientPreset,\n} from '../../shell/shell-gradient-presets';\n\nconst MENU_FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',');\n\nfunction snapshotMenuSections(sections: MenuSection[]): MenuSection[] {\n return sections.map(section => {\n if (isMenuGradientPickerSection(section)) return { ...section };\n if (isMenuSwatchPickerSection(section)) {\n return {\n ...section,\n options: section.options?.map(option => ({ ...option })),\n sections: section.sections?.map(swatchSection => ({\n ...swatchSection,\n options: swatchSection.options.map(option => ({ ...option })),\n })),\n };\n }\n return { ...section, items: section.items.map(item => ({ ...item })) };\n });\n}\n\n@Component({\n tag: 'ds-menu',\n styleUrl: 'Menu.css',\n scoped: true,\n})\nexport class Menu {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) open: boolean = false;\n @Prop() items: MenuItemData[] = [];\n /** Choice-row density. */\n @Prop() size: MenuSize = 'md';\n /** Give ordinary rows mutually-exclusive menu semantics using isSelected. */\n @Prop() selectionMode: MenuSelectionMode = 'none';\n @Prop() sections: MenuSection[] = [];\n /** Preferred side; placement flips to the opposite side when that offers a better fit. */\n @Prop() side: MenuSide = 'bottom';\n @Prop() align: MenuAlign = 'start';\n /** Align choice-row edges to the anchor by default; use `popup-frame` only for custom frame geometry. */\n @Prop() anchorAlignment: ChoicePopupAnchorAlignment = 'choice-cell';\n /** Gap between anchor and menu — number (px) or TokoMo length (`var(--dimension-space-050)`, etc.). */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number (px) or TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n @Prop() menuWidth: string | undefined;\n @Prop() minWidth: string | undefined;\n /** External trigger element to position against. Set via JS: menuEl.anchor = buttonEl */\n @Prop() anchor: HTMLElement | undefined;\n /** ID of the external trigger element for positioning */\n @Prop() anchorId: string | undefined;\n /** Show a visible ring on the initially focused menu item. Use only when the opener was keyboard-driven. */\n @Prop() initialFocusVisible: boolean = false;\n /** Accessible name for the popup menu. */\n @Prop() menuLabel: string = 'Menu';\n\n @State() private shouldRender: boolean = false;\n @State() private closing: boolean = false;\n @State() private pos: { x: number; y: number } = { x: 0, y: 0 };\n @State() private focusedIndex: number = 0;\n @State() private positionReady: boolean = false;\n @State() private focusRingVisible: boolean = false;\n\n @Event() dsClose!: EventEmitter<void>;\n /** Emitted after the popup's exit motion is complete and its rendered content is removed. */\n @Event() dsAfterClose!: EventEmitter<void>;\n @Event() dsSelect!: EventEmitter<MenuItemData>;\n /** Emitted when a `gradient-picker` section swatch is chosen. */\n @Event() dsGradientSelect!: EventEmitter<ShellGradientPreset>;\n /** Emitted when a generic `swatch-picker` section option is chosen. */\n @Event() dsSwatchSelect!: EventEmitter<string>;\n\n private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n /**\n * Anchored placement. The choice-cell measurement stays here on purpose: the\n * inner-cell align offset and the `--dimension-menu-width-xs` floor are Menu\n * semantics, not shared geometry.\n */\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.resolvedAnchor ?? null,\n getPopup: () => this.el.querySelector<HTMLElement>('.menu-popup'),\n measure: (anchorEl, popup) => {\n if (!this.open) return null;\n\n const anchorRect = anchorEl.getBoundingClientRect();\n const sectionInsetPx = this.viewportPadPx;\n if (!this.minWidth) {\n if (\n this.anchorAlignment === 'choice-cell' &&\n (this.side === 'top' || this.side === 'bottom')\n ) {\n popup.style.minWidth = `max(var(--dimension-menu-width-xs), ${choicePopupMinWidth(\n anchorRect.width,\n sectionInsetPx\n )}px)`;\n } else {\n popup.style.removeProperty('min-width');\n }\n }\n\n return {\n anchorRect,\n popupWidth: popup.offsetWidth || this.popupFallbackWidthPx,\n popupHeight: popup.offsetHeight || this.popupFallbackHeightPx,\n side: this.side,\n align: this.align,\n sideOffsetPx: this.sideOffsetPx,\n alignOffsetPx: resolveChoicePopupAlignOffset({\n align: this.align,\n alignOffsetPx: this.alignOffsetPx,\n sectionInsetPx,\n anchorAlignment: this.anchorAlignment,\n }),\n viewportPadPx: this.viewportPadPx,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n };\n },\n apply: ({ x, y }) => {\n this.pos = { x, y };\n },\n onReady: () => {\n this.positionReady = true;\n },\n liveUpdate: 'double-frame',\n topLayer: true,\n });\n /** Last content actually painted while open; retained unchanged through exit motion. */\n private lastRenderedSections: MenuSection[] = [];\n private closingSections: MenuSection[] | null = null;\n\n componentDidLoad() {\n if (this.open) this.onOpenChange(true);\n }\n\n disconnectedCallback() {\n this.cancelPositionRetry();\n this.cancelLivePositionUpdate();\n this.teardownListeners();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.teardownListeners();\n this.closingSections = null;\n this.shouldRender = true;\n this.closing = false;\n this.positionReady = false;\n this.focusRingVisible = this.initialFocusVisible;\n this.setupListeners();\n this.schedulePositionUpdate(() => {\n this.focusInitialItem();\n });\n } else if (this.shouldRender) {\n this.cancelPositionRetry();\n this.captureClosingSections();\n this.closing = true;\n this.teardownListeners();\n const closeAnimationMs = this.closeAnimationMs;\n if (closeAnimationMs <= 0) {\n this.finishClose();\n return;\n }\n this.closeTimer = setTimeout(() => {\n this.closeTimer = null;\n this.finishClose();\n }, closeAnimationMs);\n }\n }\n\n @Watch('anchor')\n @Watch('anchorId')\n onAnchorChange() {\n if (this.open) this.schedulePositionUpdate();\n }\n\n @Watch('side')\n @Watch('align')\n @Watch('anchorAlignment')\n @Watch('sideOffset')\n @Watch('alignOffset')\n onPositionPropsChange() {\n if (this.open) this.schedulePositionUpdate();\n }\n\n private get viewportPadPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get sideOffsetPx(): number {\n return resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050);\n }\n\n private get alignOffsetPx(): number {\n return resolveCssLengthPx(this.alignOffset, 0);\n }\n\n private get popupFallbackWidthPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuWidthXs, TOKEN_DEFAULTS.menuWidthXs);\n }\n\n private get popupFallbackHeightPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuFallbackHeight, TOKEN_DEFAULTS.menuFallbackHeight);\n }\n\n private get closeAnimationMs(): number {\n return resolveMotionTimeMs(TOKEN_DEFAULTS.motionShort2, TOKEN_DEFAULTS.animationDurationShort3);\n }\n\n private finishClose() {\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (popup?.matches(':popover-open')) popup.hidePopover();\n this.shouldRender = false;\n this.closing = false;\n this.lastRenderedSections = [];\n this.closingSections = null;\n this.dsAfterClose.emit();\n }\n\n private captureClosingSections() {\n if (this.closingSections) return;\n const visibleSections = this.lastRenderedSections.length\n ? this.lastRenderedSections\n : this.activeSections;\n this.closingSections = snapshotMenuSections(visibleSections);\n }\n\n private get resolvedAnchor(): HTMLElement | null {\n if (this.anchor) return this.anchor;\n if (this.anchorId) return document.getElementById(this.anchorId);\n return null;\n }\n\n private get anchorFocusTarget(): HTMLElement | null {\n const anchor = this.resolvedAnchor;\n if (!anchor) return null;\n if (anchor.matches(MENU_FOCUSABLE_SELECTOR)) return anchor;\n return anchor.querySelector<HTMLElement>(MENU_FOCUSABLE_SELECTOR) ?? anchor;\n }\n\n private focusAnchor() {\n const anchor = this.resolvedAnchor as\n (HTMLElement & { setFocus?: () => Promise<void> | void }) | null;\n if (anchor?.setFocus) {\n anchor.setFocus();\n return;\n }\n this.anchorFocusTarget?.focus();\n }\n\n private moveFocusAfterTab(backwards: boolean) {\n const anchor = this.anchorFocusTarget;\n if (!anchor) return;\n if (backwards) {\n anchor.focus();\n return;\n }\n\n const candidates = Array.from(\n document.querySelectorAll<HTMLElement>(MENU_FOCUSABLE_SELECTOR)\n ).filter(\n element =>\n !element.closest('.menu-popup') &&\n !element.closest('[inert]') &&\n element.getClientRects().length > 0\n );\n const index = candidates.indexOf(anchor);\n (candidates[index + 1] ?? anchor).focus();\n }\n\n private compositeTabLeavesPopup(event: KeyboardEvent): boolean {\n const popup = this.el.querySelector<HTMLElement>('.menu-popup');\n if (!popup) return true;\n const focusables = Array.from(\n popup.querySelectorAll<HTMLElement>(MENU_FOCUSABLE_SELECTOR)\n ).filter(element => element.getClientRects().length > 0);\n const currentIndex = focusables.indexOf(event.target as HTMLElement);\n if (currentIndex < 0) return true;\n return event.shiftKey ? currentIndex === 0 : currentIndex === focusables.length - 1;\n }\n\n private get activeSections(): MenuSection[] {\n if (this.sections.length > 0) return this.sections;\n if (this.items.length > 0) return [{ items: this.items }];\n return [];\n }\n\n private get flatItems(): MenuItemData[] {\n return this.activeSections.flatMap(section =>\n isMenuPickerSection(section) ? [] : section.items\n );\n }\n\n /** Rich preference content is a non-modal dialog, not an ARIA action menu. */\n private get hasCompositeSections(): boolean {\n return this.activeSections.some(isMenuPickerSection);\n }\n\n private cancelPositionRetry() {\n this.position.cancel();\n }\n\n private cancelLivePositionUpdate() {\n this.position.cancel();\n }\n\n /** Reposition after the current scroll/layout frame without hiding the open popup. */\n private scheduleLivePositionUpdate() {\n this.position.scheduleLiveUpdate();\n }\n\n /** Retry until anchor + popup exist — do not reveal at 0,0 on a failed first pass. */\n private schedulePositionUpdate(onReady?: () => void) {\n if (!this.open) return;\n this.positionReady = false;\n this.position.schedule(onReady);\n }\n\n /** @returns `true` when anchor and popup were found and `pos` was updated. */\n private calculatePosition(): boolean {\n return this.position.update();\n }\n\n /** Focus the selected item when present, otherwise the first enabled item. */\n private focusInitialItem() {\n if (this.hasCompositeSections) {\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>('.menu-popup [tabindex=\"0\"]')?.focus();\n });\n return;\n }\n\n const flat = this.flatItems;\n const selectedIdx = flat.findIndex(it => it.isSelected && !it.isInactive);\n const firstEnabledIdx = flat.findIndex(it => !it.isInactive);\n this.focusedIndex = selectedIdx >= 0 ? selectedIdx : firstEnabledIdx >= 0 ? firstEnabledIdx : 0;\n\n requestAnimationFrame(() => {\n const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');\n btns[this.focusedIndex]?.focus();\n });\n }\n\n private setupListeners() {\n this.clickOutsideHandler = (e: MouseEvent) => {\n const t = e.target as Node;\n const popup = this.el.querySelector('.menu-popup');\n const anchorEl = this.resolvedAnchor;\n if (popup?.contains(t) || anchorEl?.contains(t)) return;\n this.close();\n };\n\n document.addEventListener('mousedown', this.clickOutsideHandler, true);\n this.position.observe();\n }\n\n private teardownListeners() {\n this.position.unobserve();\n if (this.clickOutsideHandler) {\n document.removeEventListener('mousedown', this.clickOutsideHandler, true);\n this.clickOutsideHandler = null;\n }\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private close(restoreFocus = true) {\n this.captureClosingSections();\n if (restoreFocus) this.focusAnchor();\n this.dsClose.emit();\n this.open = false;\n this.onOpenChange(false);\n }\n\n @Listen('keydown')\n handleKeyDown(e: KeyboardEvent) {\n if (!this.shouldRender || this.closing) return;\n\n if (e.key === 'Escape') {\n e.preventDefault();\n this.close();\n return;\n }\n\n if (e.key === 'Tab') {\n if (this.hasCompositeSections && !this.compositeTabLeavesPopup(e)) return;\n e.preventDefault();\n this.close(false);\n this.moveFocusAfterTab(e.shiftKey);\n return;\n }\n\n if (this.hasCompositeSections) return;\n\n const flat = this.flatItems;\n const enabled = flat\n .map((it, i) => ({ it, i }))\n .filter(({ it }) => !it.isInactive)\n .map(({ i }) => i);\n if (!enabled.length) return;\n\n const cur = enabled.indexOf(this.focusedIndex);\n const safe = cur < 0 ? 0 : cur;\n\n switch (e.key) {\n case 'ArrowDown':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[(safe + 1) % enabled.length];\n this.focusItem(this.focusedIndex);\n break;\n case 'ArrowUp':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[(safe - 1 + enabled.length) % enabled.length];\n this.focusItem(this.focusedIndex);\n break;\n case 'Home':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[0];\n this.focusItem(this.focusedIndex);\n break;\n case 'End':\n e.preventDefault();\n e.stopPropagation();\n this.focusRingVisible = true;\n this.focusedIndex = enabled[enabled.length - 1];\n this.focusItem(this.focusedIndex);\n break;\n }\n }\n\n private focusItem(idx: number) {\n const btns = this.el.querySelectorAll<HTMLElement>('.menu-item');\n btns[idx]?.focus();\n }\n\n private handleItemClick(item: MenuItemData) {\n if (item.isInactive) return;\n // Consumers commonly change the item source while handling selection.\n // Capture first so the exit animation never paints the next menu context.\n this.captureClosingSections();\n this.dsSelect.emit(item);\n }\n\n private handleGradientSelect(preset: ShellGradientPreset) {\n this.dsGradientSelect.emit(preset);\n }\n\n private handleSwatchSelect(value: string) {\n this.dsSwatchSelect.emit(value);\n }\n\n render() {\n if (!this.shouldRender) return <Host style={{ display: 'contents' }} />;\n\n const sections = this.closing\n ? (this.closingSections ?? this.lastRenderedSections)\n : this.activeSections;\n const hasCompositeSections = sections.some(isMenuPickerSection);\n if (!this.closing) {\n this.lastRenderedSections = snapshotMenuSections(sections);\n this.closingSections = null;\n }\n let flatIdx = 0;\n\n const popupStyle: Record<string, string> = {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(this.pos.x)}px, ${Math.round(this.pos.y)}px)`,\n zIndex: '9998',\n visibility: this.positionReady ? 'visible' : 'hidden',\n };\n\n if (this.menuWidth) popupStyle['width'] = this.menuWidth;\n if (this.minWidth) popupStyle['min-width'] = this.minWidth;\n\n return (\n <Host style={{ display: 'contents' }}>\n <div\n popover=\"manual\"\n class={{\n 'menu-popup': true,\n 'menu-popup--closing': this.closing,\n 'ds-choice-popup': true,\n 'ds-choice-popup--closing': this.closing,\n }}\n style={popupStyle}\n role={hasCompositeSections ? 'dialog' : 'menu'}\n aria-label={this.menuLabel}\n aria-orientation={hasCompositeSections ? undefined : 'vertical'}\n >\n <div class=\"ds-choice-list\">\n {sections.map((section, si) => (\n <div\n key={si}\n class={{\n 'menu-section': true,\n 'menu-section--divided': si < sections.length - 1,\n 'menu-section--gradient-picker': isMenuPickerSection(section),\n 'ds-choice-section': true,\n 'ds-chrome-column': true,\n 'ds-chrome-space--sm': true,\n 'ds-choice-section--divided': si < sections.length - 1,\n }}\n role={section.header ? 'group' : undefined}\n aria-label={section.header}\n >\n {section.header && (\n <ds-text\n class={`section-header ds-choice-section__header ds-control--${this.size}`}\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n emphasis\n color=\"primary\"\n aria-hidden=\"true\"\n >\n {section.header}\n </ds-text>\n )}\n {isMenuGradientPickerSection(section) ? (\n <ds-swatch-picker\n value={section.value}\n groupLabel={section.header ?? 'Shell gradient theme'}\n sections={shellGradientPickerSections()}\n onDsChange={(e: CustomEvent<string>) => {\n e.stopPropagation();\n this.handleGradientSelect(e.detail as ShellGradientPreset);\n }}\n />\n ) : isMenuSwatchPickerSection(section) ? (\n <ds-swatch-picker\n value={section.value}\n groupLabel={section.groupLabel ?? section.header ?? 'Swatch options'}\n options={section.options ?? []}\n sections={section.sections ?? []}\n onDsChange={(e: CustomEvent<string>) => {\n e.stopPropagation();\n this.handleSwatchSelect(e.detail);\n }}\n />\n ) : (\n section.items.map(item => {\n const idx = flatIdx++;\n const isFocused = this.focusedIndex === idx;\n const isSingleSelectionItem =\n !hasCompositeSections && this.selectionMode === 'single' && !item.showSwitch;\n return (\n <button\n key={idx}\n type=\"button\"\n class={{\n 'menu-item': true,\n 'ds-choice-item': true,\n 'ds-control-frame': true,\n [`ds-control--${this.size}`]: true,\n 'ds-focus-ring-inset': true,\n 'ds-focus-ring--visible': isFocused && this.focusRingVisible,\n 'ds-interaction-fill': !item.isInactive,\n 'ds-interaction-fill--selected': !!item.isSelected && !item.isInactive,\n 'menu-item--selected': !!item.isSelected,\n 'ds-control-inactive': !!item.isInactive,\n 'menu-item--destructive': !!item.isDestructive,\n 'menu-item--focused': isFocused,\n }}\n role={\n hasCompositeSections\n ? undefined\n : item.showSwitch\n ? 'menuitemcheckbox'\n : isSingleSelectionItem\n ? 'menuitemradio'\n : 'menuitem'\n }\n aria-checked={\n !hasCompositeSections && item.showSwitch\n ? String(!!item.switchValue)\n : isSingleSelectionItem\n ? String(!!item.isSelected)\n : undefined\n }\n aria-pressed={\n hasCompositeSections\n ? String(item.showSwitch ? !!item.switchValue : !!item.isSelected)\n : undefined\n }\n aria-current={\n !hasCompositeSections &&\n !item.showSwitch &&\n !isSingleSelectionItem &&\n item.isSelected\n ? 'true'\n : undefined\n }\n disabled={item.isInactive}\n tabIndex={hasCompositeSections ? 0 : isFocused ? 0 : -1}\n onMouseDown={() => {\n this.focusRingVisible = false;\n }}\n onClick={() => this.handleItemClick(item)}\n onFocus={() => {\n this.focusedIndex = idx;\n }}\n >\n <div class=\"menu-item__content ds-choice-item__content ds-interaction-fill__content\">\n <ds-text\n class=\"menu-item__label ds-choice-item__label ds-control-label-box\"\n as=\"span\"\n variant={CONTROL_TEXT_VARIANT[this.size]}\n color={item.isSelected ? 'primary' : 'secondary'}\n >\n {item.label}\n </ds-text>\n {item.subtext && (\n <ds-text\n class=\"menu-item__subtext ds-choice-item__subtext ds-control-label-box\"\n as=\"span\"\n variant={CONTROL_SUPPORTING_TEXT_VARIANT[this.size]}\n color=\"secondary\"\n >\n {item.subtext}\n </ds-text>\n )}\n </div>\n {item.dot && (\n <span\n class=\"menu-item__dot-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-badge\n class=\"menu-item__dot\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n />\n </span>\n )}\n {item.showSwitch && (\n <ds-switch\n class=\"menu-item__switch ds-interaction-fill__content\"\n size={this.size}\n checked={!!item.switchValue}\n presentation\n />\n )}\n </button>\n );\n })\n )}\n </div>\n ))}\n </div>\n </div>\n </Host>\n );\n }\n}\n",
498
498
  "type": "registry:ui"
499
499
  },
500
500
  {
@@ -2076,7 +2076,7 @@
2076
2076
  {
2077
2077
  "name": "card-overview",
2078
2078
  "title": "CardOverview",
2079
- "description": "Page-level summary bar pairing one headline score with a reporting period, an application-owned period control, and a responsive grid of comparable measures.",
2079
+ "description": "Page-level summary card pairing one headline score with a reporting period, an application-owned period control, and a responsive grid of comparable measures, with an optional compact 48px presentation.",
2080
2080
  "type": "registry:ui",
2081
2081
  "meta": {
2082
2082
  "library": "@ds-mo/ui",
@@ -2087,7 +2087,7 @@
2087
2087
  "intent": {
2088
2088
  "audience": "general",
2089
2089
  "status": "experimental",
2090
- "summary": "Page-level summary bar pairing one headline score with a reporting period, an application-owned period control, and a responsive grid of comparable measures.",
2090
+ "summary": "Page-level summary card pairing one headline score with a reporting period, an application-owned period control, and a responsive grid of comparable measures, with an optional compact 48px presentation.",
2091
2091
  "useWhen": [
2092
2092
  "A page opens with one headline figure that the surrounding measures explain.",
2093
2093
  "Every measure is compared against the same period, so one caption describes the comparison for all of them.",
@@ -2131,13 +2131,20 @@
2131
2131
  "Omit a trend when there is nothing to report; resolveMetricTrend returns null in that case so no zero change is rendered.",
2132
2132
  "Omit score to render the bar without a headline figure. Set scoreErrorMessage to report that the figure could not be resolved while the measures remain readable.",
2133
2133
  "Loading replaces the score and the measures with skeletons and does not alter the slotted period control.",
2134
- "The period control is slotted rather than owned, because the choice of stepper, select, or date range varies by product surface."
2134
+ "At most six measures are shown. Keep larger collections outside the summary bar rather than turning it into a dense dashboard.",
2135
+ "The period control is slotted rather than owned, because the choice of stepper, select, or date range varies by product surface.",
2136
+ "compact is exactly 48px high and keeps only the score label, score value, trend, reporting copy, and slotted period control. It omits the score band, measures, and footer."
2135
2137
  ],
2136
2138
  "responsiveBehavior": [
2137
2139
  "The measure grid reflows to fewer columns and finally stacks, driven by the minimum measure width alone. There is no measurement and no horizontal scrolling.",
2140
+ "Every occupied grid track has the same width. When a final row is incomplete, its measures keep the same width as the row above and the unused tracks remain empty.",
2138
2141
  "Set metricMinWidth to change the width a measure may shrink to before the grid drops a column.",
2139
2142
  "The score block hugs its content and never shrinks, so the headline figure holds its size while the grid absorbs the remaining width.",
2140
- "The score moves above the body once the bar can no longer fit both side by side."
2143
+ "Once the card reaches its narrow stacked state, one summary block holds the score label, score, trend, score band, reporting period, and the application-owned period control above the single-column measures.",
2144
+ "In that stacked summary, the score and trend share one line at the start, while the score band is hidden to preserve space. The period control stays at the upper end, while the current period and comparison sit on separate right-aligned lines beside the score.",
2145
+ "Every measure occupies a full-width row in the stacked state.",
2146
+ "Set layout to stacked when the owning page shell enters a responsive mode before the card's own container reaches its narrow threshold.",
2147
+ "CardOverview does not observe scroll or choose its own presentation. The page owner may use compact as sticky summary chrome on supported breakpoints."
2141
2148
  ],
2142
2149
  "frameworkCaveats": {
2143
2150
  "customElements": [
@@ -2173,6 +2180,10 @@
2173
2180
  "type": "boolean",
2174
2181
  "required": false
2175
2182
  },
2183
+ "layout": {
2184
+ "type": "CardOverviewLayout",
2185
+ "required": false
2186
+ },
2176
2187
  "metricMinWidth": {
2177
2188
  "type": "string",
2178
2189
  "required": false
@@ -2196,6 +2207,10 @@
2196
2207
  "scoreErrorMessage": {
2197
2208
  "type": "string | undefined",
2198
2209
  "required": false
2210
+ },
2211
+ "variant": {
2212
+ "type": "CardOverviewVariant",
2213
+ "required": false
2199
2214
  }
2200
2215
  },
2201
2216
  "events": [
@@ -9083,7 +9098,7 @@
9083
9098
  "states": [
9084
9099
  "A variant is atomic: font size, line height, weight, and letter spacing stay together and are not overridden by consumers.",
9085
9100
  "Emphasis selects the supported stronger recipe for local hierarchy; it does not independently mean selected or active.",
9086
- "Omitted color inherits currentColor. Explicit semantic aliases or CSS-variable references are used only when Text has an independent color role.",
9101
+ "Omitted color inherits currentColor without imposing a host color declaration, so an owning component may apply its semantic foreground directly without depending on stylesheet order. Explicit semantic aliases or CSS-variable references are used when Text has an independent color role.",
9087
9102
  "Prefer the owning control's isInactive or native disabled state over manually selecting tertiary or quaternary so visual treatment and inactive semantics remain synchronized.",
9088
9103
  "Line truncation and wrapping require an owner-supplied width constraint and content-aware composition decision.",
9089
9104
  "Solid underline is the default link affordance; brand-blue links may omit it at rest. Dotted underline communicates hidden or supplemental interaction such as a tooltip. Decoration does not make Text interactive by itself.",