@micha.bigler/ui-core-micha 2.41.3 → 2.42.1

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.
@@ -211,4 +211,71 @@ describe('ChartFrame', () => {
211
211
  renderFrame({ isEmpty: true }, 'fr');
212
212
  expect(screen.getByText('Aucune donnée disponible.')).toBeTruthy();
213
213
  });
214
+
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)', () => {
220
+ function contentBoxStyle() {
221
+ return window.getComputedStyle(screen.getByTestId('chart-body').parentElement);
222
+ }
223
+
224
+ it('sizes the content box from minHeight alone as a floor, never a fixed height', () => {
225
+ renderFrame({ minHeight: 300 });
226
+ const style = contentBoxStyle();
227
+ expect(style.minHeight).toBe('300px');
228
+ expect(style.height).toBe('auto');
229
+ });
230
+
231
+ it('does not receive a fixed height when content exceeds minHeight -- the regression itself', () => {
232
+ const i18n = i18next.createInstance();
233
+ i18n.init({ lng: 'en', resources, interpolation: { escapeValue: false } });
234
+ render(
235
+ <ThemeProvider theme={createTheme()}>
236
+ <I18nextProvider i18n={i18n}>
237
+ <ChartFrame title="Chart title" minHeight={100}>
238
+ <svg data-testid="chart-body" style={{ height: 500 }} />
239
+ </ChartFrame>
240
+ </I18nextProvider>
241
+ </ThemeProvider>,
242
+ );
243
+ const style = contentBoxStyle();
244
+ expect(style.minHeight).toBe('100px');
245
+ expect(style.height).toBe('auto');
246
+ });
247
+
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 });
250
+ const style = contentBoxStyle();
251
+ expect(style.minHeight).toBe('320px');
252
+ expect(style.height).toBe('auto');
253
+ expect(style.aspectRatio).toBe('1.8 / 1');
254
+ });
255
+
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');
261
+ });
262
+
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');
268
+ });
269
+
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();
279
+ });
280
+ });
214
281
  });
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import React from 'react';
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest';
4
- import { cleanup, render } from '@testing-library/react';
4
+ import { cleanup, render, screen } from '@testing-library/react';
5
5
  import { ThemeProvider, createTheme } from '@mui/material/styles';
6
6
 
7
7
  const chartSpy = vi.hoisted(() => vi.fn(() => <div data-testid="mui-line-chart" />));
@@ -33,4 +33,49 @@ describe('LineChart preset', () => {
33
33
  legend: { position: { vertical: 'bottom', horizontal: 'start' } },
34
34
  });
35
35
  });
36
+
37
+ // CHART-8: minHeight/height/aspect resolution, wired through the actual component.
38
+ describe('minHeight/height resolution (CHART-8)', () => {
39
+ function renderLine(props) {
40
+ return render(
41
+ <ThemeProvider theme={createTheme()}>
42
+ <LineChart series={[{ data: [1, 2] }]} {...props} />
43
+ </ThemeProvider>,
44
+ );
45
+ }
46
+
47
+ afterEach(cleanup);
48
+
49
+ it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
50
+ renderLine({ minHeight: 300 });
51
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBe(300);
52
+ expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).minHeight).toBe('300px');
53
+ });
54
+
55
+ it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
56
+ renderLine({ minHeight: 320, aspect: 1.8 });
57
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBeUndefined();
58
+ const style = window.getComputedStyle(screen.getByTestId('line-chart-container'));
59
+ expect(style.minHeight).toBe('320px');
60
+ expect(style.aspectRatio).toBe('1.8 / 1');
61
+ });
62
+
63
+ it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
64
+ renderLine({ minHeight: 420, height: 380 });
65
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBe(380);
66
+ expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).minHeight).toBe('380px');
67
+ });
68
+
69
+ it('is byte-identical when minHeight equals height', () => {
70
+ renderLine({ minHeight: 320, height: 320 });
71
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBe(320);
72
+ expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).minHeight).toBe('320px');
73
+ });
74
+
75
+ it('is unchanged when height is passed alone', () => {
76
+ renderLine({ height: 280 });
77
+ expect(chartSpy.mock.calls.at(-1)[0].height).toBe(280);
78
+ expect(window.getComputedStyle(screen.getByTestId('line-chart-container')).minHeight).toBe('auto');
79
+ });
80
+ });
36
81
  });
