@micha.bigler/ui-core-micha 3.0.1 → 3.1.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.
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ // UCM-CHART-14: a parser, not a grep. This is the whole point of this file -- five wrong consumer
3
+ // counts in this series (see work-orders/UCM-CHART-14.md) all came from a grep pattern that could
4
+ // not see JSX structure: a prop several lines below the component name, an arrow function's `=>`
5
+ // inside a prop value, an apostrophe inside a comment, a `>` inside a nested `{}` expression. A
6
+ // regex line-match cannot tell any of those apart from the thing it's looking for. This script
7
+ // tracks string/template/comment/regex-literal state and brace depth in ONE continuous pass over
8
+ // each file, so its answer is reproducible output, not a claim someone has to trust.
9
+ //
10
+ // Second review round (both `reviewer` and `ui_reviewer`, independently) found the first version's
11
+ // three-pass design (a raw `RegExp.exec` tag-open search, oblivious to string/comment context, then
12
+ // a per-tag bounds scan, then a per-tag attribute scan) could itself produce a WRONG, non-UNPARSED
13
+ // answer -- worse than an honest UNPARSED, because nothing flags it: JSX-looking text inside a
14
+ // string or comment counted as a real element, and a regex literal's `}`/`>` characters (e.g.
15
+ // `data={/[}>]/}`) corrupted brace-depth tracking and closed a tag early. Both are fixed here by
16
+ // giving every stage the SAME shared state, tracked once, forward, per file.
17
+ //
18
+ // Dev script only -- never imported by the published package (kept out of package.json's `files`/
19
+ // `exports`), never writes anything, and degrades to "not present" for a missing/frontend-less
20
+ // sibling repo rather than throwing.
21
+
22
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
23
+ import { join, relative, dirname } from 'node:path';
24
+ import { fileURLToPath, pathToFileURL } from 'node:url';
25
+
26
+ // The five props this WO's series has spent five work orders removing/relocating -- what a census
27
+ // exists to count is exactly "who still passes one of these".
28
+ export const TARGET_PROPS = ['aspect', 'height', 'minHeight', 'margin', 'xAxisAngle'];
29
+ export const TARGET_COMPONENTS = ['BarChart', 'LineChart', 'ScatterChart', 'TimeSeriesChart', 'ChartFrame'];
30
+ const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx']);
31
+ const SKIP_DIR_NAMES = new Set(['node_modules', 'dist', 'build', '.git']);
32
+ const PACKAGE_SPECIFIER = '@micha.bigler/ui-core-micha';
33
+
34
+ /**
35
+ * Finds every `import { ... } from '@micha.bigler/ui-core-micha'` in `source` and returns a map
36
+ * of LOCAL name -> CANONICAL export name, restricted to `TARGET_COMPONENTS`. Import declarations
37
+ * are a much simpler grammar than JSX (no nested arbitrary expressions in the specifier list), so
38
+ * a regex is appropriate here -- unlike the JSX scan below, which is not.
39
+ */
40
+ function findImportedLocalNames(source) {
41
+ const localToCanonical = new Map();
42
+ const importRe = /import\s*\{([^}]*)\}\s*from\s*['"]@micha\.bigler\/ui-core-micha['"]/g;
43
+ let match;
44
+ while ((match = importRe.exec(source)) !== null) {
45
+ const specifiers = match[1].split(',').map((s) => s.trim()).filter(Boolean);
46
+ for (const spec of specifiers) {
47
+ const asMatch = spec.match(/^(\w+)\s+as\s+(\w+)$/);
48
+ const canonical = asMatch ? asMatch[1] : spec;
49
+ const local = asMatch ? asMatch[2] : spec;
50
+ if (TARGET_COMPONENTS.includes(canonical)) localToCanonical.set(local, canonical);
51
+ }
52
+ }
53
+ return localToCanonical;
54
+ }
55
+
56
+ function lineNumberAt(source, index) {
57
+ let line = 1;
58
+ for (let i = 0; i < index; i += 1) if (source[i] === '\n') line += 1;
59
+ return line;
60
+ }
61
+
62
+ const IDENT_START = /[A-Za-z_$]/;
63
+ const IDENT_CHAR = /[A-Za-z0-9_$]/;
64
+ // Regex-literal-start heuristic: a `/` is treated as opening a regex literal (not division) only
65
+ // when the last significant character before it is one of these -- the common "a regex can only
66
+ // start where a VALUE is expected, not follow one" rule real JS tokenizers use. Not a full parser;
67
+ // documented, and sufficient for `data={/[}>]/}`-shaped prop values, the concrete case found in
68
+ // review. A `/` following an identifier/number/`)`/`]` is treated as division and left alone.
69
+ //
70
+ // Regression found DURING this same fix's own verification run (before it ever reached review):
71
+ // `<` and `>` were originally in this set on the theory that `/` can start a regex right after a
72
+ // comparison operator -- but `<`/`>` are also exactly what precede the `/` in EVERY JSX closing
73
+ // tag (`</Something>`) and self-closing tag (`Something />`). With them included, `</ChartFrame>`
74
+ // itself was misread as a regex-literal start, consuming everything up to the next stray `/` in
75
+ // the file and corrupting brace depth for the rest of the scan -- turning every multi-line
76
+ // ChartFrame usage in the estate into a false UNPARSED. A `/` genuinely starting a regex right
77
+ // after `<`/`>` (e.g. `a < /regex/.test(b)`) does not occur in practice in this codebase; dropped.
78
+ const REGEX_PRECEDING_CHARS = new Set(['(', '{', '[', ',', ';', ':', '=', '&', '|', '!', '?', '+', '-', '*', '%', '^', '~']);
79
+
80
+ /**
81
+ * Extracts top-level (tag-depth-0, RELATIVE TO `tagSource`) attribute names from `tagSource` (a
82
+ * full, already-correctly-bounded `<Name ...>` span). Shares the same string/template/comment/
83
+ * regex tracking as the outer scanner, so a prop value containing a regex literal, a nested
84
+ * object, or a comment can never be mistaken for a top-level attribute name -- `sx={{ aspect: 1
85
+ * }}` never reports a false `aspect` (an object key inside an expression, not a JSX attribute),
86
+ * and `data={/[}>]/}` never corrupts this function's own depth count either.
87
+ */
88
+ function extractTopLevelAttributeNames(tagSource) {
89
+ const found = new Set();
90
+ let depth = 0;
91
+ let inString = null;
92
+ let inLineComment = false;
93
+ let inBlockComment = false;
94
+ let inRegex = false;
95
+ let inRegexClass = false;
96
+ let lastSignificant = null;
97
+
98
+ for (let i = 0; i < tagSource.length; i += 1) {
99
+ const c = tagSource[i];
100
+ const next = tagSource[i + 1];
101
+
102
+ if (inLineComment) { if (c === '\n') inLineComment = false; continue; }
103
+ if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i += 1; } continue; }
104
+ if (inRegex) {
105
+ if (c === '\\') { i += 1; continue; }
106
+ if (c === '[') { inRegexClass = true; continue; }
107
+ if (c === ']') { inRegexClass = false; continue; }
108
+ if (c === '/' && !inRegexClass) {
109
+ inRegex = false;
110
+ let j = i + 1;
111
+ while (j < tagSource.length && /[a-z]/i.test(tagSource[j])) j += 1;
112
+ i = j - 1;
113
+ }
114
+ continue;
115
+ }
116
+ if (inString) {
117
+ if (c === '\\') { i += 1; continue; }
118
+ if (c === inString) inString = null;
119
+ continue;
120
+ }
121
+ if (c === '/' && next === '/') { inLineComment = true; i += 1; continue; }
122
+ if (c === '/' && next === '*') { inBlockComment = true; i += 1; continue; }
123
+ if (c === '/' && (lastSignificant === null || REGEX_PRECEDING_CHARS.has(lastSignificant))) {
124
+ inRegex = true; inRegexClass = false; lastSignificant = '/'; continue;
125
+ }
126
+ if (c === '"' || c === "'" || c === '`') { inString = c; lastSignificant = c; continue; }
127
+ if (c === '{') { depth += 1; lastSignificant = c; continue; }
128
+ if (c === '}') { depth = Math.max(0, depth - 1); lastSignificant = c; continue; }
129
+
130
+ if (depth === 0 && IDENT_START.test(c) && (i === 0 || !IDENT_CHAR.test(tagSource[i - 1]))) {
131
+ let j = i + 1;
132
+ while (j < tagSource.length && IDENT_CHAR.test(tagSource[j])) j += 1;
133
+ found.add(tagSource.slice(i, j));
134
+ lastSignificant = tagSource[j - 1];
135
+ i = j - 1;
136
+ continue;
137
+ }
138
+ if (!/\s/.test(c)) lastSignificant = c;
139
+ }
140
+ return found;
141
+ }
142
+
143
+ /**
144
+ * The single-pass scanner. Walks `source` exactly ONCE, maintaining ONE shared state for strings,
145
+ * template literals (+ `${}` expression nesting), line/block comments, regex literals, and brace
146
+ * depth -- so tag-open detection and tag-close detection agree on where the real code is, instead
147
+ * of a separate context-blind pass for each. A `<Name` is only recognised as a real JSX tag open
148
+ * when the scanner is not currently inside a string/comment/regex, which is what stops `// <BarChart
149
+ * aspect />` or `"<BarChart aspect />"` from being counted. Multiple target tags can be pending at
150
+ * once (a component nested inside another's own prop expression, e.g. `<ChartFrame content={<>
151
+ * <BarChart aspect /></>} />`) -- each is closed independently, at the exact brace depth it opened
152
+ * at, so a nested tag closing does not consume or corrupt the outer tag's own remaining scan.
153
+ *
154
+ * A tag still open when the file ends is reported UNPARSED, never dropped (Definition of Done).
155
+ */
156
+ function scanForComponents(source, localToCanonical) {
157
+ const results = [];
158
+ const openTags = []; // { canonical, tagStart, closeAtDepth, line }
159
+ const localNames = [...localToCanonical.keys()];
160
+
161
+ let inString = null;
162
+ let inLineComment = false;
163
+ let inBlockComment = false;
164
+ let inRegex = false;
165
+ let inRegexClass = false;
166
+ let depth = 0;
167
+ const templateDepthStack = [];
168
+ let lastSignificant = null;
169
+
170
+ const closePendingAtCurrentDepth = (closeIndex) => {
171
+ for (let k = openTags.length - 1; k >= 0; k -= 1) {
172
+ if (openTags[k].closeAtDepth === depth) {
173
+ const tag = openTags.splice(k, 1)[0];
174
+ const tagSource = source.slice(tag.tagStart, closeIndex + 1);
175
+ const attrNames = extractTopLevelAttributeNames(tagSource);
176
+ const props = {};
177
+ for (const prop of TARGET_PROPS) props[prop] = attrNames.has(prop);
178
+ results.push({ component: tag.canonical, line: tag.line, unparsed: false, props });
179
+ }
180
+ }
181
+ };
182
+
183
+ for (let i = 0; i < source.length; i += 1) {
184
+ const c = source[i];
185
+ const next = source[i + 1];
186
+
187
+ if (inLineComment) { if (c === '\n') inLineComment = false; continue; }
188
+ if (inBlockComment) { if (c === '*' && next === '/') { inBlockComment = false; i += 1; } continue; }
189
+ if (inRegex) {
190
+ if (c === '\\') { i += 1; continue; }
191
+ if (c === '[') { inRegexClass = true; continue; }
192
+ if (c === ']') { inRegexClass = false; continue; }
193
+ if (c === '/' && !inRegexClass) {
194
+ inRegex = false;
195
+ let j = i + 1;
196
+ while (j < source.length && /[a-z]/i.test(source[j])) j += 1;
197
+ i = j - 1;
198
+ }
199
+ continue;
200
+ }
201
+ if (inString) {
202
+ if (c === '\\') { i += 1; continue; }
203
+ if (inString === '`' && c === '$' && next === '{') {
204
+ templateDepthStack.push(depth);
205
+ depth += 1;
206
+ i += 1;
207
+ inString = null;
208
+ lastSignificant = '{';
209
+ continue;
210
+ }
211
+ if (c === inString) inString = null;
212
+ continue;
213
+ }
214
+
215
+ if (c === '/' && next === '/') { inLineComment = true; i += 1; continue; }
216
+ if (c === '/' && next === '*') { inBlockComment = true; i += 1; continue; }
217
+ if (c === '/' && (lastSignificant === null || REGEX_PRECEDING_CHARS.has(lastSignificant))) {
218
+ inRegex = true; inRegexClass = false; lastSignificant = '/'; continue;
219
+ }
220
+ if (c === '"' || c === "'" || c === '`') { inString = c; lastSignificant = c; continue; }
221
+
222
+ if (c === '{') { depth += 1; lastSignificant = c; continue; }
223
+ if (c === '}') {
224
+ if (templateDepthStack.length && depth - 1 === templateDepthStack[templateDepthStack.length - 1]) {
225
+ templateDepthStack.pop();
226
+ depth -= 1;
227
+ inString = '`';
228
+ lastSignificant = '}';
229
+ continue;
230
+ }
231
+ depth = Math.max(0, depth - 1);
232
+ lastSignificant = c;
233
+ continue;
234
+ }
235
+
236
+ if (c === '<') {
237
+ let matchedName = null;
238
+ for (const name of localNames) {
239
+ if (source.startsWith(name, i + 1)) {
240
+ const after = source[i + 1 + name.length];
241
+ if (after === undefined || /[\s/>]/.test(after)) { matchedName = name; break; }
242
+ }
243
+ }
244
+ if (matchedName) {
245
+ openTags.push({
246
+ canonical: localToCanonical.get(matchedName),
247
+ tagStart: i,
248
+ closeAtDepth: depth,
249
+ line: lineNumberAt(source, i),
250
+ });
251
+ i += matchedName.length;
252
+ lastSignificant = matchedName[matchedName.length - 1];
253
+ continue;
254
+ }
255
+ lastSignificant = c;
256
+ continue;
257
+ }
258
+
259
+ if (c === '>') {
260
+ closePendingAtCurrentDepth(i);
261
+ lastSignificant = c;
262
+ continue;
263
+ }
264
+
265
+ if (!/\s/.test(c)) lastSignificant = c;
266
+ }
267
+
268
+ for (const tag of openTags) {
269
+ results.push({ component: tag.canonical, line: tag.line, unparsed: true, props: {} });
270
+ }
271
+ return results;
272
+ }
273
+
274
+ /**
275
+ * The pure analysis function -- no filesystem access, so this is what the fixture tests below
276
+ * exercise directly. Returns one entry per JSX element found whose tag name resolves (via the
277
+ * file's own imports) to one of `TARGET_COMPONENTS`.
278
+ *
279
+ * Documented, accepted heuristic limitation (unchanged from the first version, not solved by the
280
+ * single-pass rewrite): `<` is only ever treated as a JSX tag open, never as a less-than
281
+ * comparison against an uppercase-starting identifier (`foo < BarChart`). Real component
282
+ * references are never used in a numeric/relational comparison in practice, so this is judged a
283
+ * theoretical gap, not a practical one -- unlike the string/comment/regex-literal blindness fixed
284
+ * above, which were concrete, plausible false positives.
285
+ */
286
+ export function findChartApiUsage(source) {
287
+ const localToCanonical = findImportedLocalNames(source);
288
+ if (localToCanonical.size === 0) return [];
289
+ return scanForComponents(source, localToCanonical);
290
+ }
291
+
292
+ function walkSourceFiles(rootDir) {
293
+ const files = [];
294
+ function walk(dir) {
295
+ let entries;
296
+ try {
297
+ entries = readdirSync(dir, { withFileTypes: true });
298
+ } catch {
299
+ return;
300
+ }
301
+ for (const entry of entries) {
302
+ if (entry.name.startsWith('.')) continue;
303
+ if (SKIP_DIR_NAMES.has(entry.name)) continue;
304
+ const full = join(dir, entry.name);
305
+ if (entry.isDirectory()) {
306
+ walk(full);
307
+ } else if (entry.isFile()) {
308
+ const dot = entry.name.lastIndexOf('.');
309
+ if (dot === -1) continue;
310
+ if (SOURCE_EXTENSIONS.has(entry.name.slice(dot))) files.push(full);
311
+ }
312
+ }
313
+ }
314
+ walk(rootDir);
315
+ return files;
316
+ }
317
+
318
+ /**
319
+ * Locates an app's frontend source root: `frontend/src` first (this estate's usual layout), then
320
+ * a bare `src/` fallback. Returns `null` (never throws) if neither exists -- a repo with no
321
+ * frontend, or absent entirely, is reported as "not present", not an error.
322
+ */
323
+ function findAppSourceRoot(appDir) {
324
+ const candidates = [join(appDir, 'frontend', 'src'), join(appDir, 'src')];
325
+ for (const candidate of candidates) {
326
+ if (existsSync(candidate) && statSync(candidate).isDirectory()) return candidate;
327
+ }
328
+ return null;
329
+ }
330
+
331
+ export function runCensus(workspaceRoot, { excludeDirs = ['ui-core-micha'] } = {}) {
332
+ let appNames;
333
+ try {
334
+ appNames = readdirSync(workspaceRoot, { withFileTypes: true })
335
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') && !excludeDirs.includes(e.name))
336
+ .map((e) => e.name);
337
+ } catch (err) {
338
+ return { workspaceRoot, apps: [], error: `workspace root unreadable: ${err.message}` };
339
+ }
340
+
341
+ const apps = appNames.map((appName) => {
342
+ const appDir = join(workspaceRoot, appName);
343
+ const sourceRoot = findAppSourceRoot(appDir);
344
+ if (!sourceRoot) return { app: appName, present: false, entries: [] };
345
+
346
+ const entries = [];
347
+ for (const file of walkSourceFiles(sourceRoot)) {
348
+ let source;
349
+ try {
350
+ source = readFileSync(file, 'utf8');
351
+ } catch {
352
+ continue; // unreadable file (permissions, symlink race) -- not a parse failure, skip quietly
353
+ }
354
+ if (!source.includes(PACKAGE_SPECIFIER)) continue; // fast path: no ucm import at all
355
+ const usages = findChartApiUsage(source);
356
+ for (const usage of usages) {
357
+ entries.push({ ...usage, file: relative(workspaceRoot, file).replace(/\\/g, '/') });
358
+ }
359
+ }
360
+ return { app: appName, present: true, entries };
361
+ });
362
+
363
+ return { workspaceRoot, apps };
364
+ }
365
+
366
+ /**
367
+ * Reports EVERY element found (Definition of Done: "every `<ChartFrame>` and every ucm chart
368
+ * preset element"), not just the ones carrying a target prop -- a clean element is printed as
369
+ * `(clean)`, not omitted, so "this app has zero matching elements at all" and "this app has
370
+ * elements, all clean" stay visibly distinct instead of looking identical.
371
+ */
372
+ function formatReport(result) {
373
+ const lines = [];
374
+ let totalUnparsed = 0;
375
+ for (const { app, present, entries } of result.apps) {
376
+ if (!present) { lines.push(`${app}: not present (no frontend/src or src)`); continue; }
377
+ if (entries.length === 0) continue;
378
+ lines.push(`${app}:`);
379
+ for (const entry of entries) {
380
+ if (entry.unparsed) {
381
+ totalUnparsed += 1;
382
+ lines.push(` UNPARSED ${entry.file}:${entry.line} <${entry.component}> -- could not close this tag, not counted as clean`);
383
+ continue;
384
+ }
385
+ const activeProps = TARGET_PROPS.filter((p) => entry.props[p]);
386
+ const propsText = activeProps.length > 0 ? activeProps.join(' ') : '(clean)';
387
+ lines.push(` ${entry.file}:${entry.line} <${entry.component} ${propsText}>`);
388
+ }
389
+ }
390
+ if (totalUnparsed > 0) {
391
+ lines.push('');
392
+ lines.push(`${totalUnparsed} tag(s) could not be parsed -- see UNPARSED lines above. Not counted as clean.`);
393
+ }
394
+ return lines.join('\n');
395
+ }
396
+
397
+ // `pathToFileURL` (not manual string-building) is what makes this comparison correct on Windows,
398
+ // where a bare `file://${path}` is missing the extra slash before the drive letter that
399
+ // `import.meta.url` actually has (`file:///C:/...`) -- a naive comparison silently never matches,
400
+ // so `node scripts/chart-api-census.mjs` would run to completion with zero output and no error.
401
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
402
+ if (isMain) {
403
+ const workspaceRoot = process.argv[2] || join(dirname(fileURLToPath(import.meta.url)), '..', '..');
404
+ const result = runCensus(workspaceRoot);
405
+ // eslint-disable-next-line no-console
406
+ console.log(formatReport(result));
407
+ }
@@ -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,33 @@ 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: it WAS wired to `aspectRatio` on the box at 3.0.1, and worked.
28
+ *
29
+ * UCM-CHART-14 CORRECTION -- left legible on purpose, not deleted: `UCM-CHART-13` shipped claiming
30
+ * "no consumer across the five apps passes [aspect] (measured against 3.0.1)". **That measurement
31
+ * was false.** A parser-based census (`scripts/chart-api-census.mjs`, ground truth over grep) found
32
+ * FOUR live call sites in fitness-monitor (`BodyHistoryPage.jsx:295,444`, `EnvironmentPage.jsx:245,279`)
33
+ * that a `head`-truncated grep had silently missed -- the sixth wrong consumer count in this series
34
+ * (see `UCM-CHART-14`'s own WO for the other five). The REMOVAL itself still stands (`aspect` stays
35
+ * gone, the operator confirmed this is not being reopened) -- only the record of who it affected was
36
+ * wrong, and is corrected here rather than quietly rewritten. Those four sites are `FM-CHART-1`'s
37
+ * scope, not this file's. Passing either prop now throws in dev via the same `assertRemovedChartProp`
38
+ * the four chart presets use (`UCM-CHART-12`), naming `minHeight` (a card floor) or the chart's own
39
+ * `size` prop as the replacement -- `aspect`'s own message states plainly that it WAS applied and
40
+ * this is a real behaviour change (`UCM-CHART-14`, F2: the message previously said the opposite).
46
41
  */
