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

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