@@ -2,7 +2,7 @@
2
2
  import React from 'react';
3
3
  import i18next from 'i18next';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
- import { cleanup, render } from '@testing-library/react';
5
+ import { cleanup, render, screen } from '@testing-library/react';
6
6
  import { I18nextProvider } from 'react-i18next';
7
7
  import { ThemeProvider } from '@mui/material/styles';
8
8
 
@@ -232,3 +232,40 @@ describe('ScatterReferenceCurve / ScatterReferenceLine (THEME-10)', () => {
232
232
  expect(container.querySelector('text')).toBeNull();
233
233
  });
234
234
  });
235
+
236
+ // CHART-8: minHeight/height/aspect resolution, wired through the actual component.
237
+ describe('ScatterChart minHeight/height resolution (CHART-8)', () => {
238
+ afterEach(() => { cleanup(); vi.clearAllMocks(); });
239
+
240
+ it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
241
+ renderScatter({ minHeight: 300 });
242
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(300);
243
+ expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('300px');
244
+ });
245
+
246
+ it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
247
+ renderScatter({ minHeight: 320, aspect: 1.8 });
248
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBeUndefined();
249
+ const style = window.getComputedStyle(screen.getByTestId('scatter-chart-container'));
250
+ expect(style.minHeight).toBe('320px');
251
+ expect(style.aspectRatio).toBe('1.8 / 1');
252
+ });
253
+
254
+ it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
255
+ renderScatter({ minHeight: 420, height: 380 });
256
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(380);
257
+ expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('380px');
258
+ });
259
+
260
+ it('is byte-identical when minHeight equals height', () => {
261
+ renderScatter({ minHeight: 320, height: 320 });
262
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(320);
263
+ expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('320px');
264
+ });
265
+
266
+ it('is unchanged when height is passed alone', () => {
267
+ renderScatter({ height: 280 });
268
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(280);
269
+ expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('auto');
270
+ });
271
+ });
@@ -17,7 +17,9 @@ import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
17
17
  import {
18
18
  defaultNumericTickFormatter,
19
19
  PACKAGE_DEFAULT_MARGIN,
20
+ resolveChartHeight,
20
21
  sizeYAxisForContent,
22
+ warnOnHeightMismatch,
21
23
  withAxisDefaults,
22
24
  withMarginDefaults,
23
25
  } from '../src/components/charts/chartDefaults';
@@ -433,3 +435,84 @@ describe('withMarginDefaults (THEME-11)', () => {
433
435
  expect(withMarginDefaults(full)).toEqual(full);
434
436
  });
435
437
  });