47
42
  export function ChartFrame({
48
43
  title,
@@ -54,8 +49,6 @@ export function ChartFrame({
54
49
  isEmpty = false,
55
50
  emptyMessage,
56
51
  minHeight,
57
- height,
58
- aspect,
59
52
  exportOptions = false,
60
53
  onExportSvg,
61
54
  onExportPng,
@@ -65,6 +58,10 @@ export function ChartFrame({
65
58
  variant = 'outlined',
66
59
  titleVariant = 'h6',
67
60
  sx,
61
+ // UCM-CHART-13: `height`/`aspect` are gone from the signature itself (DoD) -- captured via rest,
62
+ // not named parameters, so they read as "detected leftovers", not "accepted props" a reader might
63
+ // assume are still wired to something.
64
+ ...legacyProps
68
65
  }) {
69
66
  const { t } = useTranslation();
70
67
  const theme = useTheme();
@@ -78,9 +75,18 @@ export function ChartFrame({
78
75
  const chartLabel = ariaLabel || title;
79
76
  const callerSx = Array.isArray(sx) ? sx : [sx];
80
77
 
81
- useEffect(
82
- () => warnOnChartFrameHeightMismatch(minHeight, height),
83
- [minHeight, height],
78
+ assertRemovedChartProp(
79
+ 'ChartFrame', 'height', legacyProps.height,
80
+ 'Use minHeight for the card floor, or size on the chart inside the frame if you meant the chart\'s own height.',
81
+ 'UCM-CHART-13, v3.1.0',
82
+ );
83
+ assertRemovedChartProp(
84
+ 'ChartFrame', 'aspect', legacyProps.aspect,
85
+ 'It WAS applied here (aspectRatio on the card box, through 3.0.1) -- this is a real behaviour '
86
+ + 'change, not a no-op removal. The card\'s height now follows its content, with minHeight as '
87
+ + 'the floor. If you wanted the chart itself taller or shorter, set size on the chart inside '
88
+ + 'the frame.',
89
+ 'UCM-CHART-14, v3.1.1',
84
90
  );
85
91
 
86
92
  const runExport = async (type) => {
@@ -136,7 +142,6 @@ export function ChartFrame({
136
142
  sx={{
137
143
  width: '100%',
138
144
  minHeight,
139
- aspectRatio: aspect,
140
145
  display: 'flex',
141
146
  alignItems: 'center',
142
147
  justifyContent: 'center',
@@ -556,12 +556,18 @@ export function resolveChartLayout({
556
556
  * migration path instead of a layout that quietly stops matching what the prop says) -- gated on
557
557
  * `NODE_ENV !== 'production'` like the deleted `warnOnHeightMismatch` was, so a shared package
558
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.
559
565
  */
560
- export function assertRemovedChartProp(componentName, propName, value, replacement) {
566
+ export function assertRemovedChartProp(componentName, propName, value, replacement, removedIn = 'UCM-CHART-12, v3.0.0') {
561
567
  if (value === undefined || process.env.NODE_ENV === 'production') return;
562
568
  throw new Error(
563
- `[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in UCM-CHART-12, `
564
- + `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}`,
565
571
  );
566
572
  }
567
573
 
@@ -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,79 @@ 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` was destructured but never applied to the box -- genuinely dead, no
255
+ // consumer lost anything when it went. `aspect` is DIFFERENT: it WAS applied (`aspectRatio` on
256
+ // the box, through 3.0.1). UCM-CHART-13 shipped claiming no consumer passed it either -- that was
257
+ // false (a head-truncated grep missed four live call sites in fitness-monitor); UCM-CHART-14
258
+ // corrects the record and rewrites `aspect`'s own error message, which previously said "never
259
+ // applied" -- the opposite of what happened, and actively misleading for the four callers who
260
+ // will actually see it. Both props are gone; passing either throws in dev, naming the real
261
+ // replacement, the same mechanism the four chart presets use (`UCM-CHART-12`).
262
+ describe('removed height/aspect props throw in dev, naming their replacement (UCM-CHART-13/14)', () => {
263
+ it('throws when a caller still passes height, naming minHeight AND size as the replacement', () => {
264
+ expect(() => renderFrame({ height: 380 })).toThrow(/ChartFrame.*height.*minHeight.*size/s);
261
265
  });
262
266
 
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');
267
+ it('throws when a caller still passes aspect, naming size as the replacement -- WITHOUT claiming it was never applied', () => {
268
+ const message = (() => {
269
+ try { renderFrame({ aspect: 1.8 }); } catch (err) { return err.message; }
270
+ throw new Error('expected renderFrame to throw');
271
+ })();
272
+ expect(message).toMatch(/ChartFrame.*aspect.*size/s);
273
+ // ui_reviewer finding: the prior version of this test only checked /size/, which a message
274
+ // naming JUST size (and dropping minHeight) would still pass -- the card's own floor is
275
+ // still minHeight, so the message must say so too, not only point at the chart's `size`.
276
+ expect(message).toMatch(/minHeight/);
277
+ // UCM-CHART-14 F2: the message must not repeat the false "never applied" claim that shipped
278
+ // in UCM-CHART-13 -- aspect DID apply aspectRatio to the box, and removing it is a real,
279
+ // visible change for the four callers who will hit this. A test only matching /size/ would
280
+ // pass on either wording, which is exactly how the false message shipped unnoticed.
281
+ expect(message).not.toMatch(/never applied/i);
282
+ expect(message).toMatch(/was applied/i);
283
+ });
284
+
285
+ it('attributes the height removal to UCM-CHART-13/v3.1.0 (unchanged -- height\'s message was already true)', () => {
286
+ expect(() => renderFrame({ height: 380 })).toThrow(/UCM-CHART-13.*v3\.1\.0/);
287
+ });
288
+
289
+ it('attributes the aspect removal to UCM-CHART-14/v3.1.1 (the corrected message, not the original false one)', () => {
290
+ expect(() => renderFrame({ aspect: 1.8 })).toThrow(/UCM-CHART-14.*v3\.1\.1/);
291
+ });
292
+
293
+ it('does not throw when only minHeight is passed', () => {
294
+ expect(() => renderFrame({ minHeight: 320 })).not.toThrow();
295
+ });
296
+
297
+ it('stays inert in production even when height/aspect are passed', () => {
298
+ const original = process.env.NODE_ENV;
299
+ process.env.NODE_ENV = 'production';
300
+ try {
301
+ expect(() => renderFrame({ height: 380, aspect: 1.8 })).not.toThrow();
302
+ } finally {
303
+ process.env.NODE_ENV = original;
304
+ }
268
305
  });
269
306
 
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();
307
+ it('applies nothing to the box for a still-passed height/aspect (dev-mode error aside) -- confirmed via the production path', () => {
308
+ const original = process.env.NODE_ENV;
309
+ process.env.NODE_ENV = 'production';
310
+ try {
311
+ renderFrame({ minHeight: 320, height: 380, aspect: 1.8 });
312
+ const style = window.getComputedStyle(screen.getByTestId('chart-body').parentElement);
313
+ expect(style.minHeight).toBe('320px');
314
+ expect(style.height).toBe('auto');
315
+ expect(style.aspectRatio).toBe('auto');
316
+ } finally {
317
+ process.env.NODE_ENV = original;
318
+ }
279
319
  });
280
320
  });
281
321
  });