@adia-ai/web-components 0.8.42 → 0.8.44

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 (186) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/MIGRATION.md +130 -80
  3. package/USAGE.md +1 -1
  4. package/color/index.js +1 -1
  5. package/components/action-list/action-item.a2ui.json +3 -8
  6. package/components/action-list/action-item.yaml +10 -16
  7. package/components/action-list/action-list.class.js +5 -61
  8. package/components/action-list/action-list.d.ts +1 -3
  9. package/components/avatar/avatar.class.js +12 -1
  10. package/components/button/button.d.ts +4 -0
  11. package/components/button/button.yaml +1 -1
  12. package/components/calendar-picker/calendar-picker.a2ui.json +1 -1
  13. package/components/calendar-picker/calendar-picker.class.js +2 -3
  14. package/components/calendar-picker/calendar-picker.css +16 -18
  15. package/components/calendar-picker/calendar-picker.yaml +2 -2
  16. package/components/card/card.css +3 -3
  17. package/components/chart/chart.a2ui.json +29 -18
  18. package/components/chart/chart.class.js +1308 -266
  19. package/components/chart/chart.css +151 -1
  20. package/components/chart/chart.d.ts +9 -9
  21. package/components/chart/chart.yaml +63 -31
  22. package/components/chart-legend/chart-legend.a2ui.json +20 -14
  23. package/components/chart-legend/chart-legend.class.js +79 -58
  24. package/components/chart-legend/chart-legend.css +65 -1
  25. package/components/chart-legend/chart-legend.d.ts +5 -5
  26. package/components/chart-legend/chart-legend.examples.md +4 -4
  27. package/components/chart-legend/chart-legend.yaml +50 -20
  28. package/components/check/check.css +9 -0
  29. package/components/color-area/color-area.a2ui.json +1 -1
  30. package/components/color-area/color-area.class.js +2 -2
  31. package/components/color-area/color-area.css +4 -7
  32. package/components/color-area/color-area.d.ts +1 -1
  33. package/components/color-area/color-area.yaml +3 -3
  34. package/components/color-input/color-input.a2ui.json +1 -1
  35. package/components/color-input/color-input.class.js +1 -1
  36. package/components/color-input/color-input.yaml +2 -2
  37. package/components/combobox/combobox.a2ui.json +3 -0
  38. package/components/combobox/combobox.class.js +9 -8
  39. package/components/combobox/combobox.css +25 -4
  40. package/components/combobox/combobox.yaml +6 -0
  41. package/components/command/command.css +17 -1
  42. package/components/context-menu/context-menu.d.ts +2 -0
  43. package/components/drilldown/drilldown.a2ui.json +3 -0
  44. package/components/drilldown/drilldown.css +8 -1
  45. package/components/drilldown/drilldown.yaml +2 -0
  46. package/components/field/field.class.js +5 -5
  47. package/components/heatmap/heatmap.a2ui.json +5 -0
  48. package/components/heatmap/heatmap.d.ts +2 -0
  49. package/components/heatmap/heatmap.yaml +8 -0
  50. package/components/index.js +0 -4
  51. package/components/input/input.a2ui.json +4 -4
  52. package/components/input/input.class.js +20 -7
  53. package/components/input/input.css +6 -0
  54. package/components/input/input.yaml +15 -11
  55. package/components/integration-card/integration-card.d.ts +2 -0
  56. package/components/menu/menu-item.a2ui.json +3 -8
  57. package/components/menu/menu-item.yaml +13 -21
  58. package/components/menu/menu.class.js +5 -27
  59. package/components/menu/menu.d.ts +1 -3
  60. package/components/noodles/noodles.a2ui.json +0 -15
  61. package/components/noodles/noodles.yaml +0 -12
  62. package/components/number-format/number-format.class.js +10 -0
  63. package/components/pagination/pagination.class.js +88 -4
  64. package/components/pane/pane.a2ui.json +2 -12
  65. package/components/pane/pane.class.js +5 -30
  66. package/components/pane/pane.d.ts +3 -5
  67. package/components/pane/pane.yaml +7 -19
  68. package/components/popover/popover.d.ts +2 -0
  69. package/components/progress/progress.a2ui.json +1 -1
  70. package/components/progress/progress.class.js +13 -8
  71. package/components/progress/progress.d.ts +1 -1
  72. package/components/progress/progress.yaml +2 -2
  73. package/components/radio/radio.css +7 -0
  74. package/components/range/range.class.js +9 -1
  75. package/components/search/search.css +10 -1
  76. package/components/segment/segment.a2ui.json +1 -2
  77. package/components/segment/segment.css +10 -0
  78. package/components/segment/segment.yaml +1 -2
  79. package/components/segmented/segmented.a2ui.json +1 -3
  80. package/components/segmented/segmented.class.js +2 -2
  81. package/components/segmented/segmented.yaml +2 -4
  82. package/components/select/select.a2ui.json +5 -1
  83. package/components/select/select.class.js +14 -14
  84. package/components/select/select.css +20 -1
  85. package/components/select/select.yaml +7 -1
  86. package/components/step-progress/step-progress.yaml +1 -1
  87. package/components/stream/stream.d.ts +2 -0
  88. package/components/switch/switch.css +8 -0
  89. package/components/table/table.a2ui.json +6 -1
  90. package/components/table/table.class.js +18 -1
  91. package/components/table/table.css +31 -6
  92. package/components/table/table.d.ts +3 -1
  93. package/components/table/table.examples.md +2 -2
  94. package/components/table/table.yaml +9 -1
  95. package/components/table-toolbar/table-toolbar.a2ui.json +67 -2
  96. package/components/table-toolbar/table-toolbar.class.js +492 -17
  97. package/components/table-toolbar/table-toolbar.css +311 -0
  98. package/components/table-toolbar/table-toolbar.d.ts +17 -1
  99. package/components/table-toolbar/table-toolbar.yaml +220 -15
  100. package/components/tag/tag.class.js +13 -1
  101. package/components/text/text.a2ui.json +3 -3
  102. package/components/text/text.d.ts +5 -3
  103. package/components/text/text.yaml +3 -3
  104. package/components/textarea/textarea.class.js +9 -1
  105. package/components/textarea/textarea.css +4 -0
  106. package/components/time-picker/time-picker.yaml +3 -2
  107. package/components/tour/tour.d.ts +10 -0
  108. package/components/tree/tree.yaml +1 -1
  109. package/components/upload/upload.class.js +16 -0
  110. package/components/upload/upload.d.ts +7 -0
  111. package/components/upload/upload.yaml +7 -3
  112. package/core/a11y.d.ts +22 -0
  113. package/core/a11y.js +64 -0
  114. package/core/anchor.js +9 -5
  115. package/core/element.js +1 -1
  116. package/core/icons.js +2 -5
  117. package/custom-elements.json +142 -594
  118. package/dist/host.min.css +1 -1
  119. package/dist/host.sheet.js +1 -1
  120. package/dist/theme-provider.min.js +1 -1
  121. package/dist/web-components.min.css +1 -1
  122. package/dist/web-components.min.js +122 -119
  123. package/dist/web-components.sheet.js +1 -1
  124. package/index.d.ts +0 -15
  125. package/package.json +1 -1
  126. package/patterns/access-requests/access-requests.examples.html +1 -1
  127. package/patterns/admin-keys-and-export/admin-keys-and-export.examples.html +7 -7
  128. package/patterns/agent-cost/agent-cost.examples.html +6 -6
  129. package/patterns/agent-memory/agent-memory.examples.html +3 -3
  130. package/patterns/agent-prompt-library/agent-prompt-library.examples.html +8 -8
  131. package/patterns/agent-tool-call/agent-tool-call.examples.html +1 -1
  132. package/patterns/approvals/approvals.examples.html +6 -6
  133. package/patterns/audit-log/audit-log.examples.html +1 -1
  134. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +4 -4
  135. package/patterns/chart-in-card/chart-in-card.examples.html +124 -10
  136. package/patterns/chart-in-card/chart-in-card.examples.js +37 -0
  137. package/patterns/comments-and-collaboration/comments-and-collaboration.examples.html +5 -5
  138. package/patterns/conversion-funnel/conversion-funnel.html +2 -2
  139. package/patterns/data-tables-inline-edit-and-tree/data-tables-inline-edit-and-tree.examples.html +2 -2
  140. package/patterns/diff-review/diff-review.examples.html +6 -6
  141. package/patterns/editor-shell/editor-shell.examples.html +3 -3
  142. package/patterns/export-flow/export-flow.examples.html +2 -2
  143. package/patterns/filter-bar/filter-bar.examples.html +2 -2
  144. package/patterns/forms-conditional-and-autocomplete/forms-conditional-and-autocomplete.examples.html +2 -2
  145. package/patterns/inline-dialog/inline-dialog.examples.html +2 -2
  146. package/patterns/marketing-engagement/marketing-engagement.examples.html +4 -4
  147. package/patterns/notifications-bell-and-digest/notifications-bell-and-digest.examples.html +1 -1
  148. package/patterns/permissions-matrix/permissions-matrix.examples.html +1 -1
  149. package/patterns/permissions-sharing/permissions-sharing.examples.html +2 -2
  150. package/patterns/profile-public-and-verification/profile-public-and-verification.examples.html +4 -4
  151. package/patterns/search-discovery/search-discovery.examples.html +2 -2
  152. package/styles/colors/semantics/features.css +8 -1
  153. package/styles/components.css +0 -4
  154. package/styles/type/roles.css +29 -9
  155. package/traits/arrow-grid-nav/arrow-grid-nav.js +1 -1
  156. package/traits/resize-observer/resize-observer.js +1 -1
  157. package/components/color-picker/color-picker.a2ui.json +0 -148
  158. package/components/color-picker/color-picker.class.js +0 -46
  159. package/components/color-picker/color-picker.css +0 -14
  160. package/components/color-picker/color-picker.d.ts +0 -99
  161. package/components/color-picker/color-picker.examples.md +0 -19
  162. package/components/color-picker/color-picker.js +0 -17
  163. package/components/color-picker/color-picker.yaml +0 -175
  164. package/components/datetime-picker/datetime-picker.a2ui.json +0 -256
  165. package/components/datetime-picker/datetime-picker.class.js +0 -58
  166. package/components/datetime-picker/datetime-picker.css +0 -11
  167. package/components/datetime-picker/datetime-picker.d.ts +0 -87
  168. package/components/datetime-picker/datetime-picker.examples.md +0 -25
  169. package/components/datetime-picker/datetime-picker.js +0 -17
  170. package/components/datetime-picker/datetime-picker.yaml +0 -254
  171. package/components/progress-row/progress-row.a2ui.json +0 -107
  172. package/components/progress-row/progress-row.class.js +0 -118
  173. package/components/progress-row/progress-row.css +0 -60
  174. package/components/progress-row/progress-row.d.ts +0 -24
  175. package/components/progress-row/progress-row.examples.md +0 -7
  176. package/components/progress-row/progress-row.js +0 -17
  177. package/components/progress-row/progress-row.yaml +0 -85
  178. package/components/toggle-group/toggle-group.a2ui.json +0 -148
  179. package/components/toggle-group/toggle-group.class.js +0 -195
  180. package/components/toggle-group/toggle-group.css +0 -127
  181. package/components/toggle-group/toggle-group.d.ts +0 -50
  182. package/components/toggle-group/toggle-group.examples.md +0 -33
  183. package/components/toggle-group/toggle-group.js +0 -18
  184. package/components/toggle-group/toggle-group.yaml +0 -135
  185. package/components/toggle-group/toggle-option.a2ui.json +0 -86
  186. package/components/toggle-group/toggle-option.yaml +0 -75
@@ -22,14 +22,21 @@
22
22
  * x — key for X-axis data
23
23
  * y — key(s) for Y-axis, comma-separated for multi-series
24
24
  * no-average — suppress the overlaid average line on bar/line (default: false — line shown).