438
+
439
+ // CHART-8 -- pure-function contract of the minHeight/height/aspect resolver, independent of any
440
+ // chart component wiring (component-level wiring is covered per-preset in each own test file).
441
+ describe('resolveChartHeight (CHART-8)', () => {
442
+ it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
443
+ expect(resolveChartHeight({ minHeight: 300 })).toEqual({ wrapperMinHeight: 300, chartHeight: 300 });
444
+ });
445
+
446
+ it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
447
+ expect(resolveChartHeight({ minHeight: 320, aspect: 1.8 })).toEqual({
448
+ wrapperMinHeight: 320,
449
+ chartHeight: undefined,
450
+ });
451
+ });
452
+
453
+ it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
454
+ expect(resolveChartHeight({ minHeight: 420, height: 380 })).toEqual({
455
+ wrapperMinHeight: 380,
456
+ chartHeight: 380,
457
+ });
458
+ });
459
+
460
+ it('is byte-identical (both values unchanged) when minHeight equals height', () => {
461
+ expect(resolveChartHeight({ minHeight: 320, height: 320 })).toEqual({
462
+ wrapperMinHeight: 320,
463
+ chartHeight: 320,
464
+ });
465
+ });
466
+
467
+ it('leaves the wrapper unset when height is passed alone', () => {
468
+ expect(resolveChartHeight({ height: 280 })).toEqual({ wrapperMinHeight: undefined, chartHeight: 280 });
469
+ });
470
+
471
+ it('does not cap the wrapper when minHeight is smaller than height', () => {
472
+ expect(resolveChartHeight({ minHeight: 200, height: 380 })).toEqual({
473
+ wrapperMinHeight: 200,
474
+ chartHeight: 380,
475
+ });
476
+ });
477
+ });
478
+
479
+ describe('warnOnHeightMismatch (CHART-8)', () => {
480
+ afterEach(() => vi.restoreAllMocks());
481
+
482
+ it('warns, naming the component and both values, when minHeight and height disagree', () => {
483
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
484
+ warnOnHeightMismatch('BarChart', { minHeight: 420, height: 380 });
485
+ expect(warn).toHaveBeenCalledOnce();
486
+ expect(warn.mock.calls[0][0]).toContain('BarChart');
487
+ expect(warn.mock.calls[0][0]).toContain('420');
488
+ expect(warn.mock.calls[0][0]).toContain('380');
489
+ });
490
+
491
+ it('does not warn when minHeight equals height', () => {
492
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
493
+ warnOnHeightMismatch('BarChart', { minHeight: 320, height: 320 });
494
+ expect(warn).not.toHaveBeenCalled();
495
+ });
496
+
497
+ it('does not warn on the legitimate minHeight + aspect (no height) combination', () => {
498
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
499
+ warnOnHeightMismatch('BarChart', { minHeight: 320, height: undefined });
500
+ expect(warn).not.toHaveBeenCalled();
501
+ });
502
+
503
+ it('does not warn when only one of the pair is set', () => {
504
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
505
+ warnOnHeightMismatch('BarChart', { minHeight: undefined, height: 280 });
506
+ expect(warn).not.toHaveBeenCalled();
507
+ });
508
+
509
+ it('gives ChartFrame-accurate wording when heightWins is false (CHART-9)', () => {
510
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
511
+ warnOnHeightMismatch('ChartFrame', { minHeight: 420, height: 380 }, { heightWins: false });
512
+ expect(warn).toHaveBeenCalledOnce();
513
+ const message = warn.mock.calls[0][0];
514
+ expect(message).toContain('ChartFrame');
515
+ expect(message).toContain('height is ignored');
516
+ expect(message).not.toContain('height wins');
517
+ });
518
+ });
@@ -0,0 +1,147 @@
1
+ # UCM-CHART-8 — Make the chart presets stop producing dead space below the chart
2
+
3
+ - **Repo:** `ui-core-micha`, branch `main` (shared-core; publishes from `main` without a staging step)
4
+ - **Tier:** 3 — shared-core. Every consuming app inherits the behaviour change.
5
+ - **Status:** planned
6
+ - **Workstream:** `CHART-*` (chart presets)
7
+ - **Files:** `src/components/charts/ScatterChart.jsx`, `BarChart.jsx`, `LineChart.jsx`,
8
+ `TimeSeriesChart.jsx`, **`ChartFrame.jsx`** — all five destructure `minHeight` **and** `aspect`
9
+ (verified 2026-08-21) — plus their tests. **Note for the implementer: only `ScatterChart` and
10
+ `TimeSeriesChart` reference `height` at all.** `BarChart`, `LineChart` and `ChartFrame` never
11
+ destructure it, so for them the incoherent "pair" exists only via the rest spread into the MUI
12
+ chart. Do not go looking for a declared `height` prop there.
13
+
14
+ > **Why.** `minHeight` and `height` are two different things on these presets, and the API lets a
15
+ > caller pass both. `minHeight` lands on the **wrapper `Box`** —
16
+ > `sx={{ width: '100%', minHeight, aspectRatio: aspect }}` (`ScatterChart.jsx:260`, same
17
+ > construction in `BarChart.jsx`) — while `height` sizes the **chart**. When `minHeight > height`,
18
+ > the difference is dead space **below the chart**.
19
+ >
20
+ > Measured in `hram` on 2026-08-20:
21
+ >
22
+ > | panel | `minHeight` | `height` | dead space |
23
+ > |---|---|---|---|
24
+ > | `StructuralReachabilityPanel` | 420 | 380 | 40 px |
25
+ > | `ExpertTimelinePanel` | 340 | 280 (×2) | 60 px |
26
+ > | `ResultsTimelinePanel` | 320 | 280 | 40 px |
27
+ > | `GroupMetricsPanel` | 300 | **none** | the whole chart is unsized |
28
+ > | Cost, Ratio, Accessibility, Referral, Total | equal | equal | 0 |
29
+ >
30
+ > **The equal-value majority is why this survived.** Passing both looks like harmless
31
+ > belt-and-braces and costs nothing when the numbers match, so the pattern spread by copy-paste and
32
+ > nobody noticed the cases where they diverge. It has been diagnosed and fixed **pointwise twice**
33
+ > in hram (`AllocationPerformancePanel.jsx:462-478`, `OptimizationResultsPanel.jsx:928-940`, both to
34
+ > `height={420}`) — with the explanation written as a comment **inside those two files**, where no
35
+ > new panel will ever read it.
36
+ >
37
+ > This is not N panel bugs. It is one shared API that permits an incoherent pair, and it will keep
38
+ > recurring for as long as it does. Fixing it here fixes every consumer at once, including the ones
39
+ > nobody sweeps.
40
+
41
+ ---
42
+
43
+ ## Part A — Envelope (authoritative WHAT/WHY)
44
+
45
+ ### Goal
46
+
47
+ A caller can no longer produce dead space by passing `minHeight`. The presets resolve the pair
48
+ themselves, and the incoherent combination becomes impossible rather than merely discouraged.
49
+
50
+ ### Definition of Done
51
+
52
+ - [ ] **The resolution is three-way, not two-way — `aspect` is the third input and it changes the
53
+ answer.** Amended 2026-08-21; the original two-way rule would have broken four live call sites.
54
+ - **`height` present** → it sizes the chart, and the wrapper reserves no more than that.
55
+ - **No `height`, no `aspect`** → **`minHeight` sizes the chart**, not merely the wrapper.
56
+ This is what a caller in that shape meant (`GroupMetricsPanel`: `minHeight={300}`, no
57
+ `height`, no `aspect` — chart unsized today).
58
+ - **No `height`, but `aspect` present** → **today's behaviour is correct and must not change.**
59
+ The wrapper is `width: 100%` with `aspectRatio`, so its height derives from its width, and
60
+ `minHeight` is the floor that stops it collapsing on a narrow viewport. Here `minHeight`
61
+ means exactly what its name says.
62
+
63
+ **Same prop shape, opposite intent** — which is why the rule cannot be stated on
64
+ `minHeight`/`height` alone.
65
+ - [ ] **`minHeight > height` produces no wrapper padding.** The chart is sized by `height` and the
66
+ wrapper does not reserve more than the chart occupies. The extra space was never intentional.
67
+ - [ ] **Equal values behave exactly as today** — that is the majority of call sites and they must
68
+ not shift by a pixel.
69
+ - [ ] **A dev-mode warning names the offending call site** when both are passed and they disagree.
70
+ Not a throw: a shared package that throws on a prop combination in production breaks a
71
+ consumer's page over a layout nit.
72
+ - [ ] **JSDoc on every preset that takes it** states in one line what `minHeight` now does, so the
73
+ next caller does not have to infer it from a comment in a consumer app.
74
+
75
+ ### Deliberately NOT done
76
+
77
+ - **`minHeight` is not removed from the API.** Removing it is a breaking change to a shared package,
78
+ which would make every consumer's pin bump a Tier-3 change with a real chance of unnoticed layout
79
+ regressions — a worse outcome than the defect. Deprecate in the docstring, coerce in the code.
80
+ - No change to chart internals, palettes, axis defaults or `aspect`.
81
+ - No consumer-side sweep in this WO. hram's call-site cleanup is its own follow-up, gated on the pin
82
+ bump — see below.
83
+
84
+ ### Preconditions and ordering
85
+
86
+ The fix only reaches hram after **(a)** this lands and publishes and **(b)** hram bumps its
87
+ `@micha.bigler/ui-core-micha` pin. Verify the published version is actually live before the consumer
88
+ bump — this package publishes from `main` with no staging step, so a failed publish is silent until
89
+ a consumer installs it.
90
+
91
+ ### Risks
92
+
93
+ - **The consumer check is done, and it found something — 2026-08-21.** It was written here as a
94
+ hypothetical ("a consumer may be relying on the padding as spacing"); the real finding is
95
+ different and larger. **Four live call sites pass `aspect` together with `minHeight` and no
96
+ `height`:**
97
+
98
+ | call site | props |
99
+ |---|---|
100
+ | `hram/AccessGapScatterPanel.jsx:182` | `aspect={1.8}` + `minHeight={320}` |
101
+ | `fitness-monitor/BodyHistoryPage.jsx:~328` | `aspect="960 / 380"` + `minHeight={320}` |
102
+ | `fitness-monitor/BodyHistoryPage.jsx:~355` | `aspect="960 / 380"` + `minHeight={320}` |
103
+ | `fitness-monitor/BodyHistoryPage.jsx:~450` | `aspect="400 / 220"` + `minHeight={220}` |
104
+
105
+ These are not relying on padding-as-spacing. They are using `minHeight` **as a floor under an
106
+ aspect-derived height**, which is legitimate and is what the name promises. The original
107
+ Definition of Done would have turned all four into fixed-height charts and removed their
108
+ responsive behaviour. **That is the reason for the three-way rule above**, and it is the case to
109
+ hold onto when reading the rest of this order.
110
+
111
+ Scope note: `survey-renderer` is named above as a consumer to check. **The standalone repo is
112
+ retired** (2026-07-31); the widget now lives at `survey_app/frontend/packages/survey-renderer`,
113
+ and neither it nor `cockpit` or `jg-ferien` passes `aspect` to a chart preset. jg's one `aspect=`
114
+ hit is an `ImageCropDialog`, unrelated.
115
+ - The dev warning must not fire on the equal-value majority, or it becomes noise that trains people
116
+ to ignore it.
117
+
118
+ ### Tests to WRITE — narrow
119
+
120
+ - `minHeight` alone, **no `aspect`** → the chart receives that height.
121
+ - **`minHeight` + `aspect`, no `height` → unchanged from today**: the wrapper keeps its
122
+ `aspectRatio` and `minHeight` stays a floor, and the chart is **not** given a fixed height. This is
123
+ the four-call-site case in Risks and the one a two-way rule would have broken — **it is the test
124
+ that has to fail if someone later re-simplifies the resolution.**
125
+ - `minHeight > height` → the chart receives `height` and the wrapper reserves no more than that.
126
+ - `minHeight === height` → byte-identical to the current output.
127
+ - `height` alone → unchanged.
128
+ - The dev warning fires only on the disagreeing pair, and names the values. It must **not** fire on
129
+ `minHeight` + `aspect` without `height`, which is a legitimate combination.
130
+
131
+ No full suite.
132
+
133
+ ---
134
+
135
+ ## Part B — Implementation map
136
+
137
+ > **TO BE FILLED BY THE ORCHESTRATOR** on `git pull`. Must carry the preset files with `path:line`
138
+ > anchors (`ScatterChart.jsx:173-174, :260` and the equivalents), the absolute working directory,
139
+ > the progress contract, and the preamble. **Do not dispatch while this placeholder stands.**
140
+
141
+ ---
142
+
143
+ ## Part C — Orchestrator-only
144
+
145
+ > **TO BE FILLED BY THE ORCHESTRATOR.** Review routing (`reviewer` + `ui_reviewer`, full context —
146
+ > Tier 3 shared-core), verification, register maintenance, commit, publish check, and the execution
147
+ > directive with its self-address guard.