@micha.bigler/ui-core-micha 2.39.0 → 2.40.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.
@@ -9,7 +9,7 @@ export function withGridDefaults(grid) {
9
9
 
10
10
  export function withAxisDefaults(axes, label, defaults, tickFontSize) {
11
11
  const values = axes?.length ? axes : [defaults];
12
- return values.map((axis) => ({
12
+ return values.map((axis, index) => ({
13
13
  // `defaults.scaleType` (and any other scale-defining default) must
14
14
  // survive even when the caller passes their OWN xAxis/yAxis array --
15
15
  // confirmed live in the dev harness: a caller-supplied axis with `data`
@@ -25,7 +25,17 @@ export function withAxisDefaults(axes, label, defaults, tickFontSize) {
25
25
  fontSize: tickFontSize,
26
26
  ...axis.tickLabelStyle,
27
27
  },
28
- label: axis.label || label,
28
+ // THEME-9: the single `label` argument is the caller's ONE string for
29
+ // this whole axis family (xAxisLabel/yAxisLabel) -- it may only ever
30
+ // fall back onto ONE axis. Applying it via .map() to every entry
31
+ // broadcast the identical label onto every axis in a multi-axis array
32
+ // (measured live: hram's dual-axis chart carried the same 43-character
33
+ // label on both y-axes). Only the first entry -- the common single-axis
34
+ // case is index 0 -- gets the fallback; a caller building a genuine
35
+ // multi-axis array must label each entry itself (as TimeSeriesChart's
36
+ // own secondary-axis support already does), exactly like MUI's own
37
+ // default `position: 'left'` (only the first entry, MUI docs).
38
+ label: axis.label || (index === 0 ? label : undefined),
29
39
  }));
30
40
  }
31
41
 
@@ -100,6 +110,136 @@ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
100
110
  return { xAxis: spacedXAxis, margin: spacedMargin };
101
111
  }
102
112
 
113
+ // THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
114
+ // 20px (label, if present) = 65px regardless of what the ticks actually say
115
+ // -- confirmed against @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH /
116
+ // AXIS_LABEL_DEFAULT_HEIGHT constants and its `useChartDimensions` selector
117
+ // (drawingArea.left = margin.left + axisSizeLeft, i.e. margin and axis size
118
+ // are ADDITIVE, not one containing the other). That flat default is both too
119
+ // generous for short ticks ("0%"/"50%"/"100%") and too stingy for wide ones
120
+ // ("50.0 min"), which is exactly the measured overlap on the Accessibility
121
+ // y-axis. Mirrors spaceForRotatedTicks' existing text-width heuristic below.
122
+ const Y_AXIS_TICK_SIZE = 6;
123
+ const Y_AXIS_TICK_LABEL_GAP = 2;
124
+ const Y_AXIS_LABEL_TICK_GAP = 2;
125
+ // A rotated (90deg) axis-label's line-box "thickness" -- what it costs in
126
+ // WIDTH once rotated -- runs a bit past its raw font size once ascender/
127
+ // descender room is included; matches the ~18px measured live for a 14px
128
+ // label (14 * 1.3 ~= 18).
129
+ const Y_AXIS_LABEL_THICKNESS_FACTOR = 1.3;
130
+ // A small safety margin on the estimate: `spaceForRotatedTicks`'s own risk
131
+ // note applies here too -- a too-tight cut recreates the overlap it was
132
+ // meant to fix, and glyph-width heuristics are approximate by nature.
133
+ const Y_AXIS_WIDTH_SAFETY_FACTOR = 1.1;
134
+ // MUI X-Charts' ChartsYAxisImpl hardcodes the axis-label font at 14px
135
+ // (`additionalProps.style: {...theme.typography.body1, fontSize: 14, ...}`
136
+ // -- fontSize:14 is set AFTER the theme.typography.body1 spread, so it wins
137
+ // regardless of what body1 actually resolves to). Matching that literal
138
+ // constant here -- rather than reading it off `theme.typography.body1` --
139
+ // keeps this sizing correct even if this package's body1 size ever changes;
140
+ // coupling it to body1 was only ever coincidentally right because body1
141
+ // happens to be 14px today.
142
+ const MUI_AXIS_LABEL_FONT_SIZE_PX = 14;
143
+
144
+ function longestFormattedTick(values, valueFormatter) {
145
+ return values.reduce((longest, value) => {
146
+ let text;
147
+ try {
148
+ text = valueFormatter ? String(valueFormatter(value, { location: 'tick' })) : String(value);
149
+ } catch {
150
+ // Some formatters require MUI's complete scale context; falling back
151
+ // to the raw value is conservative -- it reserves at least as much
152
+ // space as the unformatted number would need.
153
+ text = String(value);
154
+ }
155
+ return text.length > longest.length ? text : longest;
156
+ }, '');
157
+ }
158
+
159
+ /**
160
+ * Sizes a linear y-axis to its actual tick content instead of MUI's flat
161
+ * default. Only touches axes the caller left unsized (`axis.width == null`)
162
+ * and only linear/numeric ones -- a categorical axis's ticks are names, not
163
+ * numbers, and sizing those is not this function's job. Candidate values come
164
+ * from the axis' own `min`/`max` when the caller set them (as every current
165
+ * consumer does), falling back to the series actually plotted against this
166
+ * axis; if neither yields a candidate, the axis is left untouched rather than
167
+ * guessed at.
168
+ *
169
+ * `tickFontSize` is only the FALLBACK -- an axis' own `tickLabelStyle.fontSize`
170
+ * (set by `withAxisDefaults`, itself overridable per-axis by the caller) is
171
+ * read first, so a caller-tuned tick font is sized correctly rather than
172
+ * against the package-wide default. The axis LABEL's font is not a caller
173
+ * knob at all (`MUI_AXIS_LABEL_FONT_SIZE_PX` -- MUI hardcodes it).
174
+ *
175
+ * The series-to-axis match for the fallback deliberately mirrors MUI's own
176
+ * default-assignment rule, not a naive `undefined === undefined`: a series
177
+ * with no explicit `yAxisId` is assigned to the FIRST axis in the array
178
+ * (`yAxisIds[0]` in MUI's own selector), never to "no id". Matching only
179
+ * `undefined === undefined` left the primary axis of a real dual-axis chart
180
+ * (id `'primary'`, its series never set `yAxisId`) with zero matched series
181
+ * and therefore MUI's untouched flat default -- a silent no-op on exactly
182
+ * the dual-axis case this WO was measured against.
183
+ */
184
+ export function sizeYAxisForContent(yAxis, series, tickFontSize) {
185
+ const fallbackTickFontSizePx = cssLengthToPixels(tickFontSize);
186
+ const defaultAxisId = yAxis[0]?.id;
187
+ return yAxis.map((axis) => {
188
+ if (axis.width != null) return axis;
189
+ if (axis.scaleType && axis.scaleType !== 'linear') return axis;
190
+
191
+ const tickFontSizePx = cssLengthToPixels(axis.tickLabelStyle?.fontSize) || fallbackTickFontSizePx;
192
+ if (tickFontSizePx === 0) return axis;
193
+
194
+ const candidates = [];
195
+ if (typeof axis.min === 'number') candidates.push(axis.min);
196
+ if (typeof axis.max === 'number') candidates.push(axis.max);
197
+ if (candidates.length === 0) {
198
+ (series || [])
199
+ .filter((item) => (item.yAxisId ?? defaultAxisId) === axis.id)
200
+ .forEach((item) => (item.data || []).forEach((value) => {
201
+ if (typeof value === 'number') candidates.push(value);
202
+ }));
203
+ }
204
+ if (candidates.length === 0) return axis;
205
+
206
+ const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
207
+ const tickTextWidth = Math.max(
208
+ tickFontSizePx,
209
+ longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM,
210
+ );
211
+ const labelThickness = axis.label
212
+ ? Math.ceil(MUI_AXIS_LABEL_FONT_SIZE_PX * Y_AXIS_LABEL_THICKNESS_FACTOR) + Y_AXIS_LABEL_TICK_GAP
213
+ : 0;
214
+ const width = Math.ceil(
215
+ (tickTextWidth + Y_AXIS_TICK_SIZE + Y_AXIS_TICK_LABEL_GAP + labelThickness)
216
+ * Y_AXIS_WIDTH_SAFETY_FACTOR,
217
+ );
218
+
219
+ return { ...axis, width };
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Locale-correct, unit-free tick numbers as the default -- BarChart set none
225
+ * at all (raw numbers straight from the domain), and a hand-rolled `Intl`
226
+ * compact-notation default was measured to be actively wrong: German does
227
+ * not compact thousands at all (`12'500` stays `12'500`) and Swahili prepends
228
+ * a word that is WIDER than the raw number (`elfu 12.5`) -- compact notation
229
+ * does not solve the width problem and worsens it in one locale. Plain
230
+ * grouped formatting (no `notation: 'compact'`) is safe across all four
231
+ * locales this package's consumers use. Ticks get a short, rounded form;
232
+ * tooltip/legend keep full precision -- teaching screens must not round away
233
+ * a meaningful difference. A caller's own `valueFormatter` always wins (this
234
+ * is only ever used as a default, per `withAxisDefaults`' `{...defaults,
235
+ * ...axis}` merge).
236
+ */
237
+ export function defaultNumericTickFormatter(locale) {
238
+ return (value, context) => (context?.location === 'tick'
239
+ ? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
240
+ : new Intl.NumberFormat(locale).format(value));
241
+ }
242
+
103
243
  export function withChartSlotDefaults(slotProps, legendPosition) {
104
244
  const position = {
105
245
  ...DEFAULT_LEGEND_POSITION,
@@ -73,8 +73,8 @@ const COMPONENT_SURFACES = [
73
73
  componentPath('MuiTooltip', 'defaultProps.slotProps.transition.timeout.enter'),
74
74
  componentPath('MuiTooltip', 'defaultProps.slotProps.transition.timeout.exit'),
75
75
  componentPath('MuiSelect', 'defaultProps.variant'),
76
- componentPath('MuiSelect', 'styleOverrides.select.minHeight'),
77
- componentKeyLeaf('MuiSelect', 'styleOverrides.select', '@media (any-pointer: coarse)', 'minHeight'),
76
+ componentPath('MuiSelect', 'styleOverrides.root.minHeight'),
77
+ componentKeyLeaf('MuiSelect', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minHeight'),
78
78
  componentPath('MuiCheckbox', 'styleOverrides.root.color'),
79
79
  componentKeyLeaf('MuiCheckbox', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minHeight'),
80
80
  componentKeyLeaf('MuiCheckbox', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minWidth'),
@@ -255,7 +255,7 @@ export const BASELINE_STATIC = {
255
255
  MuiSelect: {
256
256
  defaultProps: { variant: 'outlined' },
257
257
  styleOverrides: {
258
- select: {
258
+ root: {
259
259
  minHeight: 40,
260
260
  boxSizing: 'border-box',
261
261
  '@media (any-pointer: coarse)': { minHeight: 44 },
@@ -50,9 +50,25 @@ describe('BarChart preset', () => {
50
50
  expect(chartSpy.mock.calls.at(-1)[0].colors).toEqual(['var(--application-chart-colour)']);
51
51
  });
52
52
 
53
- it('requires labels for both axes', () => {
54
- expect(() => render(
55
- <ThemeProvider theme={createTheme()}><BarChart series={[]} xAxisLabel="Year" /></ThemeProvider>,
56
- )).toThrow('BarChart requires xAxisLabel and yAxisLabel.');
53
+ // THEME-9: DESIGN.md #8a makes an axis title the caller's per-axis
54
+ // decision (categorical axes default to none) -- a hard "both required"
55
+ // throw made that default unachievable through this preset. Omitting a
56
+ // label now renders no title for that axis rather than crashing.
57
+ it('renders without a title on an axis whose label was omitted', () => {
58
+ render(
59
+ <ThemeProvider theme={createTheme()}><BarChart series={[{ data: [1] }]} xAxisLabel="Year" /></ThemeProvider>,
60
+ );
61
+ const props = chartSpy.mock.calls.at(-1)[0];
62
+ expect(props.xAxis[0].label).toBe('Year');
63
+ expect(props.yAxis[0].label).toBeUndefined();
64
+ });
65
+
66
+ it('renders with no title on either axis when both labels are omitted', () => {
67
+ render(
68
+ <ThemeProvider theme={createTheme()}><BarChart series={[{ data: [1] }]} /></ThemeProvider>,
69
+ );
70
+ const props = chartSpy.mock.calls.at(-1)[0];
71
+ expect(props.xAxis[0].label).toBeUndefined();
72
+ expect(props.yAxis[0].label).toBeUndefined();
57
73
  });
58
74
  });
@@ -216,12 +216,17 @@ describe('TimeSeriesChart', () => {
216
216
  ],
217
217
  };
218
218
 
219
- it('CHART-5: no custom yAxis when the shared single axis is not all-integer (today\'s real case)', () => {
219
+ it('CHART-5: no custom (integer-blanking) yAxis formatter when the shared single axis is not all-integer (today\'s real case)', () => {
220
220
  renderChart({ data: MIXED_DATA });
221
221
 
222
222
  const props = chartSpy.mock.calls.at(-1)[0];
223
223
  expect(props.yAxis).toHaveLength(1);
224
- expect(props.yAxis[0].valueFormatter).toBeUndefined();
224
+ // THEME-9: BarChart now always supplies a default (locale-grouped, non-
225
+ // blanking) tick formatter, so this is no longer undefined -- but it
226
+ // must still be BarChart's generic default, not CHART-5's own
227
+ // integer-blanking one (which would wrongly blank the 4.5/5.2/6.1 ticks).
228
+ expect(typeof props.yAxis[0].valueFormatter).toBe('function');
229
+ expect(props.yAxis[0].valueFormatter(4.5, { location: 'tick' })).not.toBe('');
225
230
  expect(props.yAxis[0].label).toBe('Count');
226
231
  });
227
232
 
@@ -293,8 +298,13 @@ describe('TimeSeriesChart', () => {
293
298
  const props = chartSpy.mock.calls.at(-1)[0];
294
299
  const primaryAxis = props.yAxis.find((axis) => axis.id === 'primary');
295
300
  const secondaryAxis = props.yAxis.find((axis) => axis.id === 'secondary');
296
- expect(typeof primaryAxis.valueFormatter).toBe('function');
297
- expect(secondaryAxis.valueFormatter).toBeUndefined();
301
+ expect(primaryAxis.valueFormatter(2, { location: 'tick' })).toBe('2');
302
+ expect(primaryAxis.valueFormatter(2.5, { location: 'tick' })).toBe('');
303
+ // THEME-9: BarChart's own default formatter now covers the secondary
304
+ // axis too (a function, not undefined) -- distinguished from the
305
+ // integer-blanking one above by NOT blanking a non-integer tick.
306
+ expect(typeof secondaryAxis.valueFormatter).toBe('function');
307
+ expect(secondaryAxis.valueFormatter(4.5, { location: 'tick' })).not.toBe('');
298
308
  });
299
309
 
300
310
  it('CHART-5: throws when a series declares axis "secondary" without secondaryYAxisLabel', () => {
@@ -318,12 +328,15 @@ describe('TimeSeriesChart', () => {
318
328
 
319
329
  it('CHART-5: toggling the non-integer series off switches the shared axis to integer-formatted', () => {
320
330
  renderChart({ data: MIXED_DATA });
321
- expect(chartSpy.mock.calls.at(-1)[0].yAxis[0].valueFormatter).toBeUndefined();
331
+ // THEME-9: BarChart's default formatter is present from the start now,
332
+ // so the CHART-5-specific signal is that a non-integer tick is NOT
333
+ // blanked yet -- the integer-blanking formatter isn't active.
334
+ expect(chartSpy.mock.calls.at(-1)[0].yAxis[0].valueFormatter(4.5, { location: 'tick' })).not.toBe('');
322
335
 
323
336
  fireEvent.click(screen.getByRole('checkbox', { name: 'Presence hours' }));
324
337
 
325
338
  const props = chartSpy.mock.calls.at(-1)[0];
326
- expect(typeof props.yAxis[0].valueFormatter).toBe('function');
339
+ expect(props.yAxis[0].valueFormatter(4.5, { location: 'tick' })).toBe('');
327
340
  });
328
341
 
329
342
  it('CHART-5: x-axis tickLabelInterval guarantees some labels always show, evenly spread', () => {
@@ -14,6 +14,11 @@ vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: lineSpy, MarkElement: 'pa
14
14
  import { BarChart } from '../src/components/charts/BarChart';
15
15
  import { LineChart } from '../src/components/charts/LineChart';
16
16
  import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
17
+ import {
18
+ defaultNumericTickFormatter,
19
+ sizeYAxisForContent,
20
+ withAxisDefaults,
21
+ } from '../src/components/charts/chartDefaults';
17
22
  import { createAppTheme } from '../src/theme/createAppTheme';
18
23
 
19
24
  const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
@@ -206,3 +211,153 @@ describe('chart chrome defaults', () => {
206
211
  });
207
212
  });
208
213
  });
214
+
215
+ // THEME-9 #2 -- `withAxisDefaults` broadcast the caller's single xAxisLabel/
216
+ // yAxisLabel string onto EVERY entry of a multi-axis array via `.map()`.
217
+ // Measured live: hram's dual-axis chart carried the identical 43-character
218
+ // label on both y-axes. This is the defect that actually shipped, pinned
219
+ // directly against the exported function rather than only through a
220
+ // rendered BarChart (which never happens to build a multi-axis array).
221
+ describe('withAxisDefaults label broadcast (THEME-9)', () => {
222
+ it('applies the single label argument only to the first axis of a multi-entry array', () => {
223
+ const result = withAxisDefaults(
224
+ [{ id: 'primary' }, { id: 'secondary' }],
225
+ 'Shared label',
226
+ { scaleType: 'linear' },
227
+ '12px',
228
+ );
229
+ expect(result[0].label).toBe('Shared label');
230
+ expect(result[1].label).toBeUndefined();
231
+ // The exact defect: never let one string land on two axes.
232
+ expect(result.filter((axis) => axis.label === 'Shared label')).toHaveLength(1);
233
+ });
234
+
235
+ it('leaves axes that set their own label untouched, whatever position they are in', () => {
236
+ const result = withAxisDefaults(
237
+ [{ label: 'Primary' }, { label: 'Secondary' }],
238
+ 'Fallback',
239
+ { scaleType: 'linear' },
240
+ '12px',
241
+ );
242
+ expect(result[0].label).toBe('Primary');
243
+ expect(result[1].label).toBe('Secondary');
244
+ });
245
+
246
+ it('still labels the common single-axis case unchanged', () => {
247
+ const result = withAxisDefaults([{ id: 'only' }], 'Cases', { scaleType: 'linear' }, '12px');
248
+ expect(result[0].label).toBe('Cases');
249
+
250
+ const synthesized = withAxisDefaults(undefined, 'Cases', { scaleType: 'linear' }, '12px');
251
+ expect(synthesized[0].label).toBe('Cases');
252
+ });
253
+ });
254
+
255
+ // THEME-9 #4 -- BarChart set no default tick formatter at all (raw domain
256
+ // numbers); a hand-rolled `Intl` compact-notation default was measured to be
257
+ // actively wrong in two of the four locales this package's consumers use:
258
+ // German does not compact thousands at all, and Swahili prepends a word
259
+ // ("elfu") that is WIDER than the raw number. Plain grouped formatting (no
260
+ // `notation: 'compact'`) is pinned here against a live `Intl.NumberFormat`
261
+ // computation rather than hardcoded locale punctuation, so the assertion
262
+ // survives ICU data updates.
263
+ describe('defaultNumericTickFormatter (THEME-9)', () => {
264
+ it.each(['de-CH', 'en', 'fr', 'sw'])('formats %s ticks the same way Intl grouping would, with no compact notation', (locale) => {
265
+ const formatter = defaultNumericTickFormatter(locale);
266
+ for (const value of [12500, 998000, 1000000000]) {
267
+ const expected = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
268
+ expect(formatter(value, { location: 'tick' })).toBe(expected);
269
+ // The exact failure mode measured live: compact notation prepending a
270
+ // wider word in Swahili, or a 'K'/'M'/'B' suffix elsewhere.
271
+ expect(formatter(value, { location: 'tick' })).not.toMatch(/[KMB]|elfu/i);
272
+ }
273
+ });
274
+
275
+ it('keeps full precision outside the tick context (tooltip/legend) rather than rounding away a meaningful difference', () => {
276
+ const formatter = defaultNumericTickFormatter('en');
277
+ expect(formatter(12500.456, { location: 'tooltip' })).toBe('12,500.456');
278
+ expect(formatter(12500.456, { location: 'tick' })).toBe('12,500.5');
279
+ });
280
+ });
281
+
282
+ // THEME-9 #1/#3 -- MUI's own y-axis width default is a flat 45px (ticks) +
283
+ // 20px (label) regardless of what the ticks actually say (confirmed against
284
+ // @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH/AXIS_LABEL_DEFAULT_HEIGHT and
285
+ // its useChartDimensions selector, where margin and axis size are additive).
286
+ // That is both too generous for short ticks and too stingy for wide
287
+ // unit-suffixed ones -- the measured Accessibility-panel overlap. Margins
288
+ // and the exact rendered overlap are verified live, not by unit test (per
289
+ // the WO); this pins the sizing FUNCTION's own contract.
290
+ describe('sizeYAxisForContent (THEME-9)', () => {
291
+ it('sizes a linear axis from its own caller-supplied min/max', () => {
292
+ const [axis] = sizeYAxisForContent(
293
+ [{ scaleType: 'linear', min: 0, max: 1, label: 'Coverage (%)' }],
294
+ [],
295
+ '12px',
296
+ );
297
+ expect(axis.width).toBeGreaterThan(0);
298
+ });
299
+
300
+ it('reserves more width for a longer formatted tick, all else equal', () => {
301
+ const [shortTick] = sizeYAxisForContent(
302
+ [{ scaleType: 'linear', min: 0, max: 1 }], [], '12px',
303
+ );
304
+ const [wideTick] = sizeYAxisForContent(
305
+ [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: (v) => `${v} minutes` }],
306
+ [], '12px',
307
+ );
308
+ expect(wideTick.width).toBeGreaterThan(shortTick.width);
309
+ });
310
+
311
+ it('falls back to the plotted series when the caller sets neither min nor max', () => {
312
+ const [axis] = sizeYAxisForContent(
313
+ [{ scaleType: 'linear' }],
314
+ [{ data: [1, 250000] }],
315
+ '12px',
316
+ );
317
+ expect(axis.width).toBeGreaterThan(0);
318
+ });
319
+
320
+ it('leaves the axis untouched when there is nothing to measure against', () => {
321
+ const [axis] = sizeYAxisForContent([{ scaleType: 'linear' }], [], '12px');
322
+ expect(axis.width).toBeUndefined();
323
+ });
324
+
325
+ it('never overrides a caller-supplied width', () => {
326
+ const [axis] = sizeYAxisForContent(
327
+ [{ scaleType: 'linear', min: 0, max: 1, width: 42 }], [], '12px',
328
+ );
329
+ expect(axis.width).toBe(42);
330
+ });
331
+
332
+ it('leaves a categorical (band/point) axis alone -- its ticks are names, not numbers', () => {
333
+ const [axis] = sizeYAxisForContent(
334
+ [{ scaleType: 'band', data: ['a', 'b'] }], [], '12px',
335
+ );
336
+ expect(axis.width).toBeUndefined();
337
+ });
338
+
339
+ // Reviewer-caught regression (THEME-9): a series with no explicit
340
+ // `yAxisId` is NOT "assigned to no axis" -- MUI defaults it to the FIRST
341
+ // axis in the array (`yAxisIds[0]`), whatever that axis' id is. Matching
342
+ // only `undefined === undefined` left the primary axis of a genuine
343
+ // dual-axis chart (id set, series unmarked) with zero matched series and
344
+ // therefore MUI's untouched flat default -- a silent no-op on exactly the
345
+ // dual-axis case this WO was measured against (TimeSeriesChart's own
346
+ // secondary-axis feature, CHART-5).
347
+ it('assigns an unmarked series to the FIRST axis in a dual-axis array, matching MUI\'s own default', () => {
348
+ const dualAxis = [{ scaleType: 'linear', id: 'primary' }, { scaleType: 'linear', id: 'secondary' }];
349
+ const series = [
350
+ { data: [1, 2, 3] }, // no yAxisId -- MUI assigns this to 'primary'
351
+ { data: [400000, 500000], yAxisId: 'secondary' },
352
+ ];
353
+
354
+ const [primary, secondary] = sizeYAxisForContent(dualAxis, series, '12px');
355
+ expect(primary.width).toBeGreaterThan(0);
356
+ expect(secondary.width).toBeGreaterThan(0);
357
+ // The secondary axis' series format much wider numbers -- it must
358
+ // reserve more width than the primary axis, not the same (which is what
359
+ // "both axes silently untouched" or "both matched the same series" would
360
+ // produce).
361
+ expect(secondary.width).toBeGreaterThan(primary.width);
362
+ });
363
+ });
@@ -171,6 +171,21 @@ describe('theme completeness', () => {
171
171
  ]),
172
172
  );
173
173
  });
174
+
175
+ it('anchors the select touch target on the input root, not the inner select slot', () => {
176
+ // THEME-8: a min-height on styleOverrides.select lands on a display:block
177
+ // element with variant-dependent, top-anchored padding -- the surplus
178
+ // height collects entirely below the text instead of centring it. The
179
+ // root is already flex/align-items:center, so anchoring there is what
180
+ // actually centres the text. A test only checking "44px somewhere" would
181
+ // pass on the broken version too.
182
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
183
+ const selectOverrides = theme.components.MuiSelect.styleOverrides;
184
+
185
+ expect(selectOverrides.select?.minHeight).toBeUndefined();
186
+ expect(selectOverrides.root.minHeight).toBe(40);
187
+ expect(selectOverrides.root['@media (any-pointer: coarse)'].minHeight).toBe(44);
188
+ });
174
189
  });
175
190
 
176
191
  describe('kit sx disjointness', () => {
@@ -0,0 +1,82 @@
1
+ # THEME-7 — Guidance: a series slot that collides with an app's own accent
2
+
3
+ ## Part A — Envelope
4
+
5
+ **Goal.** Give consuming apps a documented answer for the case where a baseline
6
+ `dataSeries.categorical` slot sits too close to something the app itself owns — the app's
7
+ accent, or one of its domain identity colours. Today the baseline offers six series colours
8
+ and no guidance for that collision, so each app rediscovers the problem on its own.
9
+
10
+ **Why — a measured case, not a hypothesis.** During hram's Imara design-language work
11
+ (hram `IMARA-2`, frozen 2026-08-13) two collisions were measured against the app's own
12
+ values, using Lab ΔE with the operator's own rejected candidate pair as the calibration
13
+ floor (that pair sat at ΔE 16.6 and was judged "not tellable apart"):
14
+
15
+ - baseline series 1 `#3D5A99` versus hram's chosen primary `#2F4F96` → **ΔE 7.2**, the
16
+ tightest pair in that app's whole system; a series line beside a primary button reads as
17
+ the same colour.
18
+ - baseline series 3 `#2E8F8A` versus hram's DALYs identity `#1F7A72` → **ΔE 8.3**; same
19
+ hue (a −28.7 vs −27.8), one lightness step apart (L 54 vs 46).
20
+
21
+ hram resolved both app-side: slot 1 dropped entirely (both replacement candidates measured
22
+ worse elsewhere — a neutral slate landed ΔE 5.0 from the shared `stale` fill, a muted green
23
+ ΔE 15.5 from the app's own olive slot), and slot 3 replaced by a navy, which simultaneously
24
+ restored the dark blue the first removal had taken away. The nine remaining slots plus an
25
+ overflow grey covered the app's needs.
26
+
27
+ **This is not a defect report against the baseline.** For an app whose primary is not a
28
+ muted indigo and which has no teal domain identity, series 1 and 3 are correct and useful.
29
+ What is missing is the *guidance*, and the observation that the baseline's own muted,
30
+ cool-leaning character makes a collision with a tastefully chosen accent more likely rather
31
+ than less — apps that like this palette tend to pick accents from the same region.
32
+
33
+ **Scope.**
34
+
35
+ 1. `DESIGN.md` — a short subsection under the data-colour principle: how to check a chosen
36
+ accent against the series palette (measure, do not eyeball; a usable threshold with the
37
+ calibration above named as its provenance), and what to do when it collides — drop the
38
+ slot rather than nudge it, because a nudged slot stays confusable while a dropped one is
39
+ simply absent.
40
+ 2. The same note in the theme package's own documentation where `dataSeries.categorical` is
41
+ described, so it is found by someone reading the token rather than the design doc.
42
+ 3. **Decide and record**: whether the baseline should additionally *ship* anything —
43
+ candidates being an exported ΔE helper apps can assert with, a documented extension
44
+ sequence for apps needing more than six slots, or nothing at all beyond the guidance.
45
+ Doing nothing is a legitimate outcome and should be recorded as a decision if chosen.
46
+
47
+ **Non-goals / do-not-touch.** Do **not** change the six shipped series values — hram's
48
+ divergence is app-specific and other consumers depend on the current set. No change to the
49
+ status palette, the completeness assertion, or any component. This is documentation plus,
50
+ at most, one small additive export.
51
+
52
+ **Tier 3** — shared-core surface, per the estate tiering table, even though the change is
53
+ documentation-led. Independent review mandatory.
54
+
55
+ **Tests to write.** None for the documentation. If item 3 lands a helper, that helper gets
56
+ its own unit test with the two measured pairs above as fixtures — they are real values with
57
+ known answers.
58
+
59
+ **Risks.** The guidance is derived from a single app's experience; state that plainly rather
60
+ than presenting it as a general law. The threshold in particular is calibrated on one
61
+ operator's judgement — useful as a starting point, not as a standard, and it should say so.
62
+
63
+ ## Part B — Implementation map (implementer)
64
+
65
+ PLACEHOLDER — Orchestrator fills this on `git pull`: context package with `path:line`
66
+ anchors into `DESIGN.md` and the token documentation, the absolute working directory, the
67
+ progress contract, the preamble. Not dispatchable while this stands.
68
+
69
+ ## Part C — Orchestrator only
70
+
71
+ **STOP — addressee guard.** If you are the implementer reading this file as your own spec:
72
+ this part is not addressed to you. It tells the Orchestrator how to invoke you; you ARE that
73
+ invocation — do not shell out to `codex exec`. Stop reading at this line.
74
+
75
+ - **Execution.** Codex first per the Tier-2/3 rule unless the status record says otherwise.
76
+ - **Review routing.** `reviewer` mandatory (Tier 3). No `ui_reviewer` unless item 3 turns
77
+ into code with a visual surface, which it should not.
78
+ - **Verification.** Scoped: the theme package's existing tests must stay green; any new
79
+ helper carries its own.
80
+ - **Cross-repo.** Reference hram `IMARA-2` and its frozen sheet as the evidence source; do
81
+ not restate the measurements from memory when the artifact holds them.
82
+