25
- * `hide-average` is a deprecated dual-read alias (ADR-0063, gh#1563).
26
25
  * color — accent/success/warning/danger/info
27
- * no-grid — hide gridlines. `hide-grid` is a deprecated dual-read alias (ADR-0063, gh#1563).
28
- * no-values — hide value labels. `hide-values` is a deprecated dual-read alias (ADR-0063, gh#1563).
26
+ * no-grid — hide gridlines.
27
+ * no-values — hide value labels.
29
28
  * corner-radius — bar corner radius in px (default: null, falls back to --a-radius)
30
29
  * dots — line/multi-line marker visibility: all|none|hover|last (default: all)
31
30
  * series-emphasis — multi-line: series key that keeps full area+line treatment; others render line-only, dimmed
31
+ * ratio — ADR-0074 aspect-ratio bucket: 3:2|1:1|2:3 (default: '' — auto-snap via
32
+ * the chart's own live aspect; explicit value pins it regardless of the
33
+ * box's actual shape). Reflects the resolved bucket as data-ratio-resolved.
34
+ * y-min/y-max — pins the value-axis domain for cartesian types (bar, line, area,
35
+ * scatter, multi-line, stacked-bar, grouped-bar, composed). Unset
36
+ * (default) keeps auto-scale (floor 0, ceiling data max). N/A for
37
+ * radial/part-to-whole types (gh#1690).
32
38
  *
39
+
33
40
  * JS API:
34
41
  * .data = [{...}, ...] — array of data objects
35
42
  */
@@ -65,6 +72,15 @@ function esc(s) {
65
72
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
66
73
  }
67
74
 
75
+ /* REQ-R-003 (bar, 1:1) — "category labels rotate 0°→abbreviated": a square
76
+ container has less width per category than 3:2, so labels truncate to a
77
+ fixed character budget with an ellipsis rather than rotating (rotation
78
+ would collide with the SPEC's own 2:3 orientation-swap cell). */
79
+ function truncateLabel(label, maxChars) {
80
+ const s = String(label);
81
+ return s.length > maxChars ? s.slice(0, Math.max(1, maxChars - 1)) + '…' : s;
82
+ }
83
+
68
84
  /* Emit tooltip data-attributes for a datum shape. Consumed by the pointer
69
85
  delegate below. All fields optional — unused ones are skipped. */
70
86
  function tip({ label, value, pct, series }) {
@@ -202,6 +218,59 @@ function topRoundedBarPath(x, y, w, h, r = 0) {
202
218
  return `M${x},${y + h} V${y + rr} Q${x},${y} ${x + rr},${y} H${x + w - rr} Q${x + w},${y} ${x + w},${y + rr} V${y + h} Z`;
203
219
  }
204
220
 
221
+ /** Mirror of topRoundedBarPath for HORIZONTAL bars (REQ-R-003 bar, 2:3 —
222
+ * "Bars run horizontal, category on Y"): rounds the RIGHT (value) end,
223
+ * leaves the LEFT (category-axis) end square, so the bar still visually
224
+ * "sits flush" on its axis the same way the vertical bar does on its
225
+ * baseline. r is clamped to (h/2, w) to handle short bars safely. */
226
+ function rightRoundedBarPath(x, y, w, h, r = 0) {
227
+ if (h <= 0 || w <= 0) return '';
228
+ const rr = Math.max(0, Math.min(r, h / 2, w));
229
+ if (rr === 0) {
230
+ return `M${x},${y} H${x + w} V${y + h} H${x} Z`;
231
+ }
232
+ return `M${x},${y} H${x + w - rr} Q${x + w},${y} ${x + w},${y + rr} V${y + h - rr} Q${x + w},${y + h} ${x + w - rr},${y + h} H${x} Z`;
233
+ }
234
+
235
+ /* REQ-R-003 (bar 1:1 carry-forward from Wave A review; segments 1:1) —
236
+ "fewer visible [bars|segments] before scroll/aggregate": the matrix cell
237
+ itself names two possible affordances (scroll OR aggregate). Scroll would
238
+ add a new interactive overflow surface to a primitive that otherwise
239
+ renders one flat, non-scrolling SVG per instance; aggregate reuses
240
+ exactly the same synthetic "+N more" bucket shape either caller already
241
+ needs, so that's the reading this ships (named explicitly — a documented
242
+ choice, not a silent substitution). Caps the rendered category count at
243
+ `cap`, rolling every category past `cap - 1` into one synthetic tail
244
+ bucket. `sortByValue` selects which categories survive the cut:
245
+ segments is a proportional composition strip, so keeping the largest
246
+ contributors visible keeps the strip representative of the whole;
247
+ bar's category axis is normally ordered (e.g. months), so it keeps the
248
+ first `cap - 1` categories in their original authored order instead and
249
+ aggregates the trailing tail — reordering an ordered axis by value would
250
+ itself be a bigger, unasked-for behavior change. */
251
+ function aggregateTail(labels, vals, cap, sortByValue) {
252
+ if (vals.length <= cap) return { labels, vals };
253
+ const keepCount = Math.max(1, cap - 1);
254
+ const order = vals.map((_, i) => i);
255
+ const keepIdx = sortByValue
256
+ ? order.slice().sort((a, b) => vals[b] - vals[a]).slice(0, keepCount).sort((a, b) => a - b)
257
+ : order.slice(0, keepCount);
258
+ const keepSet = new Set(keepIdx);
259
+ const restIdx = order.filter(i => !keepSet.has(i));
260
+ const restSum = restIdx.reduce((s, i) => s + vals[i], 0);
261
+ return {
262
+ labels: keepIdx.map(i => labels[i]).concat([`+${restIdx.length} more`]),
263
+ vals: keepIdx.map(i => vals[i]).concat([restSum]),
264
+ };
265
+ }
266
+
267
+ // REQ-R-003 (bar, 1:1) / (segments, 1:1) — visible-category caps the
268
+ // aggregate-tail helper above enforces. Segments' strip reads cluttered
269
+ // past ~5 proportional slices; bar's individual columns tolerate one more
270
+ // before the same "too many to read" point (fewer per-bar chrome).
271
+ const BAR_1_1_VISIBLE_CAP = 6;
272
+ const SEGMENTS_1_1_VISIBLE_CAP = 5;
273
+
205
274
  /* ── Aspect ratios ─────────────────────────────────────────────── */
206
275
 
207
276
  // Implicit aspect ratio — a chart derives its height from width ÷ this ratio when
@@ -209,6 +278,15 @@ function topRoundedBarPath(x, y, w, h, r = 0) {
209
278
  // was retired (OD-CHART-02); parents size charts directly now.
210
279
  const ASPECTS = { std: { ratio: 4 / 3 } };
211
280
 
281
+ // ADR-0074 (Chart 2.0 ratio grammar, gh#1624 plan step 1) — the three
282
+ // ratified `ratio` buckets as width/height numbers, and the two midpoint
283
+ // boundaries the auto-snap classification (below) compares a live aspect
284
+ // against. Deliberately three discrete buckets, never interpolated — a
285
+ // live aspect strictly between two boundaries snaps to the single nearest
286
+ // named bucket (1:1 is the default bucket: neither boundary matched).
287
+ const CHART_RATIOS = { '3:2': 3 / 2, '1:1': 1, '2:3': 2 / 3 };
288
+ const RATIO_SNAP = { wide: 5 / 4, tall: 4 / 5 };
289
+
212
290
  /* ── Component ──────────────────────────────────────────────────── */
213
291
 
214
292
  export class UIChart extends UIElement {
@@ -216,22 +294,19 @@ export class UIChart extends UIElement {
216
294
  type: { type: String, default: 'bar', reflect: true },
217
295
  x: { type: String, default: '', reflect: true },
218
296
  y: { type: String, default: '', reflect: true },
219
- // ADR-0063 (gh#1563) — `no-*` is the ratified negation prefix; `hide-*`
220
- // retires. `hideAverage`/`hideGrid`/`hideValues` stay declared (dual-
221
- // read compat, 0.8.42) so old markup keeps reflecting; `#resolveNoAttr()`
222
- // (called from connected()) mirrors an old-only value onto the new prop
223
- // + warns once per attribute. Every internal render usage reads the
224
- // canonical `noAverage`/`noGrid`/`noValues` below.
225
- hideAverage: { type: Boolean, default: false, reflect: true, attribute: 'hide-average' },
297
+ // ADR-0063 (gh#1563) — `no-*` is the ratified negation prefix; the
298
+ // deprecated `hide-*` dual-read shim was cut in 0.8.43 (gh#1617).
226
299
  noAverage: { type: Boolean, default: false, reflect: true, attribute: 'no-average' },
227
300
  color: { type: String, default: '', reflect: true },
228
- hideGrid: { type: Boolean, default: false, reflect: true, attribute: 'hide-grid' },
229
301
  noGrid: { type: Boolean, default: false, reflect: true, attribute: 'no-grid' },
230
- hideValues: { type: Boolean, default: false, reflect: true, attribute: 'hide-values' },
231
302
  noValues: { type: Boolean, default: false, reflect: true, attribute: 'no-values' },
232
303
  cornerRadius: { type: Number, default: null, reflect: true, attribute: 'corner-radius' },
233
304
  smooth: { type: Number, default: 0.4, reflect: true },
234
305
  size: { type: String, default: '', reflect: true },
306
+ // ADR-0074 (gh#1624 plan step 1) — grammar + snap plumbing only; no
307
+ // per-type rendering keys off this yet (later waves). See CHART_RATIOS
308
+ // / RATIO_SNAP + #dims() below for the auto-snap/pin mechanism.
309
+ ratio: { type: String, default: '', reflect: true },
235
310
  format: { type: String, default: 'abbr', reflect: true },
236
311
  loading: { type: Boolean, default: false, reflect: true },
237
312
  /* gh#561 — dot/marker visibility for line + multi-line. `all` is the
@@ -247,36 +322,18 @@ export class UIChart extends UIElement {
247
322
  (default) applies full treatment to every series — unchanged from
248
323
  pre-561 behavior. */
249
324
  seriesEmphasis: { type: String, default: '', reflect: true, attribute: 'series-emphasis' },
325
+ /* gh#1690 — pins the value axis domain for cartesian types (bar, line,
326
+ area, scatter, multi-line, stacked-bar, grouped-bar, composed).
327
+ Unset (default, null) keeps today's auto-scale (floor 0, ceiling the
328
+ data max). N/A for radial/part-to-whole types (pie, donut, radar,
329
+ radial-bar, gauge, funnel, treemap, sankey, segments) — those never
330
+ read a shared linear value axis. */
331
+ yMin: { type: Number, default: null, reflect: true, attribute: 'y-min' },
332
+ yMax: { type: Number, default: null, reflect: true, attribute: 'y-max' },
250
333
  };
251
334
 
252
335
  static template = () => null;
253
336
 
254
- // ADR-0063 (gh#1563) compat-shim warn-once flags — one per renamed
255
- // attribute (not collapsed into one flag; each fires independently the
256
- // first time its own old attribute resolves). Per-class, matching
257
- // toggle-group.class.js's #deprecationWarned idiom.
258
- static #warned = { hideAverage: false, hideGrid: false, hideValues: false };
259
-
260
- /** Dual-read resolution for one `hide-*` → `no-*` rename (ADR-0063).
261
- * New-name precedence: if `newAttr` is present, the old attribute is
262
- * ignored outright (no mirror, no warn) — the new prop already reflects
263
- * its own attribute normally. Otherwise, when only the old attribute is
264
- * present, mirror its (always-true, boolean-presence) value onto the
265
- * new prop and warn once per class. */
266
- #resolveNoAttr(oldAttr, newAttr, newProp, warnKey) {
267
- if (this.hasAttribute(newAttr)) return;
268
- if (!this.hasAttribute(oldAttr)) return;
269
- this[newProp] = true;
270
- if (!UIChart.#warned[warnKey]) {
271
- UIChart.#warned[warnKey] = true;
272
- // eslint-disable-next-line no-console
273
- console.warn(
274
- `[AdiaUI] <chart-ui [${oldAttr}]> is deprecated — use [${newAttr}] ` +
275
- `instead (ADR-0063, gh#1563). Kept working as-is; no behavior change.`,
276
- );
277
- }
278
- }
279
-
280
337
  #data = [];
281
338
  #shapeWarned = false; // FEEDBACK-24: one-shot wrong-.data-shape warning
282
339
  #resizeObs = null;
@@ -301,6 +358,40 @@ export class UIChart extends UIElement {
301
358
  return Number.isFinite(parsed) && parsed > 0 ? parsed : 6;
302
359
  }
303
360
 
361
+ /** Resolves the value-axis domain [lo, hi] for a cartesian render path,
362
+ * honoring explicit `yMin`/`yMax` pins over the default auto-scale
363
+ * (floor 0, ceiling the data max) — gh#1690. `vals` is the full set of
364
+ * values the unpinned bound would otherwise be derived from (a single
365
+ * series' values, or every series' values flattened for multi-series
366
+ * types). Feeds directly into `niceScale(...this.#yBounds(vals), N)`. */
367
+ #yBounds(vals) {
368
+ const lo = this.yMin != null && Number.isFinite(this.yMin) ? this.yMin : 0;
369
+ const hi = this.yMax != null && Number.isFinite(this.yMax) ? this.yMax : Math.max(...vals);
370
+ return [lo, hi];
371
+ }
372
+
373
+ /** gh#1693 review — a datum outside a PINNED [yMin, yMax] domain must
374
+ * not escape the plot box: chart-ui's SVG has `overflow: visible`, so
375
+ * an unclamped bar/point renders off-canvas and bursts into
376
+ * surrounding page content (e.g. y-max="1", value 1.5 resolves to a
377
+ * negative y-coordinate above the chart). Clamps a computed pixel
378
+ * SPAN (bar/segment height or width) to `[0, max]` — used for bar,
379
+ * stacked-bar, grouped-bar, and composed's bar series. The displayed
380
+ * value LABEL and tooltip always show the real (unclamped) value —
381
+ * only the render geometry is clamped. */
382
+ #clampSpan(span, max) {
383
+ return Math.min(Math.max(span, 0), max);
384
+ }
385
+
386
+ /** Companion to #clampSpan for point-plotted series (line, area,
387
+ * scatter, multi-line, composed's line series) — clamps a computed
388
+ * pixel COORDINATE to the plot's own [lo, hi] pixel range so an
389
+ * out-of-domain point renders pinned to the nearest plot edge instead
390
+ * of escaping the viewBox. */
391
+ #clampCoord(coord, lo, hi) {
392
+ return Math.min(Math.max(coord, lo), hi);
393
+ }
394
+
304
395
  set data(arr) {
305
396
  // FEEDBACK-24: a non-array — typically the Chart.js `{labels, datasets}`
306
397
  // envelope — is silently coerced to [] below, producing a blank chart
@@ -353,12 +444,6 @@ export class UIChart extends UIElement {
353
444
  }
354
445
 
355
446
  connected() {
356
- // ADR-0063 (gh#1563) compat shims — resolve BEFORE the first render so
357
- // there's no wrong-then-right flash; see #resolveNoAttr() above.
358
- this.#resolveNoAttr('hide-average', 'no-average', 'noAverage', 'hideAverage');
359
- this.#resolveNoAttr('hide-grid', 'no-grid', 'noGrid', 'hideGrid');
360
- this.#resolveNoAttr('hide-values', 'no-values', 'noValues', 'hideValues');
361
-
362
447
  if (!this.hasAttribute('role')) this.setAttribute('role', 'img');
363
448
  if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', `${this.type} chart`);
364
449
 
@@ -458,9 +543,16 @@ export class UIChart extends UIElement {
458
543
  // use it. Otherwise derive from aspect ratio. Safe because resize sets
459
544
  // overflow:auto which prevents content from pushing height.
460
545
  const hasExplicitHeight = this.hasAttribute('resize') || this.style.height;
461
- let height = hasExplicitHeight && containerH > 40
462
- ? containerH
463
- : Math.round(width / aspect.ratio);
546
+ // ADR-0074 an explicit `ratio` PINS the box's own aspect, overriding
547
+ // whatever height the container/explicit-height path would otherwise
548
+ // give (REQ-R-002: "regardless of the container's actual aspect
549
+ // ratio"). Unset falls through to the pre-existing behavior unchanged.
550
+ const pinnedRatio = CHART_RATIOS[this.ratio];
551
+ let height = pinnedRatio
552
+ ? Math.round(width / pinnedRatio)
553
+ : hasExplicitHeight && containerH > 40
554
+ ? containerH
555
+ : Math.round(width / aspect.ratio);
464
556
 
465
557
  // Cap against --chart-max-height (CSS-resolved to px by getComputedStyle).
466
558
  // Keeps wide-viewport renders from producing absurdly tall charts while
@@ -469,6 +561,25 @@ export class UIChart extends UIElement {
469
561
  const maxH = parseFloat(getComputedStyle(this).maxHeight);
470
562
  if (isFinite(maxH) && maxH > 0 && height > maxH) height = Math.round(maxH);
471
563
 
564
+ // ADR-0074 auto-snap classification (gh#1624 plan step 1 — grammar +
565
+ // snap plumbing only, no per-type rendering keyed off this bucket yet).
566
+ // A pinned `ratio` reports its own bucket regardless of what the box's
567
+ // ACTUAL post-cap aspect ended up being (the pin is a label, per
568
+ // REQ-R-002, not merely a suggestion the cap can override); otherwise
569
+ // classify the chart's own live rendered aspect against the two
570
+ // midpoint boundaries — nearest bucket wins, never interpolated.
571
+ const liveAspect = width / Math.max(height, 1);
572
+ const resolvedRatio = pinnedRatio
573
+ ? this.ratio
574
+ : liveAspect >= RATIO_SNAP.wide
575
+ ? '3:2'
576
+ : liveAspect <= RATIO_SNAP.tall
577
+ ? '2:3'
578
+ : '1:1';
579
+ if (this.getAttribute('data-ratio-resolved') !== resolvedRatio) {
580
+ this.setAttribute('data-ratio-resolved', resolvedRatio);
581
+ }
582
+
472
583
  // Scale-aware font sizes: consistent regardless of chart dimensions
473
584
  // Use sm/md/lg or auto-detect from smallest dimension
474
585
  const minDim = Math.min(width, height);
@@ -482,12 +593,18 @@ export class UIChart extends UIElement {
482
593
  // out the Y-axis label gutter entirely — otherwise the plot area
483
594
  // collapses at small sizes.
484
595
  const noGrid = this.noGrid;
485
- const yLabelW = noGrid ? 0 : fontSize * 3.2;
596
+ // REQ-R-004 (Chart 2.0 ratio-and-surface SPEC) — at 1:1 and 2:3 the
597
+ // internal chart margins shrink further because those buckets have the
598
+ // least WIDTH budget to spare; 3:2 keeps the full gutter since width is
599
+ // abundant there. A flat 0.8 trim on the y-label gutter is enough to
600
+ // free real plot width without crowding the tick text against the axis.
601
+ const marginTrim = resolvedRatio === '3:2' ? 1 : 0.8;
602
+ const yLabelW = noGrid ? 0 : fontSize * 3.2 * marginTrim;
486
603
  const hasXLabels = !!this.x;
487
604
  const pad = {
488
605
  top: noGrid ? 2 : fontSize * 1.6,
489
606
  right: noGrid ? 2 : fontSize * 1.2,
490
- bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2,
607
+ bottom: !hasXLabels || noGrid ? 2 : fontSize * 2.2 * marginTrim,
491
608
  left: yLabelW,
492
609
  };
493
610
 
@@ -495,7 +612,7 @@ export class UIChart extends UIElement {
495
612
  const plotW = width - pad.left - pad.right;
496
613
  const barMinW = Math.max(4, plotW / n * 0.6);
497
614
 
498
- return { width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass };
615
+ return { width, height, pad, fontSize, labelSize, valueSize, barMinW, plotW, n, sizeClass, ratioResolved: resolvedRatio };
499
616
  }
500
617
 
501
618
  /* ── Main render ──────────────────────────────────────────────── */
@@ -1065,15 +1182,49 @@ export class UIChart extends UIElement {
1065
1182
 
1066
1183
  /* ── Grid + axes helper ───────────────────────────────────────── */
1067
1184
 
1068
- #gridAndAxes(width, height, ticks, labels, pad, dims) {
1185
+ /**
1186
+ * opts (REQ-R-003 ratio-matrix hooks, all optional — every existing
1187
+ * caller that passes no opts renders byte-identically to pre-Wave-A):
1188
+ * thinGrid — reduce Y-gridline/tick count the same way the sm
1189
+ * sizeClass already does (line/area at 1:1/2:3).
1190
+ * vertical — draw gridlines as vertical rules at the X-label
1191
+ * stride instead of horizontal rules at each Y tick
1192
+ * (area, 2:3 — "gridlines vertical-only").
1193
+ * xTickMode:'sparse' — force exactly first/mid/last X labels instead of
1194
+ * the width-derived stride (line, 2:3 — "sparse
1195
+ * (start/end + one mid)").
1196
+ * xTickMode:'everyNth' — a distinct DENSITY-based fixed index stride
1197
+ * (label[0], label[N], label[2N], …, always the last)
1198
+ * rather than sparse's fixed 3-label set or 3:2's
1199
+ * pixel-width-fitted stride (line, 1:1 — "fewer
1200
+ * x-axis ticks (every Nth)", REQ-R-003). N scales
1201
+ * with the data length (targets ~4-5 visible labels)
1202
+ * so a short and a long series both read as "every
1203
+ * Nth", not a fixed magic number.
1204
+ * abbreviateLabels — truncate X labels to a fixed char budget (bar, 1:1).
1205
+ * pointAligned — position gridlines/labels at the exact data-point
1206
+ * X coordinate (`plotW / (labels.length - 1) * i`,
1207
+ * no half-step offset) instead of the default
1208
+ * category-BAND center (`plotW / labels.length * i
1209
+ * + step/2`). `#renderLine`'s own points use the
1210
+ * point formula (a line/area plot has no "band",
1211
+ * just N-1 gaps between N points) — every caller
1212
+ * that draws marks at continuous point positions
1213
+ * passes this so its gridlines/labels land exactly
1214
+ * under those marks; bar/scatter/segments etc. keep
1215
+ * the band-centered default, correct for their own
1216
+ * categorical geometry.
1217
+ */
1218
+ #gridAndAxes(width, height, ticks, labels, pad, dims, opts = {}) {
1069
1219
  const p = pad;
1070
1220
  const fs = dims?.fontSize || 10;
1071
1221
  const ls = dims?.labelSize || fs;
1072
1222
  let s = '';
1073
1223
 
1074
- // Reduce tick count at small sizes
1224
+ // Reduce tick count at small sizes, or when a ratio-matrix caller asks
1225
+ // for denser-bucket thinning (REQ-R-003: line/area "gridlines thinned").
1075
1226
  let displayTicks = ticks;
1076
- if (dims?.sizeClass === 'sm' && ticks.length > 4) {
1227
+ if ((dims?.sizeClass === 'sm' || opts.thinGrid) && ticks.length > 4) {
1077
1228
  displayTicks = ticks.filter((_, i) => i % 2 === 0 || i === ticks.length - 1);
1078
1229
  }
1079
1230
 
@@ -1084,9 +1235,35 @@ export class UIChart extends UIElement {
1084
1235
  if (!this.noGrid) {
1085
1236
  const tickRange = ticks[ticks.length - 1] - ticks[0];
1086
1237
  const safeRange = tickRange || 1; // Prevent division by zero
1087
- for (const t of displayTicks) {
1088
- const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
1089
- s += `<line data-chart-grid x1="${p.left}" y1="${gy}" x2="${width - p.right}" y2="${gy}"/>`;
1238
+
1239
+ if (opts.vertical) {
1240
+ /* REQ-R-003 (area, 2:3 "tall"): a horizontal rule per Y-tick reads
1241
+ as clutter once the plot is this narrow — a vertical rule per
1242
+ visible X-label instead emphasizes the tall silhouette, still
1243
+ anchored to real category geometry rather than a decorative
1244
+ substitute. */
1245
+ if (labels && labels.length) {
1246
+ const plotW = width - p.left - p.right;
1247
+ const last = labels.length - 1;
1248
+ // Point-aligned: matches #renderLine's own `plotW / (n-1) * i`
1249
+ // exactly, so a vertical rule lands ON the data point it marks
1250
+ // rather than at an unrelated category-band center (CodeRabbit
1251
+ // #1658 finding — verified: the pre-fix band-center formula and
1252
+ // #renderLine's point formula are two different coordinate
1253
+ // systems whenever labels.length > 1).
1254
+ const step = opts.pointAligned ? plotW / Math.max(last, 1) : plotW / labels.length;
1255
+ const stride = Math.max(1, Math.round(labels.length / 3));
1256
+ for (let i = 0; i < labels.length; i++) {
1257
+ if (i % stride !== 0 && i !== last) continue;
1258
+ const gx = opts.pointAligned ? p.left + step * i : p.left + step * i + step / 2;
1259
+ s += `<line data-chart-grid x1="${gx}" y1="${p.top}" x2="${gx}" y2="${height - p.bottom}"/>`;
1260
+ }
1261
+ }
1262
+ } else {
1263
+ for (const t of displayTicks) {
1264
+ const gy = p.top + (height - p.top - p.bottom) * (1 - (t - ticks[0]) / safeRange);
1265
+ s += `<line data-chart-grid x1="${p.left}" y1="${gy}" x2="${width - p.right}" y2="${gy}"/>`;
1266
+ }
1090
1267
  }
1091
1268
 
1092
1269
  /* Y-axis labels */
@@ -1095,19 +1272,46 @@ export class UIChart extends UIElement {
1095
1272
  s += `<text data-y-label x="${p.left - 4}" y="${gy + fs * 0.35}" text-anchor="end" font-size="${fs}">${this.#fmtValue(t)}</text>`;
1096
1273
  }
1097
1274
 
1098
- /* X-axis labels — stride based on label width so they never overlap */
1275
+ /* X-axis labels — stride based on label width so they never overlap,
1276
+ unless a ratio-matrix mode forces a different selection. */
1099
1277
  if (labels) {
1100
1278
  const plotW = width - p.left - p.right;
1101
1279
  const step = plotW / labels.length;
1102
- const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1103
- const labelPx = maxChars * ls * 0.6 + ls * 0.75;
1104
- const stride = Math.max(1, Math.ceil(labelPx / step));
1105
1280
  const last = labels.length - 1;
1106
- for (let i = 0; i < labels.length; i++) {
1107
- if (i % stride !== 0 && i !== last) continue;
1108
- if (i !== last && last - i < stride) continue;
1109
- const lx = p.left + step * i + step / 2;
1110
- s += `<text data-x-label x="${lx}" y="${height - fs * 0.5}" text-anchor="middle" font-size="${ls}">${esc(labels[i])}</text>`;
1281
+ const indices = new Set();
1282
+
1283
+ if (opts.xTickMode === 'sparse' && last > 1) {
1284
+ indices.add(0);
1285
+ indices.add(Math.round(last / 2));
1286
+ indices.add(last);
1287
+ } else if (opts.xTickMode === 'everyNth' && last > 1) {
1288
+ // Every-Nth fixed index stride — mechanically distinct from BOTH
1289
+ // sparse's fixed 3-label set (start/end/mid only) and 3:2's
1290
+ // pixel-width-fitted stride below. N targets ~4-5 visible labels
1291
+ // regardless of series length.
1292
+ const n = Math.max(2, Math.ceil(labels.length / 4));
1293
+ for (let i = 0; i <= last; i += n) indices.add(i);
1294
+ indices.add(last);
1295
+ } else {
1296
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1297
+ const labelPx = maxChars * ls * 0.6 + ls * 0.75;
1298
+ const stride = Math.max(1, Math.ceil(labelPx / step));
1299
+ for (let i = 0; i < labels.length; i++) {
1300
+ if (i % stride !== 0 && i !== last) continue;
1301
+ if (i !== last && last - i < stride) continue;
1302
+ indices.add(i);
1303
+ }
1304
+ }
1305
+
1306
+ // Point-aligned mode (line/area): label X matches the exact data
1307
+ // point coordinate, same fix as the vertical-gridline branch above
1308
+ // — `step` here stays the band-width used for the width-fit stride
1309
+ // heuristic; `pointStep`/`lx` are the actual rendered position.
1310
+ const pointStep = plotW / Math.max(last, 1);
1311
+ for (const i of indices) {
1312
+ const lx = opts.pointAligned ? p.left + pointStep * i : p.left + step * i + step / 2;
1313
+ const text = esc(opts.abbreviateLabels ? truncateLabel(labels[i], 4) : labels[i]);
1314
+ s += `<text data-x-label x="${lx}" y="${height - fs * 0.5}" text-anchor="middle" font-size="${ls}">${text}</text>`;
1111
1315
  }
1112
1316
  }
1113
1317
  }
@@ -1121,27 +1325,57 @@ export class UIChart extends UIElement {
1121
1325
  const dims = this.#dims();
1122
1326
  const data = this.#data;
1123
1327
  const yKey = this.#yKeys()[0] || this.y;
1124
- const vals = data.map(v => +(v[yKey] ?? 0));
1125
- const labels = data.map(v => v[this.x] ?? '');
1126
- const ticks = niceScale(0, Math.max(...vals), 5);
1328
+ let vals = data.map(v => +(v[yKey] ?? 0));
1329
+ let labels = data.map(v => v[this.x] ?? '');
1330
+ // Kept for the average-line calc below — the average is a property of
1331
+ // the underlying data, not the display aggregation, so it stays
1332
+ // unaffected by the "+N more" tail bucket.
1333
+ const origVals = vals;
1334
+
1335
+ // REQ-R-003 (bar, 1:1) — Wave A review carry-forward: "fewer visible
1336
+ // bars before scroll/aggregate" (see the `aggregateTail` helper above
1337
+ // for the scroll-vs-aggregate reading). Runs before `ticks` is derived
1338
+ // so the scale reflects the (possibly aggregated) rendered set, not
1339
+ // the full original one.
1340
+ if (dims.ratioResolved === '1:1' && vals.length > BAR_1_1_VISIBLE_CAP) {
1341
+ ({ labels, vals } = aggregateTail(labels, vals, BAR_1_1_VISIBLE_CAP, false));
1342
+ }
1343
+
1344
+ const ticks = niceScale(...this.#yBounds(vals), 5);
1127
1345
  const maxVal = ticks[ticks.length - 1];
1346
+ const minVal = ticks[0];
1347
+
1348
+ // REQ-R-003 (bar, 2:3 "tall"): "Bars run horizontal (category on Y),
1349
+ // legend below, best for long category lists" — a tall, narrow box has
1350
+ // little width budget for a vertical bar's category labels underneath
1351
+ // it, but a full column of height for one row per category. This is a
1352
+ // genuine orientation swap, not the same layout rescaled.
1353
+ if (dims.ratioResolved === '2:3') {
1354
+ return this.#renderBarHorizontal(dims, labels, vals, ticks, maxVal);
1355
+ }
1128
1356
 
1129
1357
  const { width, height, pad } = dims;
1130
1358
  const plotH = height - pad.top - pad.bottom;
1131
1359
  const plotW = width - pad.left - pad.right;
1132
- const barW = plotW / data.length;
1360
+ const barW = plotW / labels.length;
1133
1361
  const barInner = barW * 0.6;
1134
1362
  const barGap = (barW - barInner) / 2;
1135
1363
 
1136
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1364
+ // REQ-R-003 (bar, 1:1 "square"): abbreviate category labels a square
1365
+ // has less width per category than 3:2. The synthetic "+N more" label
1366
+ // (already short) truncates harmlessly under the same budget.
1367
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1368
+ abbreviateLabels: dims.ratioResolved === '1:1',
1369
+ });
1137
1370
 
1138
1371
  // At sm auto-hide value labels (they overlap at narrow widths).
1139
1372
  const showValues = !this.noValues && dims.sizeClass !== 'sm';
1140
- const showAverage = !this.noAverage && vals.length > 1 && dims.sizeClass !== 'sm' && !this.noGrid;
1373
+ const showAverage = !this.noAverage && origVals.length > 1 && dims.sizeClass !== 'sm' && !this.noGrid;
1141
1374
 
1142
- for (let i = 0; i < data.length; i++) {
1375
+ for (let i = 0; i < labels.length; i++) {
1143
1376
  const v = vals[i];
1144
- const barH = maxVal ? (v / maxVal) * plotH : 0;
1377
+ const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
1378
+ const barH = this.#clampSpan(rawH, plotH);
1145
1379
  const bx = pad.left + barW * i + barGap;
1146
1380
  const by = pad.top + plotH - barH;
1147
1381
 
@@ -1153,8 +1387,9 @@ export class UIChart extends UIElement {
1153
1387
  }
1154
1388
 
1155
1389
  if (showAverage) {
1156
- const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
1157
- const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
1390
+ const avg = origVals.reduce((a, b) => a + b, 0) / origVals.length;
1391
+ const rawAvgH = (maxVal - minVal) ? ((avg - minVal) / (maxVal - minVal)) * plotH : 0;
1392
+ const ay = pad.top + plotH - this.#clampSpan(rawAvgH, plotH);
1158
1393
  svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
1159
1394
  svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
1160
1395
  /* Wider invisible hit target so the thin dashed line is hoverable */
@@ -1164,6 +1399,71 @@ export class UIChart extends UIElement {
1164
1399
  return { svg, viewBox: `0 0 ${width} ${height}` };
1165
1400
  }
1166
1401
 
1402
+ /* REQ-R-003 (bar, 2:3) — horizontal orientation: category axis on the
1403
+ LEFT (Y), value axis along the BOTTOM (X). Deliberately a separate
1404
+ render path rather than a transposed reuse of the vertical one — the
1405
+ gutter sizing (driven by label WIDTH, not a fixed font multiple), the
1406
+ grid direction, and the bar path rounding (right end, not top end) all
1407
+ differ structurally, not just by a coordinate swap. */
1408
+ #renderBarHorizontal(dims, labels, vals, ticks, maxVal) {
1409
+ const { width, height } = dims;
1410
+ const fs = dims.fontSize;
1411
+ const ls = dims.labelSize;
1412
+ const noGrid = this.noGrid;
1413
+
1414
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1415
+ const leftGutter = noGrid ? 2 : Math.min(width * 0.42, Math.max(fs * 3, maxChars * ls * 0.55 + ls));
1416
+ const pad = {
1417
+ top: fs * 0.8,
1418
+ right: fs * 1.8,
1419
+ bottom: noGrid ? 2 : fs * 1.8,
1420
+ left: leftGutter,
1421
+ };
1422
+ const plotW = width - pad.left - pad.right;
1423
+ const plotH = height - pad.top - pad.bottom;
1424
+ const barH = plotH / labels.length;
1425
+ const barInner = barH * 0.6;
1426
+ const barGap = (barH - barInner) / 2;
1427
+
1428
+ let svg = '';
1429
+ if (!noGrid) {
1430
+ const tickRange = (ticks[ticks.length - 1] - ticks[0]) || 1;
1431
+ for (const t of ticks) {
1432
+ const gx = pad.left + plotW * ((t - ticks[0]) / tickRange);
1433
+ svg += `<line data-chart-grid x1="${gx}" y1="${pad.top}" x2="${gx}" y2="${height - pad.bottom}"/>`;
1434
+ svg += `<text data-y-label x="${gx}" y="${height - pad.bottom + fs * 1.3}" text-anchor="middle" font-size="${fs}">${this.#fmtValue(t)}</text>`;
1435
+ }
1436
+ }
1437
+
1438
+ const showValues = !this.noValues;
1439
+ const showAverage = !this.noAverage && vals.length > 1 && !noGrid;
1440
+
1441
+ const minVal = ticks[0];
1442
+ for (let i = 0; i < labels.length; i++) {
1443
+ const v = vals[i];
1444
+ const rawW = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotW : 0;
1445
+ const barW = this.#clampSpan(rawW, plotW);
1446
+ const by = pad.top + barH * i + barGap;
1447
+ const bx = pad.left;
1448
+
1449
+ svg += `<text data-x-label x="${pad.left - 6}" y="${by + barInner / 2 + fs * 0.35}" text-anchor="end" font-size="${ls}">${esc(labels[i])}</text>`;
1450
+ svg += `<path data-bar${tip({ label: labels[i], value: v })} d="${rightRoundedBarPath(bx, by, barW, barInner, this.#resolveRadius())}"/>`;
1451
+ if (showValues) {
1452
+ svg += `<text data-value x="${bx + barW + 4}" y="${by + barInner / 2 + fs * 0.35}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
1453
+ }
1454
+ }
1455
+
1456
+ if (showAverage) {
1457
+ const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
1458
+ const rawAvgW = (maxVal - minVal) ? ((avg - minVal) / (maxVal - minVal)) * plotW : 0;
1459
+ const ax = pad.left + this.#clampSpan(rawAvgW, plotW);
1460
+ svg += `<line data-avg x1="${ax}" y1="${pad.top}" x2="${ax}" y2="${height - pad.bottom}"/>`;
1461
+ svg += `<line data-hit${tip({ label: 'Average', value: avg })} x1="${ax}" y1="${pad.top}" x2="${ax}" y2="${height - pad.bottom}" stroke="transparent" stroke-width="12"/>`;
1462
+ }
1463
+
1464
+ return { svg, viewBox: `0 0 ${width} ${height}` };
1465
+ }
1466
+
1167
1467
  /* ── Line chart ───────────────────────────────────────────────── */
1168
1468
 
1169
1469
  #renderLine() {
@@ -1172,19 +1472,41 @@ export class UIChart extends UIElement {
1172
1472
  const yKey = this.#yKeys()[0] || this.y;
1173
1473
  const vals = data.map(v => +(v[yKey] ?? 0));
1174
1474
  const labels = data.map(v => v[this.x] ?? '');
1175
- const ticks = niceScale(0, Math.max(...vals), 5);
1475
+ const ticks = niceScale(...this.#yBounds(vals), 5);
1176
1476
  const maxVal = ticks[ticks.length - 1];
1477
+ const minVal = ticks[0];
1177
1478
 
1178
1479
  const { width, height, pad } = dims;
1179
1480
  const plotH = height - pad.top - pad.bottom;
1180
1481
  const plotW = width - pad.left - pad.right;
1181
1482
  const step = plotW / Math.max(data.length - 1, 1);
1182
1483
 
1183
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1484
+ // REQ-R-003 (line/area): 3:2 stays dense (full width-derived stride);
1485
+ // 1:1/2:3 thin the Y-gridlines ("gridlines thinned"). AREA specifically
1486
+ // at 2:3 switches to vertical-only gridlines ("tall... gridlines
1487
+ // vertical-only" — LINE's own 2:3 cell keeps thinned horizontal rules,
1488
+ // this is deliberately an area-only delta since #renderArea shares this
1489
+ // method with `this.type` still reading 'area'). X-ticks: 1:1 goes
1490
+ // every-Nth ("fewer x-axis ticks (every Nth)"), 2:3 goes sparse
1491
+ // (start/end + one mid, "emphasis on trend silhouette over tick
1492
+ // density") — two DISTINCT treatments, not one mode reused for both.
1493
+ const isArea = this.type === 'area';
1494
+ const ratio = dims.ratioResolved;
1495
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1496
+ thinGrid: ratio !== '3:2',
1497
+ vertical: isArea && ratio === '2:3',
1498
+ xTickMode: ratio === '2:3' ? 'sparse' : ratio === '1:1' ? 'everyNth' : undefined,
1499
+ // CodeRabbit #1658 finding — a line/area plot has no category BAND,
1500
+ // just N-1 gaps between N continuous points (`step` below, matching
1501
+ // this same `plotW / (n-1)` formula); every gridline/label this
1502
+ // method draws must land at that exact X, not a band center.
1503
+ pointAligned: true,
1504
+ });
1184
1505
 
1185
1506
  const points = vals.map((v, i) => {
1186
1507
  const px = pad.left + step * i;
1187
- const py = pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0);
1508
+ const rawY = pad.top + plotH - ((maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0);
1509
+ const py = this.#clampCoord(rawY, pad.top, pad.top + plotH);
1188
1510
  return { x: px, y: py, v, label: labels[i] };
1189
1511
  });
1190
1512
 
@@ -1212,7 +1534,8 @@ export class UIChart extends UIElement {
1212
1534
 
1213
1535
  if (showAverage) {
1214
1536
  const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
1215
- const ay = pad.top + plotH - (maxVal ? (avg / maxVal) * plotH : 0);
1537
+ const rawAy = pad.top + plotH - ((maxVal - minVal) ? ((avg - minVal) / (maxVal - minVal)) * plotH : 0);
1538
+ const ay = this.#clampCoord(rawAy, pad.top, pad.top + plotH);
1216
1539
  svg += `<line data-avg x1="${pad.left}" y1="${ay}" x2="${width - pad.right}" y2="${ay}"/>`;
1217
1540
  svg += `<text data-avg-label x="${width - pad.right + 2}" y="${ay + 3}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(avg)}</text>`;
1218
1541
  /* Wider invisible hit target so the thin dashed line is hoverable */
@@ -1234,10 +1557,64 @@ export class UIChart extends UIElement {
1234
1557
  // Responsive viewBox so pie respects chart-ui's max-height and
1235
1558
  // doesn't push the legend past the container bounds.
1236
1559
  const dims = this.#dims();
1237
- const { width, height } = dims;
1238
- const cx = width / 2;
1239
- const cy = height / 2;
1240
- const r = Math.max(30, Math.min(width, height) * 0.42);
1560
+ const { width, height, fontSize, valueSize } = dims;
1561
+ const ratio = dims.ratioResolved;
1562
+ // REQ-R-003 (pie, 1:1): "legend as a ring of labels around the pie" —
1563
+ // the ring needs real radial room OUTSIDE the pie's own circumference,
1564
+ // so the pie itself shrinks a bit (mirrors #renderRadar's own
1565
+ // labelPad-then-shrink technique for its external spoke labels) rather
1566
+ // than the ring overflowing the viewBox. 3:2/2:3 keep the original
1567
+ // radius unchanged — no ring at those ratios.
1568
+ //
1569
+ // CodeRabbit #1658 finding (verified real, not a false positive): a
1570
+ // flat 4px margin only reserves room for the label's ANCHOR point, not
1571
+ // its rendered extent — a near-top/bottom label's glyph height clips
1572
+ // the viewBox edge, and a near-left/right label (text-anchor start/
1573
+ // end) has its FULL RENDERED WIDTH growing outward from the anchor,
1574
+ // straight past the box edge, in any normally-sized square container,
1575
+ // not just a pathological zero-margin one. Fixed by reserving a real
1576
+ // width/height gutter.
1577
+ //
1578
+ // Wave A review carry-forward (2026-08-18): this comment used to claim
1579
+ // the gutter was "sized from the actual longest label string in THIS
1580
+ // dataset" — it wasn't. `catBudgetCeiling` below is a pure
1581
+ // WIDTH-derived safety ceiling (solved from `width`/`minR` alone, never
1582
+ // reading `labels`), so every dataset got the same worst-case
1583
+ // gutter/pie-shrink regardless of how short its real labels were —
1584
+ // correct (overflow was still mathematically excluded) but needlessly
1585
+ // conservative. Fixed for real this wave: `catBudget` now also
1586
+ // measures the dataset's OWN longest label and takes whichever is
1587
+ // smaller — a short-label dataset gets a bigger pie (a tighter gutter
1588
+ // than the ceiling alone would allow); a long-label one still clamps
1589
+ // at the same safe ceiling as before.
1590
+ const ringGap = fontSize;
1591
+ const pctSuffixLen = 8; // " (100.0%)" — worst-case suffix, generous
1592
+ const minR = 20;
1593
+ const fitHGutter = (catBudget) => (catBudget + pctSuffixLen) * valueSize * 0.62 + ringGap + 4;
1594
+ const vGutter = valueSize * 1.2 + ringGap + 4;
1595
+ // Safety ceiling: the largest category-label budget that still leaves
1596
+ // >= minR of pie radius on the width axis (the vertical gutter is
1597
+ // independent of label text length, so it never needs solving) — a
1598
+ // WIDTH-derived cap, not yet dataset-aware.
1599
+ const maxHGutterAtMinR = Math.max(0, width / 2 - minR);
1600
+ const catBudgetCeiling = Math.floor(
1601
+ (maxHGutterAtMinR - ringGap - 4) / (valueSize * 0.62)
1602
+ ) - pctSuffixLen;
1603
+ // The dataset's own longest label — the measurement the comment above
1604
+ // used to claim but never actually performed.
1605
+ const longestLabelLen = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
1606
+ const catBudget = Math.max(4, Math.min(14, catBudgetCeiling, longestLabelLen));
1607
+ const hGutter = fitHGutter(catBudget);
1608
+ const r = ratio === '1:1'
1609
+ ? Math.max(minR, Math.min(width / 2 - hGutter, height / 2 - vGutter))
1610
+ : Math.max(30, Math.min(width, height) * 0.42);
1611
+ // REQ-R-003 (pie): 3:2 "slices left" — shift the pie toward the left
1612
+ // third, reserving right-side room; 2:3 "slices top" — shift up,
1613
+ // reserving room below; 1:1 stays centered — its own delta is the ring
1614
+ // of labels, not a center shift.
1615
+ const cx = ratio === '3:2' ? Math.max(r + 8, width * 0.32) : width / 2;
1616
+ const cy = ratio === '2:3' ? Math.min(height / 2, r + fontSize * 1.5) : height / 2;
1617
+ const ringLabels = ratio === '1:1';
1241
1618
 
1242
1619
  let svg = '';
1243
1620
  let angle = -Math.PI / 2;
@@ -1256,6 +1633,24 @@ export class UIChart extends UIElement {
1256
1633
  } else {
1257
1634
  svg += `<path${attrs} d="${arcPath(cx, cy, r, angle, end)}"/>`;
1258
1635
  }
1636
+
1637
+ if (ringLabels) {
1638
+ // REQ-R-003 (pie, 1:1) — one label per slice, positioned OUTSIDE
1639
+ // the ring at its mid-angle, anchored away from the circle so the
1640
+ // text grows outward rather than crowding the arc (same anchor
1641
+ // convention #renderRadar uses for its own external labels).
1642
+ const mid = angle + slice / 2;
1643
+ const lx = polarX(cx, r + ringGap, mid);
1644
+ const ly = polarY(cy, r + ringGap, mid);
1645
+ const cosMid = Math.cos(mid);
1646
+ const anchor = Math.abs(cosMid) < 0.15 ? 'middle' : cosMid > 0 ? 'start' : 'end';
1647
+ // Truncate to the budget the radius fit above was solved against —
1648
+ // keeps the rendered text within the reserved hGutter regardless
1649
+ // of how long the real category name is.
1650
+ const ringText = `${esc(truncateLabel(labels[i], catBudget))} (${pct}%)`;
1651
+ svg += `<text data-ring-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${valueSize}">${ringText}</text>`;
1652
+ }
1653
+
1259
1654
  angle = end;
1260
1655
  }
1261
1656
 
@@ -1279,11 +1674,16 @@ export class UIChart extends UIElement {
1279
1674
  const labels = data.map(d => d[this.x] ?? '');
1280
1675
 
1281
1676
  const dims = this.#dims();
1282
- const { width, height } = dims;
1283
- const cx = width / 2;
1284
- const cy = height / 2;
1285
- const outer = Math.max(30, Math.min(width, height) * 0.42);
1677
+ const { width, height, fontSize } = dims;
1678
+ const ratio = dims.ratioResolved;
1679
+ // REQ-R-003 (donut): 1:1 "Donut centered and MAXIMIZED" — a square has
1680
+ // no side legend competing for radius, so the ring grows; 3:2 "left" /
1681
+ // 2:3 "top" mirror pie's own shift for the same reason (room reserved
1682
+ // for the center-stat + legend that follows in DOM order).
1683
+ const outer = Math.max(30, Math.min(width, height) * (ratio === '1:1' ? 0.46 : 0.42));
1286
1684
  const inner = outer * 0.72;
1685
+ const cx = ratio === '3:2' ? Math.max(outer + 8, width * 0.32) : width / 2;
1686
+ const cy = ratio === '2:3' ? Math.min(height / 2, outer + fontSize * 1.5) : height / 2;
1287
1687
 
1288
1688
  let svg = '';
1289
1689
  let angle = -Math.PI / 2;
@@ -1336,12 +1736,33 @@ export class UIChart extends UIElement {
1336
1736
  // font-size and stroke widths at their intended visual scale).
1337
1737
  const dims = this.#dims();
1338
1738
  const { width, height, fontSize } = dims;
1739
+ const ratio = dims.ratioResolved;
1740
+
1741
+ // REQ-R-003 (radar) — three deliberately distinct treatments (no
1742
+ // side/below legend widget is invented here: radar's data model is
1743
+ // ONE polygon over categorical axes, not per-category series, so
1744
+ // there's no discrete legend content to show beyond the axis labels
1745
+ // already naming every spoke — same scope note as Wave A's own
1746
+ // choice not to build literal side/below legend REPOSITIONING for
1747
+ // bar/line/pie/donut despite the matrix naming "legend right/below"
1748
+ // for those rows too; flagged in Findings):
1749
+ // 3:2 — "axis labels external": generous labelPad/labelGap, labels
1750
+ // float clearly outside the polygon.
1751
+ // 1:1 — "radar fills the square, axis labels on the spokes": a much
1752
+ // smaller pad/gap so the polygon maximizes into the square and
1753
+ // labels sit tight against each spoke's own line rather than
1754
+ // floating away from it.
1755
+ // 2:3 — "radar fills top 60%, axis labels abbreviated": the radar's
1756
+ // own vertical extent is capped to the top 60% of the box
1757
+ // (leaving the bottom for whatever a page composes below it —
1758
+ // e.g. a stat readout), and axis label TEXT truncates.
1759
+ const isSquareFill = ratio === '1:1';
1760
+ const isTopFill = ratio === '2:3';
1761
+ const labelPad = isSquareFill ? fontSize * 1.8 : fontSize * 3.5;
1762
+ const fillH = isTopFill ? height * 0.6 : height;
1339
1763
  const cx = width / 2;
1340
- const cy = height / 2;
1341
- // Leave label breathing room proportional to font size so labels
1342
- // never clip at wide sizes or crowd the polygon at small sizes.
1343
- const labelPad = fontSize * 3.5;
1344
- const r = Math.max(40, Math.min(width, height) / 2 - labelPad);
1764
+ const cy = isTopFill ? fillH / 2 : height / 2;
1765
+ const r = Math.max(40, Math.min(width, fillH) / 2 - labelPad);
1345
1766
 
1346
1767
  let svg = '';
1347
1768
  const angleStep = (Math.PI * 2) / n;
@@ -1389,14 +1810,20 @@ export class UIChart extends UIElement {
1389
1810
  svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1390
1811
  }
1391
1812
 
1392
- /* Labels */
1393
- const labelGap = fontSize * 1.3;
1813
+ /* Labels — REQ-R-003 (radar, 1:1): "axis labels on the spokes" reads
1814
+ as tight to the spoke line rather than floating in open space, so
1815
+ 1:1's own gap shrinks well below 3:2/2:3's external placement. */
1816
+ const labelGap = isSquareFill ? fontSize * 0.6 : fontSize * 1.3;
1394
1817
  for (let i = 0; i < n; i++) {
1395
1818
  const a = -Math.PI / 2 + angleStep * i;
1396
1819
  const lx = cx + (r + labelGap) * Math.cos(a);
1397
1820
  const ly = cy + (r + labelGap) * Math.sin(a);
1398
1821
  const anchor = Math.abs(Math.cos(a)) < 0.1 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end';
1399
- svg += `<text data-x-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${fontSize}">${esc(labels[i])}</text>`;
1822
+ // REQ-R-003 (radar, 2:3): "axis labels abbreviated" — the top-60%
1823
+ // fill leaves less lateral room per spoke than the full-height 3:2/
1824
+ // 1:1 treatments, so labels truncate the same way bar@1:1 does.
1825
+ const text = esc(isTopFill ? truncateLabel(labels[i], 5) : labels[i]);
1826
+ svg += `<text data-x-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${fontSize}">${text}</text>`;
1400
1827
  }
1401
1828
 
1402
1829
  return { svg, viewBox: `0 0 ${width} ${height}` };
@@ -1404,6 +1831,16 @@ export class UIChart extends UIElement {
1404
1831
 
1405
1832
  /* ── Sparkline ────────────────────────────────────────────────── */
1406
1833
 
1834
+ /* REQ-R-003 (sparkline) — the ONE documented no-delta exception: "Same
1835
+ stroke; documented exception carries forward" at all three ratios.
1836
+ Deliberate, not an oversight — sparkline is intrinsically ratio-
1837
+ agnostic (no axis, no legend, full-width single stroke); REQ-P-004's
1838
+ "at least one delta" is satisfied by the HOST stat-tile's own layout
1839
+ change around it, not by this renderer. Confirmed by this method never
1840
+ reading `this.ratio` / `dims.ratioResolved` at all — it sizes purely
1841
+ from `clientWidth`/`clientHeight`, so the same data renders the same
1842
+ stroke shape regardless of which ratio bucket the container resolves
1843
+ to (chart.test.js asserts this explicitly). */
1407
1844
  #renderSparkline() {
1408
1845
  const data = this.#data;
1409
1846
  const yKey = this.#yKeys()[0] || this.y;
@@ -1472,22 +1909,38 @@ export class UIChart extends UIElement {
1472
1909
  const yKey = this.#yKeys()[0] || this.y;
1473
1910
  const vals = data.map(v => +(v[yKey] ?? 0));
1474
1911
  const labels = data.map(v => v[this.x] ?? '');
1475
- const ticks = niceScale(0, Math.max(...vals), 5);
1912
+ const ratio = dims.ratioResolved;
1913
+ // REQ-R-003 (scatter, 2:3): "y-axis emphasized (more y-ticks than x)"
1914
+ // — a deliberately DENSER Y scale than the family default (5 ticks),
1915
+ // distinct from the X-tick stride (which #gridAndAxes' own width-fit
1916
+ // stride already thins as plotW narrows — no extra opt needed there).
1917
+ const tickCount = ratio === '2:3' ? 8 : 5;
1918
+ const ticks = niceScale(...this.#yBounds(vals), tickCount);
1476
1919
  const maxVal = ticks[ticks.length - 1];
1920
+ const minVal = ticks[0];
1477
1921
 
1478
1922
  const { width, height, pad } = dims;
1479
1923
  const plotH = height - pad.top - pad.bottom;
1480
1924
  const plotW = width - pad.left - pad.right;
1481
1925
  const step = plotW / Math.max(data.length - 1, 1);
1482
1926
 
1483
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1927
+ // REQ-R-003 (scatter, 1:1): "axis labels abbreviated" — same
1928
+ // truncation treatment as bar@1:1's own square-container cell.
1929
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
1930
+ abbreviateLabels: ratio === '1:1',
1931
+ });
1484
1932
 
1485
- const dotR = dims.sizeClass === 'sm' ? 2.5 : 4;
1933
+ // REQ-R-003 (scatter, 1:1): "point size scales down slightly for
1934
+ // density" — a square container packs the same category count into
1935
+ // less width than 3:2, so points shrink a touch to stay legible.
1936
+ const baseDotR = dims.sizeClass === 'sm' ? 2.5 : 4;
1937
+ const dotR = ratio === '1:1' ? baseDotR * 0.85 : baseDotR;
1486
1938
  const hitR = Math.max(dotR * 2, 10);
1487
1939
 
1488
1940
  for (let i = 0; i < vals.length; i++) {
1489
1941
  const px = pad.left + step * i;
1490
- const py = pad.top + plotH - (maxVal ? (vals[i] / maxVal) * plotH : 0);
1942
+ const rawY = pad.top + plotH - ((maxVal - minVal) ? ((vals[i] - minVal) / (maxVal - minVal)) * plotH : 0);
1943
+ const py = this.#clampCoord(rawY, pad.top, pad.top + plotH);
1491
1944
  svg += `<circle data-dot data-scatter cx="${px}" cy="${py}" r="${dotR}"/>`;
1492
1945
  svg += `<circle data-hit${tip({ label: labels[i], value: vals[i] })} cx="${px}" cy="${py}" r="${hitR}" fill="transparent"/>`;
1493
1946
  }
@@ -1510,7 +1963,8 @@ export class UIChart extends UIElement {
1510
1963
  const maxVal = Math.max(...vals) || 1;
1511
1964
 
1512
1965
  const dims = this.#dims();
1513
- const { width, height } = dims;
1966
+ const { width, height, fontSize, valueSize } = dims;
1967
+ const ratio = dims.ratioResolved;
1514
1968
  const cx = width / 2;
1515
1969
  const cy = height / 2;
1516
1970
  const outerR = Math.max(30, Math.min(width, height) * 0.45);
@@ -1519,13 +1973,32 @@ export class UIChart extends UIElement {
1519
1973
  const bandW = (outerR - innerR) / ringCount;
1520
1974
  const gap = Math.min(2, bandW * 0.15);
1521
1975
 
1976
+ // REQ-R-003 (radial-bar) — each ring's fill starts at a rotated point
1977
+ // (the pre-existing dasharray-rotation mechanism); WHERE that start
1978
+ // point sits per ring is this row's own per-ratio delta:
1979
+ // 3:2 "bars radiate right-biased" — every ring starts its fill at
1980
+ // 3 o'clock (angle 0) and sweeps clockwise, biasing the filled
1981
+ // arcs toward the right half of the circle.
1982
+ // 2:3 "bars radiate top-biased" — every ring starts at 12 o'clock
1983
+ // (angle -π/2, the pre-Wave-B default), biasing toward the top.
1984
+ // 1:1 "full radial fan" — rather than one shared start point, EACH
1985
+ // ring gets its OWN start angle, evenly staggered around the
1986
+ // full circle (a pinwheel/fan spread) — mechanically distinct
1987
+ // from the single shared bias the other two ratios use, not
1988
+ // just a relabeling of the same geometry.
1989
+ const isFan = ratio === '1:1';
1990
+ const uniformBiasAngle = ratio === '3:2' ? 0 : -Math.PI / 2;
1991
+ const ringStartAngle = (i) => isFan
1992
+ ? -Math.PI / 2 + (Math.PI * 2 * i) / ringCount
1993
+ : uniformBiasAngle;
1994
+
1522
1995
  let svg = '';
1523
1996
 
1524
1997
  /* Backing tracks + filled arcs per datum, from inner to outer.
1525
1998
  Uses the stroke-dasharray technique: each ring is a single <circle>,
1526
- rotated so the dash starts at 12 o'clock, with dasharray sized to
1527
- (filled, remainder). Avoids the "full circle" vs "arc path" branch
1528
- that produced rendering artifacts at 100% fills. */
1999
+ rotated so the dash starts at its own `ringStartAngle`, with
2000
+ dasharray sized to (filled, remainder). Avoids the "full circle" vs
2001
+ "arc path" branch that produced rendering artifacts at 100% fills. */
1529
2002
  for (let i = 0; i < vals.length; i++) {
1530
2003
  const r0 = innerR + bandW * i + gap / 2;
1531
2004
  const r1 = innerR + bandW * (i + 1) - gap / 2;
@@ -1533,6 +2006,13 @@ export class UIChart extends UIElement {
1533
2006
  const thickness = r1 - r0;
1534
2007
  const circumference = 2 * Math.PI * mid;
1535
2008
  const filled = Math.max(0, Math.min(1, vals[i] / maxVal)) * circumference;
2009
+ const startAngle = ringStartAngle(i);
2010
+ // A circle's own stroke-dasharray trace starts at local angle 0 (3
2011
+ // o'clock); rotate() by this many degrees moves that start point to
2012
+ // `startAngle` in the same cos/sin convention `polarX`/`polarY` use
2013
+ // elsewhere in this file (rotate(-90) ⇒ start at 12 o'clock, the
2014
+ // pre-Wave-B default — verified against the untouched 2:3 case below).
2015
+ const rotateDeg = (startAngle * 180) / Math.PI;
1536
2016
 
1537
2017
  /* Backing ring — full circle, faint stroke */
1538
2018
  svg += `<circle data-radial-track cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}"/>`;
@@ -1543,17 +2023,35 @@ export class UIChart extends UIElement {
1543
2023
  const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
1544
2024
 
1545
2025
  /* Filled arc — stroke-dasharray splits the circumference into
1546
- (drawn, gap). Rotated -90° around the center so the dash begins
1547
- at 12 o'clock and sweeps clockwise. stroke-linecap="butt" for
1548
- full rings (so the ends don't overlap into a wedge artifact);
1549
- "round" for partial arcs so ends read as bar caps. */
2026
+ (drawn, gap). Rotated around the center so the dash begins at
2027
+ this ring's own start angle and sweeps clockwise.
2028
+ stroke-linecap="butt" for full rings (so the ends don't overlap
2029
+ into a wedge artifact); "round" for partial arcs so ends read as
2030
+ bar caps. */
1550
2031
  const isFull = Math.abs(filled - circumference) < 0.5;
1551
2032
  const linecap = isFull ? 'butt' : 'round';
1552
2033
  const dashArray = isFull
1553
2034
  ? `${circumference} 0`
1554
2035
  : `${filled} ${circumference - filled}`;
1555
2036
 
1556
- svg += `<circle data-slice="${i % 10}"${tipAttrs} data-radial-bar cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}" stroke-linecap="${linecap}" stroke-dasharray="${dashArray}" transform="rotate(-90 ${cx} ${cy})"/>`;
2037
+ svg += `<circle data-slice="${i % 10}"${tipAttrs} data-radial-bar cx="${cx}" cy="${cy}" r="${mid}" fill="none" stroke-width="${thickness}" stroke-linecap="${linecap}" stroke-dasharray="${dashArray}" transform="rotate(${rotateDeg} ${cx} ${cy})"/>`;
2038
+ }
2039
+
2040
+ // REQ-R-003 (radial-bar, 1:1) — "legend as ring": one label per ring,
2041
+ // positioned outside outerR at that ring's own start angle — same
2042
+ // ring-of-labels mechanism #renderPie's own 1:1 cell ships, reused
2043
+ // rather than re-invented. The ordinary below-chart legend is
2044
+ // suppressed at 1:1 only (chart.css) since the ring now serves as it.
2045
+ if (isFan) {
2046
+ const ringLabelGap = fontSize;
2047
+ for (let i = 0; i < vals.length; i++) {
2048
+ const a = ringStartAngle(i);
2049
+ const lx = polarX(cx, outerR + ringLabelGap, a);
2050
+ const ly = polarY(cy, outerR + ringLabelGap, a);
2051
+ const cosA = Math.cos(a);
2052
+ const anchor = Math.abs(cosA) < 0.15 ? 'middle' : cosA > 0 ? 'start' : 'end';
2053
+ svg += `<text data-ring-label x="${lx}" y="${ly}" text-anchor="${anchor}" dominant-baseline="central" font-size="${valueSize}">${esc(labels[i])}</text>`;
2054
+ }
1557
2055
  }
1558
2056
 
1559
2057
  this.#legendData = data.map((d, i) => ({
@@ -1588,14 +2086,29 @@ export class UIChart extends UIElement {
1588
2086
  const pct = Math.max(0, Math.min(1, primary / maxVal));
1589
2087
 
1590
2088
  const dims = this.#dims();
1591
- const { width, height } = dims;
1592
-
1593
- /* Place the arc's visual center so the half-circle uses the full
1594
- width below the value label. cy sits lower so the half-arc has
1595
- room for the big center label above it. */
1596
- const cx = width / 2;
1597
- const cy = height * 0.68;
1598
- const outerR = Math.max(40, Math.min(width * 0.45, height * 0.6));
2089
+ const { width, height, fontSize } = dims;
2090
+ const ratio = dims.ratioResolved;
2091
+ const isWide = ratio === '3:2';
2092
+ const isTall = ratio === '2:3';
2093
+
2094
+ /* REQ-R-003 (gauge) three deliberately distinct placements (no
2095
+ side/below legend widget invented here either — same single-value
2096
+ rationale as radar/scatter above, since a gauge with one primary
2097
+ reading has no discrete per-category set to legend beyond the
2098
+ readout itself):
2099
+ 3:2 "gauge left, readout right" — cx shifts left (mirrors pie/
2100
+ donut's own 3:2 shift), the readout moves OUTSIDE to the
2101
+ right of the arc rather than sitting centered over it.
2102
+ 1:1 "gauge centered and maximized, readout inside the arc" — the
2103
+ biggest outerR of the three, readout stays centered in the
2104
+ arc's own hollow (the pre-Wave-B default position).
2105
+ 2:3 "gauge top, readout below" — cy shifts up (mirrors pie/
2106
+ donut's own 2:3 shift), the readout moves BELOW the arc's
2107
+ flat baseline instead of overlapping it. */
2108
+ const outerR = Math.max(40, Math.min(
2109
+ width * (ratio === '1:1' ? 0.48 : 0.45),
2110
+ height * (isTall ? 0.46 : 0.6),
2111
+ ));
1599
2112
  const innerR = outerR * 0.72;
1600
2113
  /* End-cap radius — fully-rounded pill ends matching the
1601
2114
  progress-ui convention (--progress-radius: var(--a-radius-full)).
@@ -1604,6 +2117,9 @@ export class UIChart extends UIElement {
1604
2117
  ends of the arc. */
1605
2118
  const capR = (outerR - innerR) / 2;
1606
2119
 
2120
+ const cx = isWide ? Math.max(outerR + 8, width * 0.34) : width / 2;
2121
+ const cy = isTall ? Math.min(height * 0.5, outerR + fontSize * 1.3) : height * 0.68;
2122
+
1607
2123
  let svg = '';
1608
2124
 
1609
2125
  /* Backing arc — full 180° upper semicircle, from 9 o'clock (angle π)
@@ -1620,13 +2136,27 @@ export class UIChart extends UIElement {
1620
2136
  svg += `<path data-slice="0"${tipAttrs} data-gauge-fill d="${donutArcPath(cx, cy, outerR, innerR, Math.PI, fillEnd, capR)}"/>`;
1621
2137
  }
1622
2138
 
1623
- /* Center value label */
2139
+ /* Readout label — position/anchor keyed off the ratio-specific layout
2140
+ decided above; font sizing stays keyed off outerR unconditionally. */
1624
2141
  const totalFs = Math.max(18, Math.round(outerR * 0.42));
1625
2142
  const labelFs = Math.max(10, Math.round(outerR * 0.2));
1626
- const labelY = cy - outerR * 0.15;
1627
- svg += `<text data-gauge-value x="${cx}" y="${labelY}" text-anchor="middle" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
2143
+ let labelX, labelY, anchor;
2144
+ if (isWide) {
2145
+ labelX = cx + outerR + fontSize * 1.2;
2146
+ labelY = cy - outerR * 0.15;
2147
+ anchor = 'start';
2148
+ } else if (isTall) {
2149
+ labelX = cx;
2150
+ labelY = cy + totalFs * 0.7;
2151
+ anchor = 'middle';
2152
+ } else {
2153
+ labelX = cx;
2154
+ labelY = cy - outerR * 0.15;
2155
+ anchor = 'middle';
2156
+ }
2157
+ svg += `<text data-gauge-value x="${labelX}" y="${labelY}" text-anchor="${anchor}" dominant-baseline="central" font-size="${totalFs}">${this.#fmtValue(primary)}</text>`;
1628
2158
  if (maxVal !== primary) {
1629
- svg += `<text data-gauge-max x="${cx}" y="${labelY + totalFs * 0.8}" text-anchor="middle" dominant-baseline="central" font-size="${labelFs}">of ${this.#fmtValue(maxVal)}</text>`;
2159
+ svg += `<text data-gauge-max x="${labelX}" y="${labelY + totalFs * 0.8}" text-anchor="${anchor}" dominant-baseline="central" font-size="${labelFs}">of ${this.#fmtValue(maxVal)}</text>`;
1630
2160
  }
1631
2161
 
1632
2162
  return { svg, viewBox: `0 0 ${width} ${height}` };
@@ -1655,16 +2185,79 @@ export class UIChart extends UIElement {
1655
2185
  const dims = this.#dims();
1656
2186
  const { width, height } = dims;
1657
2187
  const fs = dims.fontSize;
1658
- const padX = Math.max(width * 0.18, 80); /* room for stage labels */
2188
+ const ratio = dims.ratioResolved;
2189
+
2190
+ this.#legendData = data.map((d, i) => ({
2191
+ label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
2192
+ }));
2193
+
2194
+ let svg = '';
2195
+
2196
+ /* REQ-R-003 (funnel, 3:2) — "Wide stepped bars left-to-right": the
2197
+ vertical funnel's own trapezoid geometry rotated 90° — stage
2198
+ PROGRESSION reads along X instead of Y, and each value maps to a
2199
+ column's half-HEIGHT instead of a row's half-WIDTH. A wide box has
2200
+ width to spare and little height, so this orientation spends the
2201
+ abundant axis on the stage sequence and the scarce one on the value
2202
+ taper — the opposite trade the tall 2:3 cell below makes. */
2203
+ if (ratio === '3:2') {
2204
+ const padY = Math.max(height * 0.16, fs * 2.2); /* room for label/value/drop rows */
2205
+ const padX = fs * 0.8;
2206
+ const plotW = width - padX * 2;
2207
+ const plotH = height - padY * 2;
2208
+ const stageW = plotW / n;
2209
+ const gap = Math.max(2, stageW * 0.08);
2210
+ const cy = padY + plotH / 2;
2211
+
2212
+ for (let i = 0; i < n; i++) {
2213
+ const vLeft = vals[i];
2214
+ const vRight = (i < n - 1) ? vals[i + 1] : vals[i];
2215
+ const halfLeft = (vLeft / maxVal) * (plotH / 2);
2216
+ const halfRight = (vRight / maxVal) * (plotH / 2);
2217
+ const x0 = padX + stageW * i + gap / 2;
2218
+ const x1 = padX + stageW * (i + 1) - gap / 2;
2219
+ const midX = (x0 + x1) / 2;
2220
+
2221
+ const pct = ((vLeft / maxVal) * 100).toFixed(1);
2222
+ const tipAttrs = tip({ label: labels[i], value: vLeft, pct });
2223
+
2224
+ /* Trapezoid path: top-left, top-right, bottom-right, bottom-left,
2225
+ tapering vertically left-to-right instead of top-to-bottom. */
2226
+ const d = `M ${x0} ${cy - halfLeft} L ${x1} ${cy - halfRight} L ${x1} ${cy + halfRight} L ${x0} ${cy + halfLeft} Z`;
2227
+ svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
2228
+
2229
+ svg += `<text data-funnel-label x="${midX}" y="${padY - fs * 0.6}" text-anchor="middle" dominant-baseline="alphabetic" font-size="${fs}">${esc(labels[i])}</text>`;
2230
+ svg += `<text data-funnel-value x="${midX}" y="${height - padY + fs * 1.3}" text-anchor="middle" dominant-baseline="hanging" font-size="${fs}">${this.#fmtValue(vLeft)}</text>`;
2231
+ if (i > 0) {
2232
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
2233
+ svg += `<text data-funnel-drop x="${midX}" y="${height - padY + fs * 2.5}" text-anchor="middle" dominant-baseline="hanging" font-size="${fs * 0.85}">${dropPct}%</text>`;
2234
+ }
2235
+ }
2236
+
2237
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2238
+ }
2239
+
2240
+ /* 1:1 and 2:3 keep the vertical (top-to-bottom) orientation, but move
2241
+ label chrome to free width for the funnel body — the scarce axis at
2242
+ both these buckets, per REQ-R-004:
2243
+ 1:1 — "stage labels inline": label sits INSIDE the trapezoid,
2244
+ freeing the whole left gutter (value + pct stay external
2245
+ right, unchanged from the pre-2.0 default).
2246
+ 2:3 — "stage labels + % drop external right": both stacked in the
2247
+ RIGHT gutter instead of split left/right, freeing the whole
2248
+ left edge for the widest possible body in a narrow frame —
2249
+ funnel's most natural ratio per the SPEC. */
2250
+ const isInline = ratio === '1:1';
2251
+ const isTallExternalRight = ratio === '2:3';
2252
+ const padXLeft = (isInline || isTallExternalRight) ? fs * 0.8 : Math.max(width * 0.18, 80);
2253
+ const padXRight = isTallExternalRight ? Math.max(width * 0.30, fs * 6) : Math.max(width * 0.18, 80);
1659
2254
  const padY = fs * 0.8;
1660
- const plotW = width - padX * 2;
2255
+ const plotW = width - padXLeft - padXRight;
1661
2256
  const plotH = height - padY * 2;
1662
2257
  const stageH = plotH / n;
1663
2258
  const gap = Math.max(2, stageH * 0.08);
2259
+ const cx = padXLeft + plotW / 2;
1664
2260
 
1665
- const cx = width / 2;
1666
-
1667
- let svg = '';
1668
2261
  for (let i = 0; i < n; i++) {
1669
2262
  const vTop = vals[i];
1670
2263
  const vBot = (i < n - 1) ? vals[i + 1] : vals[i];
@@ -1672,42 +2265,82 @@ export class UIChart extends UIElement {
1672
2265
  const halfBot = (vBot / maxVal) * (plotW / 2);
1673
2266
  const y0 = padY + stageH * i + gap / 2;
1674
2267
  const y1 = padY + stageH * (i + 1) - gap / 2;
2268
+ const midY = y0 + stageH / 2 - gap / 2;
1675
2269
 
1676
2270
  const pct = ((vTop / maxVal) * 100).toFixed(1);
1677
2271
  const tipAttrs = tip({ label: labels[i], value: vTop, pct });
1678
2272
 
1679
- /* Trapezoid path: top-left, top-right, bottom-right, bottom-left. */
1680
2273
  const d = `M ${cx - halfTop} ${y0} L ${cx + halfTop} ${y0} L ${cx + halfBot} ${y1} L ${cx - halfBot} ${y1} Z`;
1681
2274
  svg += `<path data-slice="${i % 10}" data-funnel-stage${tipAttrs} d="${d}"/>`;
1682
2275
 
1683
- /* Stage label — left-aligned outside the funnel */
1684
- svg += `<text data-funnel-label x="${padX - 8}" y="${y0 + stageH / 2}" text-anchor="end" dominant-baseline="central" font-size="${fs}">${esc(labels[i])}</text>`;
2276
+ if (isInline) {
2277
+ // Real-browser-probe finding (this build, not caught by Vitest
2278
+ // no layout/paint there): an unconditional inline label overflows
2279
+ // a narrow LATER stage's own trapezoid width, spilling the
2280
+ // overflow portion onto the plain page background where the
2281
+ // on-color fill token reads as invisible (near-white-on-white).
2282
+ // Truncate per-stage to that stage's own average width — the same
2283
+ // char-budget-from-available-space technique #renderPie's own
2284
+ // ring-label gutter and #renderTreemap's tile labels already use
2285
+ // — and skip the label entirely once even a 2-char abbreviation
2286
+ // wouldn't fit (an absent label reads better than an illegible,
2287
+ // spilling one).
2288
+ const avgHalfWidth = (halfTop + halfBot) / 2;
2289
+ const stageBudget = Math.floor((avgHalfWidth * 2 - fs) / (fs * 0.6));
2290
+ if (stageBudget >= 2) {
2291
+ svg += `<text data-funnel-label data-funnel-label-inline x="${cx}" y="${midY}" text-anchor="middle" dominant-baseline="central" font-size="${fs}">${esc(truncateLabel(labels[i], stageBudget))}</text>`;
2292
+ }
2293
+ } else {
2294
+ svg += `<text data-funnel-label x="${padXLeft - 8}" y="${midY}" text-anchor="end" dominant-baseline="central" font-size="${fs}">${esc(labels[i])}</text>`;
2295
+ }
1685
2296
 
1686
- /* Value + pct — right-aligned outside the funnel */
1687
- svg += `<text data-funnel-value x="${width - padX + 8}" y="${y0 + stageH / 2}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${this.#fmtValue(vTop)}</text>`;
1688
- if (i > 0) {
1689
- const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
1690
- svg += `<text data-funnel-drop x="${width - padX + 8}" y="${y0 + stageH / 2 + fs * 1.1}" text-anchor="start" dominant-baseline="central" font-size="${fs * 0.85}">${dropPct}%</text>`;
2297
+ if (isTallExternalRight) {
2298
+ svg += `<text data-funnel-label data-funnel-label-right x="${width - padXRight + 8}" y="${midY - fs * 0.6}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${esc(labels[i])}</text>`;
2299
+ svg += `<text data-funnel-value x="${width - padXRight + 8}" y="${midY + fs * 0.6}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${this.#fmtValue(vTop)}</text>`;
2300
+ if (i > 0) {
2301
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
2302
+ svg += `<text data-funnel-drop x="${width - padXRight + 8}" y="${midY + fs * 1.7}" text-anchor="start" dominant-baseline="central" font-size="${fs * 0.85}">${dropPct}%</text>`;
2303
+ }
2304
+ } else {
2305
+ svg += `<text data-funnel-value x="${width - padXRight + 8}" y="${midY}" text-anchor="start" dominant-baseline="central" font-size="${fs}">${this.#fmtValue(vTop)}</text>`;
2306
+ if (i > 0) {
2307
+ const dropPct = ((vals[i] / vals[0]) * 100).toFixed(0);
2308
+ svg += `<text data-funnel-drop x="${width - padXRight + 8}" y="${midY + fs * 1.1}" text-anchor="start" dominant-baseline="central" font-size="${fs * 0.85}">${dropPct}%</text>`;
2309
+ }
1691
2310
  }
1692
2311
  }
1693
2312
 
1694
- this.#legendData = data.map((d, i) => ({
1695
- label: labels[i], value: vals[i], pct: ((vals[i] / maxVal) * 100).toFixed(1), slot: i % 10,
1696
- }));
1697
-
1698
2313
  return { svg, viewBox: `0 0 ${width} ${height}` };
1699
2314
  }
1700
2315
 
1701
2316
  /* ── Treemap (hierarchical rect tiling) ────────────────────────
1702
- Flat squarified treemap: each datum is a rect whose area is
1703
- proportional to its value. Not nested — Phase 5 ships flat
1704
- rectangles; a nested variant (children arrays) is a natural
1705
- future extension.
1706
-
1707
- Uses the Bruls/Huijbregts/Van Wijk squarified algorithm adapted
1708
- for single-level data: sorts values desc, packs rows that hold
1709
- aspect ratios close to 1, alternating row direction based on
1710
- remaining container aspect. */
2317
+ Flat grid tiling: each datum is a rect whose area is proportional
2318
+ to its value. Not nested — Phase 5 ships flat rectangles; a nested
2319
+ variant (children arrays) is a natural future extension.
2320
+
2321
+ REQ-R-003 (treemap) — "wide grid, more columns than rows" (3:2) /
2322
+ "balanced grid, near-square cells" (1:1) / "tall grid, more rows
2323
+ than columns" (2:3). This wave REPLACES the prior Bruls/Huijbregts/
2324
+ Van Wijk squarified packer with a deliberate, ratio-keyed row-major
2325
+ grid a proposed amendment to the pre-Wave-C implementation, stated
2326
+ here and in this build's Findings, not silent. Measured first: the
2327
+ squarified packer's own row-membership decision is driven almost
2328
+ entirely by the DATA's value distribution, not by the container's
2329
+ aspect (a `gridBias`-scaled worst-ratio threshold was tried first and
2330
+ empirically failed on the SPEC's own non-goal case — a container
2331
+ shape alone, or a scaled variant of the same heuristic, does not
2332
+ reliably deliver "more columns than rows" vs. "more rows than
2333
+ columns"; several skewed/even/small-n datasets at the REAL width/
2334
+ height pairs `#dims()` actually produces — constant width, only
2335
+ height varies by ratio pin — inverted the intended topology). A
2336
+ row-major grid keyed directly on COLUMN COUNT is the "hand-authored,
2337
+ not a generic algorithm" reading REQ-P-004/this SPEC's own Non-goals
2338
+ section asks for: `cols` is chosen from `n` and the ratio bucket
2339
+ alone, so the topology is deterministic regardless of the dataset's
2340
+ value spread; each row's HEIGHT is still weighted by its own value
2341
+ sum (bigger-value rows get proportionally more height) and each
2342
+ tile's WIDTH by its value share of its row, so area-proportionality
2343
+ — the one property a treemap must never break — holds exactly. */
1711
2344
  #renderTreemap() {
1712
2345
  const data = this.#data;
1713
2346
  const yKey = this.#yKeys()[0] || this.y;
@@ -1719,71 +2352,35 @@ export class UIChart extends UIElement {
1719
2352
  const dims = this.#dims();
1720
2353
  const { width, height, fontSize } = dims;
1721
2354
  const total = vals.reduce((a, b) => a + b, 0) || 1;
2355
+ const ratio = dims.ratioResolved;
1722
2356
 
1723
- /* Sort indices by value desc squarified needs sorted input but
1724
- we keep original indices for color/label/hit mapping. */
2357
+ /* Sort indices by value desc (largest tiles read first, top-left)
2358
+ keep original indices for color/label/hit mapping. */
1725
2359
  const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => vals[b] - vals[a]);
1726
2360
 
1727
- const rects = []; /* {i, x, y, w, h} in plot coords */
1728
-
1729
- /* Scale areas so they fill the container. */
1730
- const area = width * height;
1731
- const scaled = order.map(i => (vals[i] / total) * area);
1732
-
1733
- /* Squarified packer — iterative rows. */
1734
- let x = 0, y = 0, w = width, h = height;
1735
- let row = [];
1736
- let rowStart = 0;
1737
-
1738
- const worst = (row, width) => {
1739
- if (row.length === 0) return Infinity;
1740
- const sum = row.reduce((a, b) => a + b, 0);
1741
- const max = Math.max(...row);
1742
- const min = Math.min(...row);
1743
- const wsq = width * width;
1744
- const ssq = sum * sum;
1745
- return Math.max((wsq * max) / ssq, ssq / (wsq * min));
1746
- };
2361
+ const cols = ratio === '3:2'
2362
+ ? Math.max(2, Math.ceil(Math.sqrt(n * 2)))
2363
+ : ratio === '2:3'
2364
+ ? Math.max(1, Math.floor(Math.sqrt(n / 2)))
2365
+ : Math.max(1, Math.round(Math.sqrt(n)));
1747
2366
 
1748
- const layoutRow = (row, rowStart, x, y, w, h) => {
1749
- const sum = row.reduce((a, b) => a + b, 0);
1750
- const horizontal = w >= h;
1751
- const rowH = horizontal ? sum / w : h;
1752
- const rowW = horizontal ? w : sum / h;
1753
-
1754
- let offset = 0;
1755
- for (let k = 0; k < row.length; k++) {
1756
- const frac = row[k] / sum;
1757
- const idx = order[rowStart + k];
1758
- if (horizontal) {
1759
- const segW = frac * w;
1760
- rects.push({ i: idx, x: x + offset, y, w: segW, h: rowH });
1761
- offset += segW;
1762
- } else {
1763
- const segH = frac * h;
1764
- rects.push({ i: idx, x, y: y + offset, w: rowW, h: segH });
1765
- offset += segH;
1766
- }
1767
- }
1768
- if (horizontal) return { x, y: y + rowH, w, h: h - rowH };
1769
- return { x: x + rowW, y, w: w - rowW, h };
1770
- };
2367
+ const rows = [];
2368
+ for (let i = 0; i < order.length; i += cols) rows.push(order.slice(i, i + cols));
2369
+ const rowSums = rows.map(r => r.reduce((s, i) => s + vals[i], 0));
2370
+ const rowSumTotal = rowSums.reduce((a, b) => a + b, 0) || 1;
1771
2371
 
1772
- let remaining = scaled.slice();
1773
- while (remaining.length > 0) {
1774
- row = [remaining[0]];
1775
- rowStart = scaled.length - remaining.length;
1776
- const shortSide = Math.min(w, h);
1777
- let i = 1;
1778
- while (i < remaining.length) {
1779
- const next = row.concat(remaining[i]);
1780
- if (worst(next, shortSide) > worst(row, shortSide)) break;
1781
- row = next;
1782
- i++;
2372
+ const rects = []; /* {i, x, y, w, h} in plot coords */
2373
+ let y = 0;
2374
+ for (let ri = 0; ri < rows.length; ri++) {
2375
+ const rowH = (rowSums[ri] / rowSumTotal) * height;
2376
+ const rowValTotal = rowSums[ri] || 1;
2377
+ let x = 0;
2378
+ for (const idx of rows[ri]) {
2379
+ const w = (vals[idx] / rowValTotal) * width;
2380
+ rects.push({ i: idx, x, y, w, h: rowH });
2381
+ x += w;
1783
2382
  }
1784
- const newRect = layoutRow(row, rowStart, x, y, w, h);
1785
- x = newRect.x; y = newRect.y; w = newRect.w; h = newRect.h;
1786
- remaining = remaining.slice(row.length);
2383
+ y += rowH;
1787
2384
  }
1788
2385
 
1789
2386
  /* Emit rects + labels. */
@@ -1804,13 +2401,19 @@ export class UIChart extends UIElement {
1804
2401
  const canShowAny = r.w > fontSize * 4;
1805
2402
  const tall = canShowAny && r.h > fontSize * 2.5;
1806
2403
  const short = canShowAny && !tall && r.h > fontSize * 1.2;
2404
+ // REQ-R-003 (treemap, 2:3) — "label truncation more aggressive": the
2405
+ // `cols` choice above already narrows every tile's own width budget
2406
+ // at this bucket (fewest columns of the three), so the char budget
2407
+ // shrinks to match rather than relying on the generic overflow the
2408
+ // tile-size gate above already guards against.
2409
+ const labelChars = ratio === '2:3' ? 8 : 20;
1807
2410
 
1808
2411
  if (tall) {
1809
- svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(labels[r.i])}</text>`;
2412
+ svg += `<text data-treemap-label x="${labelX}" y="${r.y + fontSize + 4}" font-size="${fontSize}" dominant-baseline="hanging">${esc(truncateLabel(labels[r.i], labelChars))}</text>`;
1810
2413
  svg += `<text data-treemap-value x="${labelX}" y="${r.y + fontSize * 2 + 6}" font-size="${fontSize * 0.9}" dominant-baseline="hanging">${this.#fmtValue(vals[r.i])}</text>`;
1811
2414
  } else if (short) {
1812
2415
  const cy = r.y + r.h / 2;
1813
- svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(labels[r.i])}</text>`;
2416
+ svg += `<text data-treemap-label x="${labelX}" y="${cy}" font-size="${fontSize}" dominant-baseline="central">${esc(truncateLabel(labels[r.i], labelChars))}</text>`;
1814
2417
  }
1815
2418
  }
1816
2419
 
@@ -1847,19 +2450,106 @@ export class UIChart extends UIElement {
1847
2450
 
1848
2451
  const dims = this.#dims();
1849
2452
  const { width, height, fontSize } = dims;
1850
- const nodeW = Math.max(8, width * 0.03);
1851
- const colPad = nodeW + fontSize * 5;
2453
+ const ratio = dims.ratioResolved;
2454
+ const sources = [...sourceSet.values()];
2455
+ const targets = [...targetSet.values()];
2456
+ const sourceTotal = sources.reduce((a, s) => a + s.flow, 0) || 1;
2457
+ const targetTotal = targets.reduce((a, t) => a + t.flow, 0) || 1;
1852
2458
 
1853
- const sourceTotal = [...sourceSet.values()].reduce((a, s) => a + s.flow, 0) || 1;
1854
- const targetTotal = [...targetSet.values()].reduce((a, t) => a + t.flow, 0) || 1;
2459
+ let svg = '';
1855
2460
 
1856
- const nodeGap = fontSize * 0.6;
2461
+ /* REQ-R-003 (sankey, 2:3) "Flow forced top-to-bottom, nodes stack
2462
+ vertically" — the SPEC's own "hardest cell" and the only one of the
2463
+ three that isn't a gutter/label tweak: sources become a horizontal
2464
+ row along the TOP, targets a horizontal row along the BOTTOM, and
2465
+ every link becomes a VERTICAL bezier band instead of a horizontal
2466
+ one. Node "flow" now maps to a WIDTH instead of a HEIGHT, and labels
2467
+ sit above the top row / below the bottom row instead of left/right
2468
+ of a column — the same column↔row transpose #renderFunnel's own
2469
+ 3:2 cell performs, applied to the opposite type of node layout. */
2470
+ if (ratio === '2:3') {
2471
+ const nodeH = Math.max(8, height * 0.05);
2472
+ const rowPad = nodeH + fontSize * 1.8;
2473
+ // CodeRabbit (PR #1662) — a many-node dataset can make
2474
+ // nodeGap * (count - 1) exceed width, driving sourceTotalW/
2475
+ // targetTotalW negative: every <rect data-sankey-node> gets a
2476
+ // negative width and the vertical link bands invert. Cap the
2477
+ // total gap budget at half the available width (regardless of
2478
+ // node count) so the remaining width for node bodies never
2479
+ // collapses to zero or below.
2480
+ const maxGapBudget = width * 0.5;
2481
+ const nodeGap = Math.min(
2482
+ fontSize * 0.8,
2483
+ maxGapBudget / Math.max(1, Math.max(sources.length, targets.length) - 1),
2484
+ );
2485
+ const sourceTotalW = Math.max(1, width - nodeGap * Math.max(0, sources.length - 1));
2486
+ const targetTotalW = Math.max(1, width - nodeGap * Math.max(0, targets.length - 1));
2487
+
2488
+ let x = 0;
2489
+ for (const s of sources) {
2490
+ const w = (s.flow / sourceTotal) * sourceTotalW;
2491
+ s.x0 = x; s.x1 = x + w; s.cursor = x;
2492
+ x += w + nodeGap;
2493
+ }
2494
+ x = 0;
2495
+ for (const t of targets) {
2496
+ const w = (t.flow / targetTotal) * targetTotalW;
2497
+ t.x0 = x; t.x1 = x + w; t.cursor = x;
2498
+ x += w + nodeGap;
2499
+ }
1857
2500
 
1858
- /* Assign y0/y1 to each source node, stacked top-to-bottom. */
1859
- const sources = [...sourceSet.values()];
1860
- const targets = [...targetSet.values()];
1861
- const sourceTotalH = height - nodeGap * (sources.length - 1);
1862
- const targetTotalH = height - nodeGap * (targets.length - 1);
2501
+ const nodeRx = Math.min(this.#resolveRadius(), nodeH / 2);
2502
+
2503
+ sources.forEach((s, i) => {
2504
+ const tipAttrs = tip({ label: s.name, value: s.flow });
2505
+ svg += `<rect data-sankey-node data-slice="${i % 10}"${tipAttrs} x="${s.x0}" y="${rowPad - nodeH}" width="${s.x1 - s.x0}" height="${nodeH}" rx="${nodeRx}"/>`;
2506
+ svg += `<text data-sankey-label x="${(s.x0 + s.x1) / 2}" y="${rowPad - nodeH - 6}" text-anchor="middle" dominant-baseline="alphabetic" font-size="${fontSize}">${esc(truncateLabel(s.name, 10))}</text>`;
2507
+ });
2508
+ targets.forEach((t, i) => {
2509
+ const tipAttrs = tip({ label: t.name, value: t.flow });
2510
+ svg += `<rect data-sankey-node data-slice="${(sources.length + i) % 10}"${tipAttrs} x="${t.x0}" y="${height - rowPad}" width="${t.x1 - t.x0}" height="${nodeH}" rx="${nodeRx}"/>`;
2511
+ svg += `<text data-sankey-label x="${(t.x0 + t.x1) / 2}" y="${height - rowPad + nodeH + fontSize + 2}" text-anchor="middle" dominant-baseline="alphabetic" font-size="${fontSize}">${esc(truncateLabel(t.name, 10))}</text>`;
2512
+ });
2513
+
2514
+ for (const link of data) {
2515
+ const s = sourceSet.get(link.source ?? link.from ?? '');
2516
+ const t = targetSet.get(link.target ?? link.to ?? '');
2517
+ if (!s || !t) continue;
2518
+ const v = +(link.value ?? link.v ?? 0);
2519
+ if (v <= 0) continue;
2520
+
2521
+ const sW = (v / sourceTotal) * sourceTotalW;
2522
+ const tW = (v / targetTotal) * targetTotalW;
2523
+ const sLeft = s.cursor; const sRight = sLeft + sW;
2524
+ const tLeft = t.cursor; const tRight = tLeft + tW;
2525
+ s.cursor += sW;
2526
+ t.cursor += tW;
2527
+
2528
+ const y0 = rowPad;
2529
+ const y1 = height - rowPad;
2530
+ const my = (y0 + y1) / 2;
2531
+ const tipAttrs = tip({ label: `${s.name} → ${t.name}`, value: v });
2532
+
2533
+ const path = `M ${sLeft} ${y0} C ${sLeft} ${my}, ${tLeft} ${my}, ${tLeft} ${y1} L ${tRight} ${y1} C ${tRight} ${my}, ${sRight} ${my}, ${sRight} ${y0} Z`;
2534
+ svg += `<path data-sankey-link${tipAttrs} d="${path}"/>`;
2535
+ }
2536
+
2537
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2538
+ }
2539
+
2540
+ /* 3:2 (baseline — "wide flow left-to-right, node labels external",
2541
+ unchanged from the pre-2.0 default) and 1:1 ("flow compressed, node
2542
+ labels abbreviated/on-hover") share the horizontal column layout;
2543
+ 1:1 shrinks the label gutter and truncates node names (full name
2544
+ stays available via the node's own hover tooltip either way). */
2545
+ const isCompressed = ratio === '1:1';
2546
+ const nodeW = Math.max(8, width * 0.03);
2547
+ const colPad = nodeW + fontSize * (isCompressed ? 2.2 : 5);
2548
+ const labelChars = isCompressed ? 6 : 24;
2549
+
2550
+ const nodeGap = fontSize * 0.6;
2551
+ const sourceTotalH = height - nodeGap * Math.max(0, sources.length - 1);
2552
+ const targetTotalH = height - nodeGap * Math.max(0, targets.length - 1);
1863
2553
 
1864
2554
  let y = 0;
1865
2555
  for (const s of sources) {
@@ -1878,20 +2568,20 @@ export class UIChart extends UIElement {
1878
2568
  y += h + nodeGap;
1879
2569
  }
1880
2570
 
1881
- let svg = '';
1882
-
1883
2571
  const nodeRx = Math.min(this.#resolveRadius(), nodeW / 2);
1884
2572
 
1885
2573
  /* Source nodes — left column */
1886
2574
  sources.forEach((s, i) => {
1887
- svg += `<rect data-sankey-node data-slice="${i % 10}" x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
1888
- svg += `<text data-sankey-label x="${colPad - nodeW - 6}" y="${(s.y0 + s.y1) / 2}" text-anchor="end" dominant-baseline="central" font-size="${fontSize}">${esc(s.name)}</text>`;
2575
+ const tipAttrs = tip({ label: s.name, value: s.flow });
2576
+ svg += `<rect data-sankey-node data-slice="${i % 10}"${tipAttrs} x="${colPad - nodeW}" y="${s.y0}" width="${nodeW}" height="${s.y1 - s.y0}" rx="${nodeRx}"/>`;
2577
+ svg += `<text data-sankey-label x="${colPad - nodeW - 6}" y="${(s.y0 + s.y1) / 2}" text-anchor="end" dominant-baseline="central" font-size="${fontSize}">${esc(truncateLabel(s.name, labelChars))}</text>`;
1889
2578
  });
1890
2579
 
1891
2580
  /* Target nodes — right column */
1892
2581
  targets.forEach((t, i) => {
1893
- svg += `<rect data-sankey-node data-slice="${(sources.length + i) % 10}" x="${width - colPad}" y="${t.y0}" width="${nodeW}" height="${t.y1 - t.y0}" rx="${nodeRx}"/>`;
1894
- svg += `<text data-sankey-label x="${width - colPad + nodeW + 6}" y="${(t.y0 + t.y1) / 2}" text-anchor="start" dominant-baseline="central" font-size="${fontSize}">${esc(t.name)}</text>`;
2582
+ const tipAttrs = tip({ label: t.name, value: t.flow });
2583
+ svg += `<rect data-sankey-node data-slice="${(sources.length + i) % 10}"${tipAttrs} x="${width - colPad}" y="${t.y0}" width="${nodeW}" height="${t.y1 - t.y0}" rx="${nodeRx}"/>`;
2584
+ svg += `<text data-sankey-label x="${width - colPad + nodeW + 6}" y="${(t.y0 + t.y1) / 2}" text-anchor="start" dominant-baseline="central" font-size="${fontSize}">${esc(truncateLabel(t.name, labelChars))}</text>`;
1895
2585
  });
1896
2586
 
1897
2587
  /* Links — bezier bands */
@@ -1933,6 +2623,7 @@ export class UIChart extends UIElement {
1933
2623
  #renderComposed() {
1934
2624
  const dims = this.#dims();
1935
2625
  const data = this.#data;
2626
+ const ratio = dims.ratioResolved;
1936
2627
  const keys = this.#yKeys();
1937
2628
  const barKey = keys[0] || 'bar';
1938
2629
  const lineKey = keys[1] || 'line';
@@ -1940,8 +2631,9 @@ export class UIChart extends UIElement {
1940
2631
  const barVals = data.map(d => +(d[barKey] ?? 0));
1941
2632
  const lineVals = data.map(d => +(d[lineKey] ?? 0));
1942
2633
  const allVals = [...barVals, ...lineVals];
1943
- const ticks = niceScale(0, Math.max(...allVals), 5);
2634
+ const ticks = niceScale(...this.#yBounds(allVals), 5);
1944
2635
  const maxVal = ticks[ticks.length - 1];
2636
+ const minVal = ticks[0];
1945
2637
 
1946
2638
  const { width, height, pad } = dims;
1947
2639
  const plotH = height - pad.top - pad.bottom;
@@ -1949,39 +2641,95 @@ export class UIChart extends UIElement {
1949
2641
  const barW = plotW / data.length;
1950
2642
  const barInner = barW * 0.6;
1951
2643
  const barGap = (barW - barInner) / 2;
1952
- const step = plotW / Math.max(data.length - 1, 1);
1953
2644
 
1954
2645
  let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
1955
2646
 
1956
- /* Bar series (slot 0) */
2647
+ /* Bar series (slot 0) — "primary series only fully rendered" (2:3) is
2648
+ this same unconditional render at every ratio; the primary axis
2649
+ treatment never changes, only the SECONDARY series below does. */
1957
2650
  if (!this.#isSeriesHidden(barKey)) {
1958
2651
  for (let i = 0; i < data.length; i++) {
1959
2652
  const v = barVals[i];
1960
- const barH = maxVal ? (v / maxVal) * plotH : 0;
2653
+ const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
2654
+ const barH = this.#clampSpan(rawH, plotH);
1961
2655
  const bx = pad.left + barW * i + barGap;
1962
2656
  const by = pad.top + plotH - barH;
1963
2657
  svg += `<path${this.#seriesFill(0, barKey)}${tip({ label: labels[i], value: v, series: barKey })} d="${topRoundedBarPath(bx, by, barInner, barH, this.#resolveRadius())}"/>`;
1964
2658
  }
1965
2659
  }
1966
2660
 
1967
- /* Line series (slot 1) — anchored at bar center X positions */
1968
2661
  if (!this.#isSeriesHidden(lineKey)) {
1969
- const points = lineVals.map((v, i) => ({
1970
- x: pad.left + barW * i + barW / 2,
1971
- y: pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0),
1972
- v, label: labels[i],
1973
- }));
1974
- const t = Math.max(0, Math.min(1, this.smooth));
1975
- svg += `<path data-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(points, t)}"/>`;
1976
- for (const p of points) {
1977
- svg += `<circle data-dot${this.#seriesFill(1, lineKey)} cx="${p.x}" cy="${p.y}" r="3"/>`;
1978
- svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
2662
+ if (ratio === '2:3') {
2663
+ /* REQ-R-003 (composed, 2:3) "secondary as sparkline inset": a
2664
+ tall frame has too little WIDTH to overlay a full-scale line
2665
+ across the same cramped bar plot without the two competing for
2666
+ the same axis, so the line renders as a small self-scaled
2667
+ sparkline (own min/max, no shared axis) tucked into the plot's
2668
+ top-right corner instead the same "inset trend" reading a
2669
+ stat-tile's own corner sparkline uses elsewhere in this file. */
2670
+ const insetW = plotW * 0.34;
2671
+ const insetH = Math.min(plotH * 0.26, insetW * 0.6);
2672
+ const insetX = pad.left + plotW - insetW - 2;
2673
+ const insetY = pad.top + 2;
2674
+ const lo = Math.min(...lineVals);
2675
+ const hi = Math.max(...lineVals);
2676
+ const span = hi - lo || 1;
2677
+ const insetPoints = lineVals.map((v, i) => ({
2678
+ x: insetX + (lineVals.length > 1 ? (i / (lineVals.length - 1)) * insetW : insetW / 2),
2679
+ y: insetY + insetH - ((v - lo) / span) * insetH,
2680
+ v, label: labels[i],
2681
+ }));
2682
+ const t = Math.max(0, Math.min(1, this.smooth));
2683
+ svg += `<rect data-composed-inset-bg x="${insetX - 4}" y="${insetY - 4}" width="${insetW + 8}" height="${insetH + 8}" rx="${this.#resolveRadius()}"/>`;
2684
+ svg += `<path data-composed-inset-line${this.#seriesStroke(1, lineKey)} d="${smoothPath(insetPoints, t)}"/>`;
2685
+ for (const p of insetPoints) {
2686
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="6" fill="transparent"/>`;
2687
+ }
2688
+ svg += `<text data-composed-inset-label x="${insetX}" y="${insetY - 6}" text-anchor="start" font-size="${dims.valueSize}">${esc(lineKey)}</text>`;
2689
+ } else {
2690
+ /* 3:2 baseline (unchanged) and 1:1 share the full-axis overlay;
2691
+ 1:1 additionally de-emphasizes the secondary series. */
2692
+ const points = lineVals.map((v, i) => {
2693
+ const rawY = pad.top + plotH - ((maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0);
2694
+ return {
2695
+ x: pad.left + barW * i + barW / 2,
2696
+ y: this.#clampCoord(rawY, pad.top, pad.top + plotH),
2697
+ v, label: labels[i],
2698
+ };
2699
+ });
2700
+ const t = Math.max(0, Math.min(1, this.smooth));
2701
+ /* REQ-R-003/REQ-R-007 (composed, 1:1) — "secondary series
2702
+ de-emphasized via -container-low tint": a square view crams
2703
+ both series onto the same cramped axis with nothing to visually
2704
+ subordinate the secondary reading, so its stroke/dots switch
2705
+ from the ordinary categorical palette to the muted neutral role
2706
+ token ADR-0073 already uses for de-emphasis elsewhere — a COLOR
2707
+ swap, never an opacity hack (REQ-R-007 explicitly rules that
2708
+ out as a "chart-invented" substitute for the ratified tokens). */
2709
+ const deemphasized = ratio === '1:1';
2710
+ const lineAttrs = deemphasized
2711
+ ? ` data-slice="1" data-deemphasized-tint style="stroke: var(--md-sys-color-neutral-on-surface-variant)"`
2712
+ : this.#seriesStroke(1, lineKey);
2713
+ const dotAttrs = deemphasized
2714
+ ? ` data-slice="1" data-deemphasized-tint style="fill: var(--md-sys-color-neutral-on-surface-variant)"`
2715
+ : this.#seriesFill(1, lineKey);
2716
+ svg += `<path data-line${lineAttrs} d="${smoothPath(points, t)}"/>`;
2717
+ for (const p of points) {
2718
+ svg += `<circle data-dot${dotAttrs} cx="${p.x}" cy="${p.y}" r="3"/>`;
2719
+ svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: lineKey })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
2720
+ }
1979
2721
  }
1980
2722
  }
1981
2723
 
1982
2724
  this.#legendData = [
1983
2725
  { label: barKey, key: barKey, slot: 0 },
1984
- { label: lineKey, key: lineKey, slot: 1 },
2726
+ // CodeRabbit (PR #1662) the 1:1 de-emphasis above swaps the
2727
+ // drawn series to a muted neutral token, but the legend payload
2728
+ // still reported plain slot-1 palette data, so #buildLegend (and
2729
+ // any external chart-legend-ui[for] mirroring it) painted a
2730
+ // legend dot that didn't match what was actually drawn. Signal
2731
+ // the state so consumers can mirror it.
2732
+ { label: lineKey, key: lineKey, slot: 1, deemphasized: ratio === '1:1' },
1985
2733
  ];
1986
2734
 
1987
2735
  return { svg, viewBox: `0 0 ${width} ${height}` };
@@ -1994,9 +2742,43 @@ export class UIChart extends UIElement {
1994
2742
  #renderSegments() {
1995
2743
  const data = this.#data;
1996
2744
  const yKey = this.#yKeys()[0] || this.y;
1997
- const vals = data.map(d => +(d[yKey] ?? 0));
2745
+ let vals = data.map(d => +(d[yKey] ?? 0));
2746
+ let labels = data.map(d => d[this.x] ?? '');
1998
2747
  const total = vals.reduce((a, b) => a + b, 0) || 1;
1999
- const labels = data.map(d => d[this.x] ?? '');
2748
+
2749
+ // #dims() also classifies + reflects `data-ratio-resolved` (its own
2750
+ // side effect) — needed here since this renderer otherwise sizes
2751
+ // itself from clientWidth/a fixed strip height, not #dims()'s own
2752
+ // width/height/pad numbers.
2753
+ const dims = this.#dims();
2754
+ const ratio = dims.ratioResolved;
2755
+
2756
+ // REQ-R-003 (segments, 1:1) — "fewer segments visible before '+N
2757
+ // more'": the same aggregate-tail mechanism as bar@1:1's own Wave A
2758
+ // carry-forward cell, sorted by VALUE (a proportional strip reads
2759
+ // best keeping its biggest contributors visible, unlike bar's ordered
2760
+ // category axis — see `aggregateTail`'s own doc comment above).
2761
+ if (ratio === '1:1' && vals.length > SEGMENTS_1_1_VISIBLE_CAP) {
2762
+ ({ labels, vals } = aggregateTail(labels, vals, SEGMENTS_1_1_VISIBLE_CAP, true));
2763
+ }
2764
+
2765
+ // Legend data (same shape pie/donut use) — built from the (possibly
2766
+ // aggregated) visible set so "legend below"/"legend stacked" always
2767
+ // mirrors exactly what's drawn, never a longer list than the strip
2768
+ // itself shows.
2769
+ this.#legendData = labels.map((label, i) => ({
2770
+ label,
2771
+ value: vals[i],
2772
+ pct: ((vals[i] / total) * 100).toFixed(1),
2773
+ slot: i % 10,
2774
+ }));
2775
+
2776
+ // REQ-R-003 (segments, 2:3) — "tall vertical stacked bars": the same
2777
+ // orientation-swap rationale as bar's own 2:3 cell, applied to this
2778
+ // type's single composite strip instead of N independent bars.
2779
+ if (ratio === '2:3') {
2780
+ return this.#renderSegmentsVertical(dims, labels, vals, total);
2781
+ }
2000
2782
 
2001
2783
  // ViewBox matches actual render width so there's no horizontal
2002
2784
  // stretching — keeps rounded corners from flattening into ellipses
@@ -2050,17 +2832,63 @@ export class UIChart extends UIElement {
2050
2832
  }
2051
2833
  }
2052
2834
 
2053
- // Legend data (same shape pie/donut use).
2054
- this.#legendData = data.map((d, i) => ({
2055
- label: labels[i],
2056
- value: vals[i],
2057
- pct: ((vals[i] / total) * 100).toFixed(1),
2058
- slot: i % 10,
2059
- }));
2060
-
2061
2835
  return { svg, viewBox: `0 0 ${w} ${h}` };
2062
2836
  }
2063
2837
 
2838
+ /* REQ-R-003 (segments, 2:3) — vertical mirror of the horizontal strip
2839
+ above: a fixed-thickness column (same magic-number role `h = 24` plays
2840
+ for the horizontal variant, just on the width axis instead) stacked
2841
+ top-to-bottom, sized from #dims()'s own ratio-aware height so the
2842
+ column genuinely grows tall rather than staying the horizontal
2843
+ variant's flat strip. */
2844
+ #renderSegmentsVertical(dims, labels, vals, total) {
2845
+ const w = 24;
2846
+ const height = Math.max(60, dims.height);
2847
+ const r = Math.min(this.#resolveRadius(), w / 2);
2848
+ const cs = getComputedStyle(this);
2849
+ const gap = Math.max(0, parseFloat(cs.getPropertyValue('--chart-segments-gap')) || 2);
2850
+
2851
+ const heights = [];
2852
+ for (let i = 0; i < vals.length; i++) {
2853
+ const endY = (i === vals.length - 1) ? height : ((vals.slice(0, i + 1).reduce((s, v) => s + v, 0)) / total) * height;
2854
+ const startY = (i === 0) ? 0 : ((vals.slice(0, i).reduce((s, v) => s + v, 0)) / total) * height;
2855
+ heights.push({ y: startY, h: Math.max(0, endY - startY) });
2856
+ }
2857
+
2858
+ let svg = '';
2859
+ for (let i = 0; i < heights.length; i++) {
2860
+ const { y, h: segH } = heights[i];
2861
+ if (segH <= 0) continue;
2862
+
2863
+ const isFirst = i === 0;
2864
+ const isLast = i === heights.length - 1;
2865
+ const drawH = Math.max(0, segH - (isLast ? 0 : gap));
2866
+
2867
+ const pct = ((vals[i] / total) * 100).toFixed(1);
2868
+ const tipAttrs = tip({ label: labels[i], value: vals[i], pct });
2869
+ const a11yTitle = `<title>${esc(labels[i])}: ${vals[i]} (${pct}%)</title>`;
2870
+
2871
+ if ((isFirst && isLast) || (!isFirst && !isLast) || r === 0) {
2872
+ svg += `<rect data-slice="${i % 10}"${tipAttrs} x="0" y="${y}" width="${w}" height="${drawH}"${(isFirst || isLast) && r ? ` rx="${r}"` : ''}>${a11yTitle}</rect>`;
2873
+ } else {
2874
+ // Asymmetric rounded corners: TOP two corners for the first
2875
+ // segment, BOTTOM two for the last — the vertical mirror of the
2876
+ // horizontal variant's left/right corner rounding above.
2877
+ const rr = r;
2878
+ const y2 = y + drawH;
2879
+ let d;
2880
+ if (isFirst) {
2881
+ d = `M 0 ${y + rr} A ${rr} ${rr} 0 0 1 ${rr} ${y} H ${w - rr} A ${rr} ${rr} 0 0 1 ${w} ${y + rr} V ${y2} H 0 Z`;
2882
+ } else { // isLast
2883
+ d = `M 0 ${y} H ${w} V ${y2 - rr} A ${rr} ${rr} 0 0 1 ${w - rr} ${y2} H ${rr} A ${rr} ${rr} 0 0 1 0 ${y2 - rr} Z`;
2884
+ }
2885
+ svg += `<path data-slice="${i % 10}"${tipAttrs} d="${d}">${a11yTitle}</path>`;
2886
+ }
2887
+ }
2888
+
2889
+ return { svg, viewBox: `0 0 ${w} ${height}` };
2890
+ }
2891
+
2064
2892
  /* ── Stacked bar ──────────────────────────────────────────────── */
2065
2893
 
2066
2894
  #renderStackedBar() {
@@ -2070,10 +2898,23 @@ export class UIChart extends UIElement {
2070
2898
 
2071
2899
  /* Get stacked totals for max */
2072
2900
  const totals = data.map(d => keys.reduce((sum, k) => sum + (+(d[k] ?? 0)), 0));
2073
- const ticks = niceScale(0, Math.max(...totals), 5);
2901
+ const ticks = niceScale(...this.#yBounds(totals), 5);
2074
2902
  const maxVal = ticks[ticks.length - 1];
2903
+ const minVal = ticks[0];
2075
2904
 
2076
2905
  const dims = this.#dims();
2906
+ const ratio = dims.ratioResolved;
2907
+
2908
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
2909
+
2910
+ // REQ-R-003 (stacked-bar, 2:3) — "bars rotate to horizontal": same
2911
+ // orientation-swap rationale as bar's own 2:3 cell — a tall, narrow
2912
+ // box has width for a value axis but not for N vertical category
2913
+ // columns each carrying a multi-segment stack.
2914
+ if (ratio === '2:3') {
2915
+ return this.#renderStackedBarHorizontal(dims, labels, keys, data, maxVal, ticks);
2916
+ }
2917
+
2077
2918
  const { width, height, pad } = dims;
2078
2919
  const plotH = height - pad.top - pad.bottom;
2079
2920
  const plotW = width - pad.left - pad.right;
@@ -2081,20 +2922,36 @@ export class UIChart extends UIElement {
2081
2922
  const barInner = barW * 0.6;
2082
2923
  const barGap = (barW - barInner) / 2;
2083
2924
 
2084
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
2925
+ // REQ-R-003 (stacked-bar, 1:1 "square"): abbreviate category labels
2926
+ // same truncation treatment as bar@1:1's own square-container cell.
2927
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
2928
+ abbreviateLabels: ratio === '1:1',
2929
+ });
2930
+
2931
+ // Baseline (value-0 line) shifts up when a pinned yMin lands below zero
2932
+ // — segments still stack additively by raw value, only the pixel scale
2933
+ // and the zero-line's position account for the pinned domain.
2934
+ const stackScale = (maxVal - minVal) ? plotH / (maxVal - minVal) : 0;
2935
+ // gh#1693 review — a pinned yMax lower than the actual stacked total
2936
+ // must not let the cumulative stack burst above the plot's top edge
2937
+ // (svg overflow:visible bleeds an unclamped span into page content).
2938
+ // The baseline itself is clamped defensively too (an extreme yMin
2939
+ // could otherwise place it above pad.top).
2940
+ const stackBaselineY = this.#clampCoord(pad.top + plotH + minVal * stackScale, pad.top, pad.top + plotH);
2085
2941
 
2086
2942
  for (let i = 0; i < data.length; i++) {
2087
- let stackY = pad.top + plotH;
2943
+ let stackY = stackBaselineY;
2088
2944
  const segCount = keys.length;
2089
2945
  for (let k = 0; k < segCount; k++) {
2090
2946
  if (this.#isSeriesHidden(keys[k])) continue;
2091
2947
  const v = +(data[i][keys[k]] ?? 0);
2092
- const segH = maxVal ? (v / maxVal) * plotH : 0;
2093
- if (segH <= 0) { stackY -= segH; continue; }
2094
- stackY -= segH;
2948
+ const rawSegH = v * stackScale;
2949
+ if (rawSegH <= 0) { stackY = this.#clampCoord(stackY - rawSegH, pad.top, pad.top + plotH); continue; }
2950
+ const clampedTop = Math.max(stackY - rawSegH, pad.top);
2951
+ const bh = stackY - clampedTop;
2095
2952
  const bx = pad.left + barW * i + barGap;
2096
- const by = stackY;
2097
- const bh = segH;
2953
+ const by = clampedTop;
2954
+ stackY = clampedTop;
2098
2955
  const isTop = k === segCount - 1;
2099
2956
  const r = this.#resolveRadius();
2100
2957
 
@@ -2113,7 +2970,77 @@ export class UIChart extends UIElement {
2113
2970
  }
2114
2971
  }
2115
2972
 
2116
- this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
2973
+ return { svg, viewBox: `0 0 ${width} ${height}` };
2974
+ }
2975
+
2976
+ /* REQ-R-003 (stacked-bar, 2:3) — horizontal mirror of the vertical
2977
+ renderer above: categories run top-to-bottom on the Y axis, each
2978
+ category's segments stack LEFT-TO-RIGHT (still additively, same
2979
+ stacking semantics — only the axis swaps, per bar's own #renderBar
2980
+ ↔ #renderBarHorizontal precedent). */
2981
+ #renderStackedBarHorizontal(dims, labels, keys, data, maxVal, ticks) {
2982
+ const { width, height } = dims;
2983
+ const fs = dims.fontSize;
2984
+ const ls = dims.labelSize;
2985
+ const noGrid = this.noGrid;
2986
+
2987
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
2988
+ const leftGutter = noGrid ? 2 : Math.min(width * 0.42, Math.max(fs * 3, maxChars * ls * 0.55 + ls));
2989
+ const pad = {
2990
+ top: fs * 0.8,
2991
+ right: fs * 1.8,
2992
+ bottom: noGrid ? 2 : fs * 1.8,
2993
+ left: leftGutter,
2994
+ };
2995
+ const plotW = width - pad.left - pad.right;
2996
+ const plotH = height - pad.top - pad.bottom;
2997
+ const barH = plotH / labels.length;
2998
+ const barInner = barH * 0.6;
2999
+ const barGap = (barH - barInner) / 2;
3000
+ const minVal = ticks[0];
3001
+ const stackScale = (maxVal - minVal) ? plotW / (maxVal - minVal) : 0;
3002
+ // gh#1693 review — mirrors the vertical stacked-bar clamp: a pinned
3003
+ // yMax lower than the actual stacked total must not let the
3004
+ // cumulative stack burst past the plot's right edge.
3005
+ const stackBaselineX = this.#clampCoord(pad.left - minVal * stackScale, pad.left, pad.left + plotW);
3006
+
3007
+ let svg = '';
3008
+ if (!noGrid) {
3009
+ const tickRange = (ticks[ticks.length - 1] - ticks[0]) || 1;
3010
+ for (const t of ticks) {
3011
+ const gx = pad.left + plotW * ((t - ticks[0]) / tickRange);
3012
+ svg += `<line data-chart-grid x1="${gx}" y1="${pad.top}" x2="${gx}" y2="${height - pad.bottom}"/>`;
3013
+ svg += `<text data-y-label x="${gx}" y="${height - pad.bottom + fs * 1.3}" text-anchor="middle" font-size="${fs}">${this.#fmtValue(t)}</text>`;
3014
+ }
3015
+ }
3016
+
3017
+ for (let i = 0; i < labels.length; i++) {
3018
+ const by = pad.top + barH * i + barGap;
3019
+ svg += `<text data-x-label x="${pad.left - 6}" y="${by + barInner / 2 + fs * 0.35}" text-anchor="end" font-size="${ls}">${esc(labels[i])}</text>`;
3020
+
3021
+ let stackX = stackBaselineX;
3022
+ const segCount = keys.length;
3023
+ for (let k = 0; k < segCount; k++) {
3024
+ if (this.#isSeriesHidden(keys[k])) continue;
3025
+ const v = +(data[i][keys[k]] ?? 0);
3026
+ const rawSegW = v * stackScale;
3027
+ if (rawSegW <= 0) continue;
3028
+ const bx = stackX;
3029
+ const clampedEnd = Math.min(stackX + rawSegW, pad.left + plotW);
3030
+ const segW = clampedEnd - bx;
3031
+ stackX = clampedEnd;
3032
+ const isEnd = k === segCount - 1;
3033
+ const attrs = `${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })}`;
3034
+
3035
+ if (isEnd) {
3036
+ // End segment (or single segment) — round the value-end corners
3037
+ // only, mirroring #renderStackedBar's own top-corner rounding.
3038
+ svg += `<path${attrs} d="${rightRoundedBarPath(bx, by, segW, barInner, this.#resolveRadius())}"/>`;
3039
+ } else {
3040
+ svg += `<rect${attrs} x="${bx}" y="${by}" width="${segW}" height="${barInner}"/>`;
3041
+ }
3042
+ }
3043
+ }
2117
3044
 
2118
3045
  return { svg, viewBox: `0 0 ${width} ${height}` };
2119
3046
  }
@@ -2126,10 +3053,25 @@ export class UIChart extends UIElement {
2126
3053
  const labels = data.map(d => d[this.x] ?? '');
2127
3054
 
2128
3055
  const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
2129
- const ticks = niceScale(0, Math.max(...allVals), 5);
3056
+ const ticks = niceScale(...this.#yBounds(allVals), 5);
2130
3057
  const maxVal = ticks[ticks.length - 1];
3058
+ const minVal = ticks[0];
2131
3059
 
2132
3060
  const dims = this.#dims();
3061
+ const ratio = dims.ratioResolved;
3062
+
3063
+ this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
3064
+
3065
+ // REQ-R-003 (grouped-bar, 2:3) — "groups rotate to horizontal bars
3066
+ // stacked by category": the group transposes the same way bar/
3067
+ // stacked-bar do at 2:3 — each category becomes a ROW, and within
3068
+ // that row every series still gets its own sub-bar (grouped, not
3069
+ // additive) — just arranged top-to-bottom in the row instead of
3070
+ // side-by-side in a column.
3071
+ if (ratio === '2:3') {
3072
+ return this.#renderGroupedBarHorizontal(dims, labels, keys, data, maxVal, ticks);
3073
+ }
3074
+
2133
3075
  const { width, height, pad } = dims;
2134
3076
  const plotH = height - pad.top - pad.bottom;
2135
3077
  const plotW = width - pad.left - pad.right;
@@ -2139,13 +3081,20 @@ export class UIChart extends UIElement {
2139
3081
  const subBarW = (totalBarSpace - barGap * (keys.length - 1)) / keys.length;
2140
3082
  const groupPad = (groupW - totalBarSpace) / 2;
2141
3083
 
2142
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
3084
+ // REQ-R-003 (grouped-bar, 1:1) "groups compress (bar width narrows
3085
+ // before category count drops)": category labels abbreviate first
3086
+ // (same treatment as bar/stacked-bar's own 1:1 cells), freeing width
3087
+ // for the bars themselves rather than ever dropping a category.
3088
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, {
3089
+ abbreviateLabels: ratio === '1:1',
3090
+ });
2143
3091
 
2144
3092
  for (let i = 0; i < data.length; i++) {
2145
3093
  for (let k = 0; k < keys.length; k++) {
2146
3094
  if (this.#isSeriesHidden(keys[k])) continue;
2147
3095
  const v = +(data[i][keys[k]] ?? 0);
2148
- const barH = maxVal ? (v / maxVal) * plotH : 0;
3096
+ const rawH = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0;
3097
+ const barH = this.#clampSpan(rawH, plotH);
2149
3098
  const bx = pad.left + groupW * i + groupPad + (subBarW + barGap) * k;
2150
3099
  const by = pad.top + plotH - barH;
2151
3100
  svg += `<path${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${topRoundedBarPath(bx, by, subBarW, barH, this.#resolveRadius())}"/>`;
@@ -2156,7 +3105,65 @@ export class UIChart extends UIElement {
2156
3105
  }
2157
3106
  }
2158
3107
 
2159
- this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
3108
+ return { svg, viewBox: `0 0 ${width} ${height}` };
3109
+ }
3110
+
3111
+ /* REQ-R-003 (grouped-bar, 2:3) — horizontal mirror of the vertical
3112
+ renderer above: categories run top-to-bottom on the Y axis, and each
3113
+ category's own K series get their own sub-bar ROW within that
3114
+ category's band (never summed — grouped semantics preserved, only
3115
+ the axis transposes, same as #renderStackedBar's additive stack
3116
+ staying additive across its own horizontal mirror). */
3117
+ #renderGroupedBarHorizontal(dims, labels, keys, data, maxVal, ticks) {
3118
+ const { width, height } = dims;
3119
+ const fs = dims.fontSize;
3120
+ const ls = dims.labelSize;
3121
+ const noGrid = this.noGrid;
3122
+
3123
+ const maxChars = labels.reduce((m, l) => Math.max(m, String(l).length), 1);
3124
+ const leftGutter = noGrid ? 2 : Math.min(width * 0.42, Math.max(fs * 3, maxChars * ls * 0.55 + ls));
3125
+ const pad = {
3126
+ top: fs * 0.8,
3127
+ right: fs * 1.8,
3128
+ bottom: noGrid ? 2 : fs * 1.8,
3129
+ left: leftGutter,
3130
+ };
3131
+ const plotW = width - pad.left - pad.right;
3132
+ const plotH = height - pad.top - pad.bottom;
3133
+ const groupH = plotH / labels.length;
3134
+ const barGap = 3;
3135
+ const totalBarSpace = groupH * 0.7;
3136
+ const subBarH = (totalBarSpace - barGap * (keys.length - 1)) / keys.length;
3137
+ const groupPad = (groupH - totalBarSpace) / 2;
3138
+ const minVal = ticks[0];
3139
+
3140
+ let svg = '';
3141
+ if (!noGrid) {
3142
+ const tickRange = (ticks[ticks.length - 1] - ticks[0]) || 1;
3143
+ for (const t of ticks) {
3144
+ const gx = pad.left + plotW * ((t - ticks[0]) / tickRange);
3145
+ svg += `<line data-chart-grid x1="${gx}" y1="${pad.top}" x2="${gx}" y2="${height - pad.bottom}"/>`;
3146
+ svg += `<text data-y-label x="${gx}" y="${height - pad.bottom + fs * 1.3}" text-anchor="middle" font-size="${fs}">${this.#fmtValue(t)}</text>`;
3147
+ }
3148
+ }
3149
+
3150
+ for (let i = 0; i < labels.length; i++) {
3151
+ const groupY = pad.top + groupH * i;
3152
+ svg += `<text data-x-label x="${pad.left - 6}" y="${groupY + groupH / 2 + fs * 0.35}" text-anchor="end" font-size="${ls}">${esc(labels[i])}</text>`;
3153
+
3154
+ for (let k = 0; k < keys.length; k++) {
3155
+ if (this.#isSeriesHidden(keys[k])) continue;
3156
+ const v = +(data[i][keys[k]] ?? 0);
3157
+ const rawW = (maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotW : 0;
3158
+ const barW = this.#clampSpan(rawW, plotW);
3159
+ const by = groupY + groupPad + (subBarH + barGap) * k;
3160
+ svg += `<path${this.#seriesFill(k % 10, keys[k])}${tip({ label: labels[i], value: v, series: keys[k] })} d="${rightRoundedBarPath(pad.left, by, barW, subBarH, this.#resolveRadius())}"/>`;
3161
+
3162
+ if (!this.noValues) {
3163
+ svg += `<text data-value x="${pad.left + barW + 4}" y="${by + subBarH / 2 + fs * 0.35}" text-anchor="start" font-size="${dims.valueSize}">${this.#fmtValue(v)}</text>`;
3164
+ }
3165
+ }
3166
+ }
2160
3167
 
2161
3168
  return { svg, viewBox: `0 0 ${width} ${height}` };
2162
3169
  }
@@ -2169,23 +3176,42 @@ export class UIChart extends UIElement {
2169
3176
  const labels = data.map(d => d[this.x] ?? '');
2170
3177
 
2171
3178
  const allVals = data.flatMap(d => keys.map(k => +(d[k] ?? 0)));
2172
- const ticks = niceScale(0, Math.max(...allVals), 5);
3179
+ const ticks = niceScale(...this.#yBounds(allVals), 5);
2173
3180
  const maxVal = ticks[ticks.length - 1];
3181
+ const minVal = ticks[0];
2174
3182
 
2175
3183
  const dims = this.#dims();
3184
+ const ratio = dims.ratioResolved;
2176
3185
  const { width, height, pad } = dims;
2177
3186
  const plotH = height - pad.top - pad.bottom;
2178
3187
  const plotW = width - pad.left - pad.right;
2179
3188
  const step = plotW / Math.max(data.length - 1, 1);
2180
3189
 
2181
- let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims);
3190
+ let svg = this.#gridAndAxes(width, height, ticks, labels, pad, dims, { pointAligned: true });
3191
+
3192
+ // REQ-R-003 (multi-line) — a progressive de-emphasis across the three
3193
+ // ratios rather than one fixed identification mechanism:
3194
+ // 3:2 "all series labeled inline at line end (no legend needed)" —
3195
+ // each series' terminal point gets its own key label; the
3196
+ // ordinary below-chart legend is suppressed (chart.css) since
3197
+ // these end-labels already serve that job.
3198
+ // 1:1 "inline end-labels replaced by a compact legend below" — end
3199
+ // labels drop, the ordinary legend (unsuppressed at this ratio)
3200
+ // takes over.
3201
+ // 2:3 "end-labels dropped entirely, legend is the only series key" —
3202
+ // same as 1:1's own legend-only fallback; named as its own cell
3203
+ // in the spec because the OTHER identification path (inline
3204
+ // labels) narrows away first at 1:1 and there's nothing left to
3205
+ // drop further at 2:3 — the legend was already the sole path.
3206
+ const inlineEndLabels = ratio === '3:2';
2182
3207
 
2183
3208
  for (let k = 0; k < keys.length; k++) {
2184
3209
  if (this.#isSeriesHidden(keys[k])) continue;
2185
3210
  const vals = data.map(d => +(d[keys[k]] ?? 0));
2186
3211
  const points = vals.map((v, i) => {
2187
3212
  const px = pad.left + step * i;
2188
- const py = pad.top + plotH - (maxVal ? (v / maxVal) * plotH : 0);
3213
+ const rawY = pad.top + plotH - ((maxVal - minVal) ? ((v - minVal) / (maxVal - minVal)) * plotH : 0);
3214
+ const py = this.#clampCoord(rawY, pad.top, pad.top + plotH);
2189
3215
  return { x: px, y: py, v, label: labels[i] };
2190
3216
  });
2191
3217
 
@@ -2218,6 +3244,11 @@ export class UIChart extends UIElement {
2218
3244
  }
2219
3245
  svg += `<circle data-hit${tip({ label: p.label, value: p.v, series: keys[k] })} cx="${p.x}" cy="${p.y}" r="10" fill="transparent"/>`;
2220
3246
  });
3247
+
3248
+ if (inlineEndLabels && points.length) {
3249
+ const last = points[points.length - 1];
3250
+ svg += `<text data-series-end-label${this.#seriesFill(k % 10, keys[k])} x="${last.x + 6}" y="${last.y}" text-anchor="start" dominant-baseline="central" font-size="${dims.labelSize}">${esc(keys[k])}</text>`;
3251
+ }
2221
3252
  }
2222
3253
 
2223
3254
  this.#legendData = keys.map((k, i) => ({ label: k, key: k, slot: i % 10 }));
@@ -2251,7 +3282,18 @@ export class UIChart extends UIElement {
2251
3282
  const dot = document.createElement('span');
2252
3283
  dot.setAttribute('data-legend-dot', '');
2253
3284
  dot.setAttribute('data-slice', String(item.slot));
2254
- if (item.key) dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
3285
+ // CodeRabbit (PR #1662) a de-emphasized series (composed@1:1's
3286
+ // secondary line, REQ-R-007) draws in the muted neutral token, not
3287
+ // its categorical palette slot; the legend dot must match what was
3288
+ // actually drawn, and skip the --color-{key} override hook here
3289
+ // too (a consumer's override is for the CATEGORICAL reading, which
3290
+ // this ratio deliberately isn't showing).
3291
+ if (item.deemphasized) {
3292
+ dot.setAttribute('data-deemphasized-tint', '');
3293
+ dot.style.background = 'var(--md-sys-color-neutral-on-surface-variant)';
3294
+ } else if (item.key) {
3295
+ dot.style.background = `var(--color-${item.key}, var(--chart-${item.slot}))`;
3296
+ }
2255
3297
  el.appendChild(dot);
2256
3298
 
2257
3299
  const text = document.createElement('span');