@micha.bigler/ui-core-micha 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useId, useRef, useState } from 'react';
1
+ import React, { useId, useRef, useState } from 'react';
2
2
  import {
3
3
  Alert,
4
4
  Box,
@@ -11,38 +11,27 @@ import {
11
11
  import { useTranslation } from 'react-i18next';
12
12
  import { useTheme } from '@mui/material/styles';
13
13
  import { exportChartPng, exportChartSvg } from './exportChart';
14
+ import { assertRemovedChartProp } from './chartDefaults';
14
15
 
15
16
  export const CHART_FRAME_ROOT_SX = { p: 2 };
16
17
  export const CHART_FRAME_ALERT_SX = { mt: 2 };
17
18
 
18
- /**
19
- * UCM-CHART-12: `warnOnHeightMismatch` (the shared helper this note used to call into
20
- * `chartDefaults`) was deleted along with `resolveChartHeight` -- both belonged to the
21
- * `{minHeight, height, aspect}` trio the four chart presets used to accept, which that WO
22
- * replaced with `resolveChartLayout`'s `size`/`height` model. `ChartFrame` keeps its OWN
23
- * `minHeight`/`height`/`aspect` props unchanged (CHART-9: it is a card floor, not a chart size),
24
- * so it keeps this note too -- same text, same condition, just no longer routed through the
25
- * now-deleted shared function.
26
- */
27
- function warnOnChartFrameHeightMismatch(minHeight, height) {
28
- if (process.env.NODE_ENV === 'production') return;
29
- if (minHeight == null || height == null || minHeight === height) return;
30
- // eslint-disable-next-line no-console
31
- console.warn(
32
- `[ui-core-micha] <ChartFrame> received both minHeight={${minHeight}} and height={${height}}; `
33
- + 'they disagree; height is ignored here and minHeight keeps sizing the wrapper as a floor. '
34
- + 'Pass only minHeight, or drop height if it was meant to size the whole card.',
35
- );
36
- }
37
-
38
19
  /**
39
20
  * Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
40
21
  *
41
22
  * `minHeight` (CHART-9): always just a floor on the content box, which holds a whole card --
42
- * title, toolbar, chart, legend, footnotes, export links -- not a single chart. `height` is never
43
- * applied to the box (unlike the four chart presets, whose box holds exactly one chart and use
44
- * `resolveChartLayout`'s `size`/`height` resolution); it is only compared against `minHeight` for
45
- * this component's own dev-mode mismatch note.
23
+ * title, toolbar, chart, legend, footnotes, export links -- not a single chart.
24
+ *
25
+ * UCM-CHART-13: `height` and `aspect` are GONE. `height` was destructured and only ever fed a
26
+ * dev-mode mismatch warning, never applied to the box -- genuinely dead. `aspect` is a DIFFERENT
27
+ * case (codex review finding, corrected from this WO's own Envelope, which claimed it too was
28
+ * applied nowhere): it WAS wired to `aspectRatio` on the box at 3.0.1, and worked. It is removed
29
+ * anyway, because no consumer across the five apps passes it (measured against 3.0.1) and
30
+ * `ChartFrame` accepting a sizing prop it is the only one of the four+frame components to apply is
31
+ * exactly the kind of inconsistency this WO exists to close -- a working-but-unused prop is still
32
+ * unused surface, just not dead code the way `height` was. Passing either now throws in dev via the
33
+ * same `assertRemovedChartProp` the four chart presets use (`UCM-CHART-12`), naming `minHeight` (a
34
+ * card floor) or the chart's own `size` prop as the replacement.
46
35
  */
47
36
  export function ChartFrame({
48
37
  title,
@@ -54,8 +43,6 @@ export function ChartFrame({
54
43
  isEmpty = false,
55
44
  emptyMessage,
56
45
  minHeight,
57
- height,
58
- aspect,
59
46
  exportOptions = false,
60
47
  onExportSvg,
61
48
  onExportPng,
@@ -65,6 +52,10 @@ export function ChartFrame({
65
52
  variant = 'outlined',
66
53
  titleVariant = 'h6',
67
54
  sx,
55
+ // UCM-CHART-13: `height`/`aspect` are gone from the signature itself (DoD) -- captured via rest,
56
+ // not named parameters, so they read as "detected leftovers", not "accepted props" a reader might
57
+ // assume are still wired to something.
58
+ ...legacyProps
68
59
  }) {
69
60
  const { t } = useTranslation();
70
61
  const theme = useTheme();
@@ -78,9 +69,15 @@ export function ChartFrame({
78
69
  const chartLabel = ariaLabel || title;
79
70
  const callerSx = Array.isArray(sx) ? sx : [sx];
80
71
 
81
- useEffect(
82
- () => warnOnChartFrameHeightMismatch(minHeight, height),
83
- [minHeight, height],
72
+ assertRemovedChartProp(
73
+ 'ChartFrame', 'height', legacyProps.height,
74
+ 'Use minHeight for the card floor, or size on the chart inside the frame if you meant the chart\'s own height.',
75
+ 'UCM-CHART-13, v3.1.0',
76
+ );
77
+ assertRemovedChartProp(
78
+ 'ChartFrame', 'aspect', legacyProps.aspect,
79
+ 'Removed -- it was never applied to the frame. Use size on the chart inside the frame if you meant the chart\'s own aspect.',
80
+ 'UCM-CHART-13, v3.1.0',
84
81
  );
85
82
 
86
83
  const runExport = async (type) => {
@@ -136,7 +133,6 @@ export function ChartFrame({
136
133
  sx={{
137
134
  width: '100%',
138
135
  minHeight,
139
- aspectRatio: aspect,
140
136
  display: 'flex',
141
137
  alignItems: 'center',
142
138
  justifyContent: 'center',
@@ -99,6 +99,7 @@ export function LineChart({
99
99
  xLabels,
100
100
  hideLegend,
101
101
  legendPosition,
102
+ slotProps,
102
103
  tickFontSize: theme.typography.caption.fontSize,
103
104
  spacing: theme.spacing,
104
105
  defaultLineHeight: theme.typography.caption.lineHeight,
@@ -230,6 +230,7 @@ export function ScatterChart({
230
230
  xLabels,
231
231
  hideLegend,
232
232
  legendPosition,
233
+ slotProps,
233
234
  tickFontSize: theme.typography.caption.fontSize,
234
235
  spacing: theme.spacing,
235
236
  defaultLineHeight: theme.typography.caption.lineHeight,
@@ -247,7 +247,15 @@ export function sizeYAxisForContent(yAxis, series, tickFontSize) {
247
247
  if (candidates.length === 0) return axis;
248
248
 
249
249
  const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
250
- const tickTextWidth = Math.max(
250
+ // UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed, but a caller
251
+ // `valueFormatter` reduced every one of them to a blank string -- this is the y-axis analogue
252
+ // of `xAxisHasVisibleTicks`'s Access-scatter case, and must collapse the same way. Distinct
253
+ // from `candidates.length === 0` above (nothing to measure at all, MUI's own default applies,
254
+ // untouched): here there WAS content, it just renders as nothing. With no axis title either,
255
+ // the axis has genuinely nothing to show, so the whole band collapses to 0 -- not merely the
256
+ // text-width term floored, matching the x-axis band's own all-or-nothing zero.
257
+ if (longestTick.length === 0 && !axis.label) return { ...axis, width: 0 };
258
+ const tickTextWidth = longestTick.length === 0 ? 0 : Math.max(
251
259
  tickFontSizePx,
252
260
  longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM,
253
261
  );
@@ -316,11 +324,16 @@ const LEGEND_BAND_PX = 32;
316
324
  // UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
317
325
  // render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
318
326
  // pure function cannot see), "auto" is a documented HEURISTIC, not a true fit computation: rotate
319
- // once there are enough categories AND the longest one is long enough that a horizontal row would
320
- // plausibly collide. The mandatory browser-level check (WO Part C) is what actually verifies fit
321
- // against real rendered geometry; this only picks a reasonable default ahead of that.
327
+ // once there are enough categories AND the longest one, MEASURED (same mechanism as the rotated
328
+ // projection below -- real DOM measurement when available, the per-glyph estimate otherwise), is
329
+ // wide enough that a horizontal row would plausibly collide. Codex second-pass review finding:
330
+ // the first cut of this heuristic used a raw character-count threshold instead of the measured
331
+ // width this function already has available -- replaced, since "decide from the measured labels"
332
+ // (Rule 3) is achievable even without a real container width to compare against. The count
333
+ // threshold stays as the OTHER half of the heuristic: a handful of wide labels can still fit
334
+ // spread across a wide chart, so width alone should not trigger rotation either.
322
335
  const AUTO_ROTATE_TICK_COUNT = 6;
323
- const AUTO_ROTATE_LABEL_CHARS = 4;
336
+ const AUTO_ROTATE_LABEL_WIDTH_PX = 50;
324
337
 
325
338
  function xAxisTickContentText(axis) {
326
339
  const data = axis?.data;
@@ -351,12 +364,23 @@ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextW
351
364
  const fontSize = cssLengthToPixels(axis?.tickLabelStyle?.fontSize) || cssLengthToPixels(fallbackTickFontSize);
352
365
  const longestLabel = xAxisTickContentText(axis) || '';
353
366
  const dataCount = Array.isArray(axis?.data) ? axis.data.length : 0;
367
+ const measure = measureTextWidth || measureTickTextWidthPx;
368
+
369
+ // Measured once, up front -- both "auto"'s rotation DECISION and the rotated-band PROJECTION
370
+ // below need the label's real width, and must agree on what "real" means (same measurement,
371
+ // same estimate fallback) or the two could disagree about the same label.
372
+ const labelWidthPx = fontSize > 0
373
+ ? Math.max(fontSize, (() => {
374
+ const measured = measure(longestLabel, fontSize);
375
+ return Number.isFinite(measured) && measured > 0 ? measured : estimateTextWidthPx(longestLabel, fontSize);
376
+ })())
377
+ : 0;
354
378
 
355
379
  let angle = 0;
356
380
  if (xLabelsMode === 'angled') {
357
381
  angle = Number(axis?.tickLabelStyle?.angle) || -45;
358
382
  } else if (xLabelsMode === 'auto') {
359
- const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && longestLabel.length > AUTO_ROTATE_LABEL_CHARS;
383
+ const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && labelWidthPx > AUTO_ROTATE_LABEL_WIDTH_PX;
360
384
  angle = needsRotation ? -45 : 0;
361
385
  } // "horizontal" always stays at angle 0
362
386
 
@@ -367,14 +391,7 @@ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextW
367
391
  if (fontSize === 0) return { tickBand: TICK_BAND_BASE_PX, angle };
368
392
 
369
393
  const lineHeight = Number(axis?.tickLabelStyle?.lineHeight) || defaultLineHeight;
370
- const measure = measureTextWidth || measureTickTextWidthPx;
371
- const measuredWidth = measure(longestLabel, fontSize);
372
- const textWidth = Math.max(
373
- fontSize,
374
- Number.isFinite(measuredWidth) && measuredWidth > 0
375
- ? measuredWidth
376
- : estimateTextWidthPx(longestLabel, fontSize),
377
- );
394
+ const textWidth = labelWidthPx;
378
395
  const radians = (angle * Math.PI) / 180;
379
396
  const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
380
397
  + Math.abs(Math.sin(radians)) * textWidth;
@@ -434,6 +451,7 @@ export function resolveChartLayout({
434
451
  xLabels = 'auto',
435
452
  hideLegend = false,
436
453
  legendPosition = DEFAULT_LEGEND_POSITION,
454
+ slotProps,
437
455
  tickFontSize,
438
456
  spacing,
439
457
  defaultLineHeight = 1,
@@ -449,33 +467,50 @@ export function resolveChartLayout({
449
467
  );
450
468
  const xTitleBand = primaryXAxis?.label ? AXIS_TITLE_BAND_PX : 0;
451
469
 
470
+ // Codex second-pass review finding: a caller-set `slotProps.legend.position` overrides
471
+ // `legendPosition` at RENDER time (`withChartSlotDefaults`) but was invisible here, so the
472
+ // margin/height this function reserved could disagree with where the legend actually rendered.
473
+ // `resolveLegendPosition` is the one merge both call sites now share.
474
+ const effectiveLegendPosition = resolveLegendPosition(legendPosition, slotProps);
475
+
452
476
  // A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
453
477
  // finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
454
478
  // over-reserving height and under-reserving width for a side-placed legend. This WO's width
455
479
  // invariant does not itemize a legend-width band, so a side legend simply gets none of the
456
480
  // height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
457
481
  // legend is off or horizontal").
458
- const legendVertical = legendPosition?.vertical ?? DEFAULT_LEGEND_POSITION.vertical;
482
+ const legendVertical = effectiveLegendPosition.vertical;
459
483
  const legendIsSidePlaced = legendVertical === 'middle';
460
484
  const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
461
485
  const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
462
486
 
463
487
  const sizedYAxis = sizeYAxisForContent(yAxis?.length ? yAxis : [{}], series, tickFontSize);
464
- const yAxisBand = sizedYAxis[0]?.width ?? 0;
488
+ // Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's `axis: 'secondary'`
489
+ // feature, CHART-5) has a SECOND y-axis on the right, consuming its own width -- the resolver
490
+ // only ever accounted for `sizedYAxis[0]`. `position` defaults to `'left'` for an unmarked axis
491
+ // (matching MUI's own default-assignment rule, same as `sizeYAxisForContent`'s own series-match
492
+ // logic above), so only an explicit `'right'` counts as the secondary axis.
493
+ const primaryYAxis = sizedYAxis.find((axisEntry) => axisEntry.position !== 'right') ?? sizedYAxis[0];
494
+ const secondaryYAxisEntry = sizedYAxis.find((axisEntry) => axisEntry.position === 'right');
495
+ const yAxisBand = primaryYAxis?.width ?? 0;
496
+ const secondaryYAxisBand = secondaryYAxisEntry?.width ?? 0;
465
497
  const rightPad = PACKAGE_DEFAULT_MARGIN.right;
466
498
 
467
499
  const resolvedXAxis = xAxisList.map((axisEntry, index) => {
468
- if (index !== 0 || axisEntry.height != null) return axisEntry;
500
+ if (index !== 0) return axisEntry;
469
501
  // `xLabels` is the SOLE source of truth for rotation (Rule 3) -- `angle` is always written
470
- // explicitly here, even when 0, rather than only overriding a truthy value. Leaving a stale
471
- // caller-supplied `tickLabelStyle.angle` in place when `xLabels` resolved to no rotation would
472
- // let MUI still render a rotated label whose band this resolver reserved as if it were flat --
473
- // exactly the space/render mismatch this WO exists to close, just inverted (under-reservation
474
- // instead of CHART-10's over-reservation).
502
+ // explicitly here, even when 0, rather than only overriding a truthy value, and regardless of
503
+ // whether the caller also set an explicit `height` (own `height` bypasses the model's SIZE,
504
+ // never its ROTATION -- codex second-pass review finding: the two were previously coupled,
505
+ // so a caller-set `height` could silently keep a stale rotated `tickLabelStyle.angle` even
506
+ // under `xLabels="horizontal"`). Leaving a stale caller-supplied angle in place when `xLabels`
507
+ // resolved to no rotation would let MUI still render a rotated label whose band this resolver
508
+ // reserved as if it were flat -- exactly the space/render mismatch this WO exists to close,
509
+ // just inverted (under-reservation instead of CHART-10's over-reservation).
475
510
  const { angle: _callerAngle, ...restTickLabelStyle } = axisEntry.tickLabelStyle || {};
476
511
  return {
477
512
  ...axisEntry,
478
- height: xAxisBand + xTitleBand,
513
+ height: axisEntry.height ?? (xAxisBand + xTitleBand),
479
514
  tickLabelStyle: { ...restTickLabelStyle, angle },
480
515
  };
481
516
  });
@@ -484,14 +519,16 @@ export function resolveChartLayout({
484
519
  top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
485
520
  bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
486
521
  left: PACKAGE_DEFAULT_MARGIN.left,
487
- right: rightPad,
522
+ right: rightPad + secondaryYAxisBand,
488
523
  };
489
524
 
490
525
  // `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
491
526
  // furniture bands (see this function's own docblock for why this alone does not prove the
492
527
  // absence of a double-counted term, and what does).
493
528
  const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
494
- const plotWidth = containerWidth != null ? containerWidth - yAxisBand - rightPad : undefined;
529
+ const plotWidth = containerWidth != null
530
+ ? containerWidth - yAxisBand - secondaryYAxisBand - rightPad
531
+ : undefined;
495
532
 
496
533
  return {
497
534
  chartHeight,
@@ -502,6 +539,7 @@ export function resolveChartLayout({
502
539
  bands: {
503
540
  plotHeight,
504
541
  xAxisBand,
542
+ secondaryYAxisBand,
505
543
  xTitleBand,
506
544
  legendBand: legendHeightBand,
507
545
  yAxisBand,
@@ -518,21 +556,41 @@ export function resolveChartLayout({
518
556
  * migration path instead of a layout that quietly stops matching what the prop says) -- gated on
519
557
  * `NODE_ENV !== 'production'` like the deleted `warnOnHeightMismatch` was, so a shared package
520
558
  * cannot crash a consumer's production page over a layout prop; loud in dev, inert in prod.
559
+ *
560
+ * `removedIn` (codex review finding, UCM-CHART-13): a shared helper hardcoding ONE WO/version
561
+ * string would misattribute the removal for every OTHER call site that reuses it -- `ChartFrame`
562
+ * (UCM-CHART-13, v3.1.0) removes `height`/`aspect` for a different reason, in a different WO, than
563
+ * the four presets' own `minHeight`/`aspect`/`margin` (UCM-CHART-12, v3.0.0). The default keeps
564
+ * every existing call site's message byte-identical; only a NEW call site needs to override it.
521
565
  */
522
- export function assertRemovedChartProp(componentName, propName, value, replacement) {
566
+ export function assertRemovedChartProp(componentName, propName, value, replacement, removedIn = 'UCM-CHART-12, v3.0.0') {
523
567
  if (value === undefined || process.env.NODE_ENV === 'production') return;
524
568
  throw new Error(
525
- `[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in UCM-CHART-12, `
526
- + `v3.0.0 -- see docs/CHART-LAYOUT.md). ${replacement}`,
569
+ `[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in ${removedIn} `
570
+ + `-- see docs/CHART-LAYOUT.md). ${replacement}`,
527
571
  );
528
572
  }
529
573
 
530
- export function withChartSlotDefaults(slotProps, legendPosition) {
531
- const position = {
574
+ /**
575
+ * UCM-CHART-12 (codex second-pass review finding): the single source of truth for where the
576
+ * legend actually renders -- `legendPosition` merged with a `slotProps.legend.position` override,
577
+ * same precedence `withChartSlotDefaults` already applied. Before this fix, `resolveChartLayout`
578
+ * only ever looked at `legendPosition` when deciding how much space to reserve and on which side,
579
+ * while `withChartSlotDefaults` (below) separately let `slotProps.legend.position` win at RENDER
580
+ * time -- a caller passing both could get a legend rendered top/side while the resolver had
581
+ * reserved space for bottom (or none), the exact reserved-vs-rendered mismatch this WO exists to
582
+ * close. Both call sites now merge through this one function.
583
+ */
584
+ export function resolveLegendPosition(legendPosition, slotProps) {
585
+ return {
532
586
  ...DEFAULT_LEGEND_POSITION,
533
587
  ...legendPosition,
534
588
  ...slotProps?.legend?.position,
535
589
  };
590
+ }
591
+
592
+ export function withChartSlotDefaults(slotProps, legendPosition) {
593
+ const position = resolveLegendPosition(legendPosition, slotProps);
536
594
  return {
537
595
  ...slotProps,
538
596
  tooltip: {
@@ -213,10 +213,8 @@ describe('ChartFrame', () => {
213
213
  });
214
214
 
215
215
  // CHART-9: ChartFrame's content box is a whole CARD (title, toolbar, chart, legend, footnotes,
216
- // export links), not a single chart -- unlike the four chart presets, which do apply
217
- // resolveChartHeight's height/aspect resolution to their own box. ChartFrame's box must never
218
- // take a fixed `height`; `minHeight` is always just a floor content can exceed.
219
- describe('minHeight/height resolution (CHART-9)', () => {
216
+ // export links), not a single chart. `minHeight` is always just a floor content can exceed.
217
+ describe('minHeight resolution (CHART-9)', () => {
220
218
  function contentBoxStyle() {
221
219
  return window.getComputedStyle(screen.getByTestId('chart-body').parentElement);
222
220
  }
@@ -245,37 +243,56 @@ describe('ChartFrame', () => {
245
243
  expect(style.height).toBe('auto');
246
244
  });
247
245
 
248
- it('leaves minHeight as a floor and gives no fixed height when aspect is set (no height)', () => {
249
- renderFrame({ minHeight: 320, aspect: 1.8 });
246
+ it('leaves minHeight unset (auto) when the caller passes none', () => {
247
+ renderFrame();
250
248
  const style = contentBoxStyle();
251
- expect(style.minHeight).toBe('320px');
249
+ expect(style.minHeight).toBe('auto');
252
250
  expect(style.height).toBe('auto');
253
- expect(style.aspectRatio).toBe('1.8 / 1');
254
251
  });
252
+ });
255
253
 
256
- it('does not apply height as a fixed box height even when passed explicitly, and does not throw', () => {
257
- expect(() => renderFrame({ minHeight: 420, height: 380 })).not.toThrow();
258
- const style = contentBoxStyle();
259
- expect(style.minHeight).toBe('420px');
260
- expect(style.height).toBe('auto');
254
+ // UCM-CHART-13: `height`/`aspect` were destructured but never applied to the box -- measured
255
+ // against 3.0.1, no consumer passed either. Both are now gone; passing one throws in dev, naming
256
+ // the replacement, the same as the four chart presets' removed props (`UCM-CHART-12`).
257
+ describe('removed height/aspect props throw in dev, naming their replacement (UCM-CHART-13)', () => {
258
+ it('throws when a caller still passes height, naming minHeight AND size as the replacement', () => {
259
+ expect(() => renderFrame({ height: 380 })).toThrow(/ChartFrame.*height.*minHeight.*size/s);
261
260
  });
262
261
 
263
- it('ignores height when passed alone, with no minHeight floor either', () => {
264
- renderFrame({ height: 280 });
265
- const style = contentBoxStyle();
266
- expect(style.minHeight).toBe('auto');
267
- expect(style.height).toBe('auto');
262
+ it('throws when a caller still passes aspect, naming size as the replacement', () => {
263
+ expect(() => renderFrame({ aspect: 1.8 })).toThrow(/ChartFrame.*aspect.*size/s);
264
+ });
265
+
266
+ it('attributes the removal to UCM-CHART-13/v3.1.0, not the presets\' own UCM-CHART-12 message', () => {
267
+ expect(() => renderFrame({ height: 380 })).toThrow(/UCM-CHART-13.*v3\.1\.0/);
268
+ });
269
+
270
+ it('does not throw when only minHeight is passed', () => {
271
+ expect(() => renderFrame({ minHeight: 320 })).not.toThrow();
272
+ });
273
+
274
+ it('stays inert in production even when height/aspect are passed', () => {
275
+ const original = process.env.NODE_ENV;
276
+ process.env.NODE_ENV = 'production';
277
+ try {
278
+ expect(() => renderFrame({ height: 380, aspect: 1.8 })).not.toThrow();
279
+ } finally {
280
+ process.env.NODE_ENV = original;
281
+ }
268
282
  });
269
283
 
270
- it('warns with wording accurate to its own behaviour -- minHeight wins, not height', () => {
271
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
272
- renderFrame({ minHeight: 420, height: 380 });
273
- expect(warnSpy).toHaveBeenCalledOnce();
274
- const message = warnSpy.mock.calls[0][0];
275
- expect(message).toContain('ChartFrame');
276
- expect(message).toContain('height is ignored');
277
- expect(message).not.toContain('height wins');
278
- warnSpy.mockRestore();
284
+ it('applies nothing to the box for a still-passed height/aspect (dev-mode error aside) -- confirmed via the production path', () => {
285
+ const original = process.env.NODE_ENV;
286
+ process.env.NODE_ENV = 'production';
287
+ try {
288
+ renderFrame({ minHeight: 320, height: 380, aspect: 1.8 });
289
+ const style = window.getComputedStyle(screen.getByTestId('chart-body').parentElement);
290
+ expect(style.minHeight).toBe('320px');
291
+ expect(style.height).toBe('auto');
292
+ expect(style.aspectRatio).toBe('auto');
293
+ } finally {
294
+ process.env.NODE_ENV = original;
295
+ }
279
296
  });
280
297
  });
281
298
  });
@@ -20,8 +20,10 @@ import {
20
20
  assertRemovedChartProp,
21
21
  defaultNumericTickFormatter,
22
22
  resolveChartLayout,
23
+ resolveLegendPosition,
23
24
  sizeYAxisForContent,
24
25
  withAxisDefaults,
26
+ withChartSlotDefaults,
25
27
  } from '../src/components/charts/chartDefaults';
26
28
  import { createAppTheme } from '../src/theme/createAppTheme';
27
29
 
@@ -290,6 +292,15 @@ describe('removed chart props throw in dev, naming their replacement (UCM-CHART-
290
292
  .toThrow(/BarChart.*minHeight.*Use size instead\./s);
291
293
  });
292
294
 
295
+ // UCM-CHART-13 (codex review finding): a shared helper hardcoding one WO/version string would
296
+ // misattribute the removal for a different call site's own, different removal.
297
+ it('defaults removedIn to UCM-CHART-12/v3.0.0 (the four presets\' own call sites), but a caller can override it', () => {
298
+ expect(() => assertRemovedChartProp('BarChart', 'minHeight', 320, 'Use size instead.'))
299
+ .toThrow(/UCM-CHART-12, v3\.0\.0/);
300
+ expect(() => assertRemovedChartProp('ChartFrame', 'aspect', 1.8, 'Use size instead.', 'UCM-CHART-13, v3.1.0'))
301
+ .toThrow(/UCM-CHART-13, v3\.1\.0/);
302
+ });
303
+
293
304
  it('assertRemovedChartProp stays inert in production', () => {
294
305
  const original = process.env.NODE_ENV;
295
306
  process.env.NODE_ENV = 'production';
@@ -442,6 +453,102 @@ describe('resolveChartLayout composition invariant (UCM-CHART-12)', () => {
442
453
  expect(bands.yAxisBand + bands.plotWidth + bands.rightPad).toBe(600);
443
454
  expect(bands.yAxisBand).toBeGreaterThan(0);
444
455
  });
456
+
457
+ // Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's axis: 'secondary'
458
+ // feature) has a SECOND y-axis on the right, which the resolver previously ignored entirely --
459
+ // `sizedYAxis[0]` was the only width ever accounted for. The width invariant must include it.
460
+ it('accounts for a secondary (right-positioned) y-axis in both margin.right and the width invariant', () => {
461
+ const single = resolveChartLayout({
462
+ xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
463
+ yAxis: [{ scaleType: 'linear', id: 'primary', min: 0, max: 100 }],
464
+ tickFontSize: 12,
465
+ spacing: theme.spacing,
466
+ containerWidth: 600,
467
+ });
468
+ const dual = resolveChartLayout({
469
+ xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
470
+ yAxis: [
471
+ { scaleType: 'linear', id: 'primary', position: 'left', min: 0, max: 100 },
472
+ { scaleType: 'linear', id: 'secondary', position: 'right', min: 0, max: 500000 },
473
+ ],
474
+ tickFontSize: 12,
475
+ spacing: theme.spacing,
476
+ containerWidth: 600,
477
+ });
478
+
479
+ expect(dual.bands.secondaryYAxisBand).toBeGreaterThan(0);
480
+ // The wider secondary axis (500000 vs 100) must reserve MORE than the primary alone.
481
+ expect(dual.margin.right).toBeGreaterThan(single.margin.right);
482
+ expect(dual.margin.right).toBe(PACKAGE_DEFAULT_MARGIN.right + dual.bands.secondaryYAxisBand);
483
+ // Width invariant, now with the secondary band as a fourth term.
484
+ expect(dual.bands.yAxisBand + dual.bands.plotWidth + dual.bands.secondaryYAxisBand + dual.bands.rightPad)
485
+ .toBe(600);
486
+ });
487
+
488
+ it('an unmarked (no explicit position) second y-axis defaults to the LEFT, matching MUI, and reserves no secondary band', () => {
489
+ const layout = resolveChartLayout({
490
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
491
+ yAxis: [
492
+ { scaleType: 'linear', id: 'a', min: 0, max: 1 },
493
+ { scaleType: 'linear', id: 'b', min: 0, max: 1 },
494
+ ],
495
+ tickFontSize: 12,
496
+ spacing: theme.spacing,
497
+ });
498
+ expect(layout.bands.secondaryYAxisBand).toBe(0);
499
+ });
500
+
501
+ // Codex second-pass review finding: a caller-supplied `valueFormatter` that blanks every tick
502
+ // must collapse the y-axis band to zero too (Rule 2's general principle), not just the x-axis
503
+ // case the WO's Definition of Done names explicitly.
504
+ it('collapses the y-axis band to zero when every tick formats to a blank string', () => {
505
+ const layout = resolveChartLayout({
506
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
507
+ yAxis: [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '' }],
508
+ tickFontSize: 12,
509
+ spacing: theme.spacing,
510
+ });
511
+ expect(layout.bands.yAxisBand).toBe(0);
512
+ expect(layout.yAxis[0].width).toBe(0);
513
+ });
514
+
515
+ // Codex second-pass review finding: `slotProps.legend.position` can override `legendPosition` at
516
+ // render time (`withChartSlotDefaults`) -- the resolver must reserve space for wherever the
517
+ // legend ACTUALLY renders, not just what `legendPosition` alone says.
518
+ it('reserves space for the slotProps.legend.position override, not just legendPosition', () => {
519
+ const layout = resolveChartLayout({
520
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
521
+ yAxis: [{ scaleType: 'linear' }],
522
+ legendPosition: { vertical: 'bottom' },
523
+ slotProps: { legend: { position: { vertical: 'top' } } },
524
+ spacing: theme.spacing,
525
+ });
526
+ expect(layout.margin.top).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.top);
527
+ expect(layout.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
528
+ });
529
+
530
+ it('resolveLegendPosition matches withChartSlotDefaults\' own merge precedence exactly', () => {
531
+ const legendPosition = { vertical: 'bottom', horizontal: 'start' };
532
+ const slotProps = { legend: { position: { horizontal: 'end' } } };
533
+ const resolved = resolveLegendPosition(legendPosition, slotProps);
534
+ const fromSlotDefaults = withChartSlotDefaults(slotProps, legendPosition).legend.position;
535
+ expect(resolved).toEqual(fromSlotDefaults);
536
+ expect(resolved).toEqual({ vertical: 'bottom', horizontal: 'end' });
537
+ });
538
+
539
+ // Codex second-pass review finding: a caller-set `xAxis[0].height` bypasses the model's SIZE,
540
+ // but rotation must still be governed entirely by `xLabels` -- a stale caller-set angle must not
541
+ // survive under a mode that says "no rotation".
542
+ it('still governs rotation via xLabels even when the caller sets an explicit axis height', () => {
543
+ const layout = resolveChartLayout({
544
+ xAxis: [{ scaleType: 'band', data: ['Jan'], height: 60, tickLabelStyle: { angle: -60 } }],
545
+ yAxis: [{ scaleType: 'linear' }],
546
+ xLabels: 'horizontal',
547
+ spacing: theme.spacing,
548
+ });
549
+ expect(layout.xAxis[0].height).toBe(60); // the caller's own height escape still wins
550
+ expect(layout.xAxis[0].tickLabelStyle.angle).toBeFalsy(); // but the stale rotation does not survive
551
+ });
445
552
  });
446
553
 
447
554
  describe('resolveChartLayout size tokens and height escape (UCM-CHART-12)', () => {
@@ -491,12 +598,17 @@ describe('resolveChartLayout xLabels rotation (UCM-CHART-12)', () => {
491
598
  expect(layout.xAxis[0].tickLabelStyle.angle).toBe(-45);
492
599
  });
493
600
 
494
- it('"auto" stays horizontal for a few short labels, rotates for many long ones', () => {
601
+ it('"auto" stays horizontal for a few short labels, rotates for many long ones (measured width, not char count)', () => {
602
+ // codex second-pass review finding: "auto" must decide from the label's MEASURED width, not a
603
+ // raw character count -- these inject a deterministic measurer instead of relying on the
604
+ // per-glyph estimate, and a `tickFontSize` fallback so the resolver can actually measure.
495
605
  const few = resolveChartLayout({
496
606
  xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb', 'Mar'] }],
497
607
  yAxis: [{ scaleType: 'linear' }],
498
608
  xLabels: 'auto',
609
+ tickFontSize: 12,
499
610
  spacing: theme.spacing,
611
+ measureTextWidth: () => 20, // narrow -- well under the rotation threshold
500
612
  });
501
613
  expect(few.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
502
614
 
@@ -504,11 +616,37 @@ describe('resolveChartLayout xLabels rotation (UCM-CHART-12)', () => {
504
616
  xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `Ward name ${i}`) }],
505
617
  yAxis: [{ scaleType: 'linear' }],
506
618
  xLabels: 'auto',
619
+ tickFontSize: 12,
507
620
  spacing: theme.spacing,
621
+ measureTextWidth: () => 90, // wide -- over the rotation threshold
508
622
  });
509
623
  expect(many.xAxis[0].tickLabelStyle.angle).toBe(-45);
510
624
  });
511
625
 
626
+ it('"auto" does not rotate a few WIDE labels -- tick count and measured width must both cross the threshold', () => {
627
+ const layout = resolveChartLayout({
628
+ xAxis: [{ scaleType: 'band', data: ['A very wide single label'] }],
629
+ yAxis: [{ scaleType: 'linear' }],
630
+ xLabels: 'auto',
631
+ tickFontSize: 12,
632
+ spacing: theme.spacing,
633
+ measureTextWidth: () => 200, // wide, but only one category
634
+ });
635
+ expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
636
+ });
637
+
638
+ it('"auto" does not rotate many labels that are individually narrow', () => {
639
+ const layout = resolveChartLayout({
640
+ xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `${i}`) }],
641
+ yAxis: [{ scaleType: 'linear' }],
642
+ xLabels: 'auto',
643
+ tickFontSize: 12,
644
+ spacing: theme.spacing,
645
+ measureTextWidth: () => 8, // many categories, but each label is tiny
646
+ });
647
+ expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
648
+ });
649
+
512
650
  it('drives the rotated tick band from a measured width, not a flat constant', () => {
513
651
  const short = resolveChartLayout({
514
652
  xAxis: [{ scaleType: 'band', data: ['A'], tickLabelStyle: { fontSize: 12 } }],
@@ -655,6 +793,24 @@ describe('sizeYAxisForContent (THEME-9)', () => {
655
793
  expect(axis.width).toBe(42);
656
794
  });
657
795
 
796
+ // UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed (min/max set), but
797
+ // a caller `valueFormatter` reduced every tick to a blank string -- distinct from "nothing to
798
+ // measure at all" (the untouched/undefined case below), this must collapse to a real 0, not the
799
+ // font-size floor a blank string previously still received.
800
+ it('collapses to width 0 when every formatted tick is blank and there is no axis title', () => {
801
+ const [axis] = sizeYAxisForContent(
802
+ [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '' }], [], '12px',
803
+ );
804
+ expect(axis.width).toBe(0);
805
+ });
806
+
807
+ it('still reserves width for the axis TITLE when ticks are blank but a label is set', () => {
808
+ const [axis] = sizeYAxisForContent(
809
+ [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '', label: 'Coverage' }], [], '12px',
810
+ );
811
+ expect(axis.width).toBeGreaterThan(0);
812
+ });
813
+
658
814
  it('leaves a categorical (band/point) axis alone -- its ticks are names, not numbers', () => {
659
815
  const [axis] = sizeYAxisForContent(
660
816
  [{ scaleType: 'band', data: ['a', 'b'] }], [], '12px',