@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.
- package/DESIGN.md +1 -0
- package/WORK_ORDERS.md +3 -0
- package/dist/components/charts/BarChart.js +14 -8
- package/dist/components/charts/LineChart.js +15 -9
- package/dist/components/charts/chartDefaults.js +139 -1
- package/dist/theme/themeCompleteness.js +2 -2
- package/dist/theme/tokens.js +1 -1
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +19 -7
- package/src/components/charts/LineChart.jsx +24 -12
- package/src/components/charts/chartDefaults.js +142 -2
- package/src/theme/themeCompleteness.js +2 -2
- package/src/theme/tokens.js +1 -1
- package/tests/BarChart.test.jsx +20 -4
- package/tests/TimeSeriesChart.test.jsx +19 -6
- package/tests/chartDefaults.test.js +155 -0
- package/tests/themeCompleteness.test.js +15 -0
- package/work-orders/THEME-7.md +82 -0
- package/work-orders/THEME-8.md +211 -0
- package/work-orders/THEME-9.md +194 -0
|
@@ -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
|
-
|
|
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.
|
|
77
|
-
componentKeyLeaf('MuiSelect', 'styleOverrides.
|
|
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'),
|
package/src/theme/tokens.js
CHANGED
package/tests/BarChart.test.jsx
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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(
|
|
297
|
-
expect(
|
|
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
|
-
|
|
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(
|
|
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
|
+
|