@micha.bigler/ui-core-micha 3.0.0 → 3.0.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.
package/WORK_ORDERS.md CHANGED
@@ -94,4 +94,4 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
94
94
  | UCM-CHART-9 | `ChartFrame` is a card, not a chart: it must never take a fixed height | REGRESSION in published 2.42.0, observed on hram staging. UCM-CHART-8 wired `resolveChartHeight()` through the four chart presets AND through `ChartFrame`. Correct for the presets, wrong for the frame: in the `minHeight`-alone branch the resolver returns `chartHeight = minHeight`, and `ChartFrame` applies it as `height` on its content box. A preset's box holds one chart; **`ChartFrame`'s box holds a whole card** -- title, toolbar, chart, legend, footnotes, export links. The floor became a fixed height, the content overflows, and ADJACENT CARDS VISIBLY OVERLAP (card title drawn over the chart above it, three sets of Export SVG/PNG stacked at different offsets). UCM-CHART-8's own Files note recorded that `ChartFrame` never destructures `height` -- the implementation then gave it one. | 2026-08-21 | done | 2026-08-22: `ChartFrame` fix + version bump + accurate mismatch warning | Tier 3, shared-core, live regression in `latest`. Full order: [`work-orders/UCM-CHART-9.md`](work-orders/UCM-CHART-9.md). Operator chose FIX-FORWARD over reverting hram's pin (2026-08-21), because 2.42.0 also appears to have stopped hram's Detailed-Results React #185 crash -- plausible mechanism is that deterministic sizing removed a resize-driven setState loop, but that is a HYPOTHESIS, not a finding, and this WO must re-check whether the crash returns (if it does, that is a finding for HRAM-RES-29, not a reason to undo this). Do NOT revert UCM-CHART-8: its preset behaviour is correct and includes the `aspect` branch four live call sites depend on; this WO narrows one consumer of the resolver only, and any edit needed to a preset test means the change has leaked. **Codex:** out of credits on the one allowed probe today (`.claude/codex-status.md`, 2026-08-22) -- Orchestrator implemented directly, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at Tier 3, so no routing change. **Reviews:** `reviewer` -- 2 findings (R1 version not bumped in-diff, fixed by bumping `package.json` to 2.42.1 in this commit so `publish.yml`'s version-increase gate fires on push; R2 jsdom-can't-measure-layout note on one test, non-blocking, covered by the live-browser check below). `ui_reviewer` -- 2 findings (U1 `warnOnHeightMismatch`'s message was left factually wrong for `ChartFrame` post-fix -- FIXED: added a `heightWins` param, `ChartFrame` now gets its own accurate wording, test coverage added in both `chartDefaults.test.js` and `ChartFrame.test.jsx`; U2 height-alone-on-ChartFrame is now a silent no-op with no dev warning -- accepted as-is, out of scope for this regression fix). Both reviews clean after fixes, no open findings. **Tests:** affected-area set (`ChartFrame`, `chartDefaults`, all four presets) -- 129/129 green, preset assertions byte-identical/unchanged. **Rendered check:** this repo's own dev harness (`npm run dev`, `dev/entries.jsx` "Charts / ScatterChart (allocation shape)") driven live in the internal preview browser at 375px and 1280px -- DOM/computed-style inspection (no screenshot capture available in this session; declared substitution per the gate's fallback clause), confirmed the content box carries no fixed `height` and, after forcing the inner chart element's rendered height up to 900px/1100px to reproduce the regression scenario, the box grew to match with zero scrollHeight/clientHeight overflow at both widths. The Envelope's full five-card check (Sensitivity, Access, Access Ladder, Allocation, Optimization, rendered in hram itself) is **NOT done by this WO** -- this repo's harness only has 3 of the 5 named cards and no live hram instance was linked against this build. Carried forward as a mandatory precondition on whichever WO next bumps hram's `ui-core-micha` pin past 2.42.0. 2.42.0 stays flagged do-not-adopt; 2.42.1 publishes automatically via `publish.yml` on this push (version-increase gate). PROCESS NOTE, recorded deliberately: 2.42.0 shipped on 188 green unit tests that say nothing about layout -- the rendered check above is the layout-level evidence this WO exists to add, even though it stops short of the full hram five-card scope. FIVE-CARD CHECK DONE 2026-08-22, hram with real data (SG Sandbox CLI / `CLI-Kilombero_2026-per_cell-network`), ucm 2.42.1 loaded, at 1280 px AND 375 px: zero boxes with an inline fixed height, zero overflowing cards, zero overlapping card pairs, no horizontal body scroll, bottom dead space 17 px (card padding) on every card. CHART-9 does what it set out to do. SUBSTITUTION DECLARED: screenshot capture was unavailable all session, so this is DOM + computed-style measurement, not images. hram pin bumped to 2.42.1 in `6dd00c1d`. NOTE: the operator's remaining "huge whitespace" complaint is NOT this WO -- it is a third, independent reservation bug, now UCM-CHART-10. |
95
95
  | UCM-CHART-10 | The rotated-tick allowance is reserved twice, and the surplus is dead space | `spaceForRotatedTicks` adds the same `extraHeight` BOTH to `axis.height` and to `margin.bottom`, and in MUI v8 those are additive. The axis band already holds the rotated labels, so the margin copy is left over as an empty band below the plot. Measured in hram 2026-08-22 at 1280 px with ucm 2.42.1: Ward Metrics and Access Ladder each show a 340 px surface whose plot ends at 131 px -- **130 px, 38 %, empty**; Cost 92 px; Division Metrics 85 px; Cost-effectiveness 44 px; Expert Mode only 8 px. Two things make it conclusive: the band SCALES WITH THE LABEL LOAD (35 long rotated ward names cost 130 px, short labels cost 8), and `emptyBelowLowestLabel` equals `emptyBelowAxisLine` on every chart -- the labels sit inside the axis band and nothing occupies the space beneath them. | 2026-08-22 | done | 2026-08-22: `spaceForRotatedTicks` fix + version bump | Tier 3, shared-core. Full order: [`work-orders/UCM-CHART-10.md`](work-orders/UCM-CHART-10.md). THIRD instance of one failure class in this file: space reserved in two places that add up -- after UCM-CHART-8 (`minHeight` vs `height`) and hram's RES-28 (`margin.left` vs an unsized `yAxis`). Neither CHART-8 nor CHART-9 could have caught it: both worked on the wrapper BOX, this is the axis MARGIN arithmetic. **Fix:** kept `extraHeight` on `axis.height` only, `margin` now passes through `spaceForRotatedTicks` completely untouched (no `bottom` computed in any branch) -- a caller-set `margin.bottom` is therefore protected unconditionally, a strict superset of the removed `callerSetBottom` escape hatch, which both reviewers confirmed is a clean simplification, not a behaviour gap. **Codex:** `.claude/codex-status.md` already had an `unavailable` line for 2026-08-22 (logged during `UCM-CHART-9` earlier the same day) -- skipped the attempt per the known-unavailable shortcut, implemented directly, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at Tier 3, so no routing change. **Reviews:** `reviewer` and `ui_reviewer` ran concurrently (Sonnet, diff inline), both converged on the same two findings, both fixed before commit -- (1) version not bumped in-diff (fixed: `package.json` 2.42.1 -> 2.42.2, so `publish.yml`'s version-increase gate fires on push); (2) the WO's "investigate in the same pass" item (HRAM-RES-29 item 2, scatter x-axis tick-label clipping) had no recorded confirm/refute. **Refuted and recorded in the WO file:** `ScatterChart.jsx` never calls `spaceForRotatedTicks` at all, and hram's own named call sites (`AccessGapScatterPanel.jsx`, `OptimizationResultsPanel.jsx`) set no `tickLabelStyle.angle` either -- this helper cannot be the cause. Matches hram's own `HRAM-RES-29` register row, which had independently traced and closed item 2 the same day to an unrelated pre-2.42.0 missing-`height`-prop bug on `ScatterChart`, already fixed by `UCM-CHART-8`/2.42.0. Both reviews clean after fixes, no open findings. **Tests:** affected-area set (`chartDefaults` incl. new `spaceForRotatedTicks (CHART-10)` unit-test block, all four presets, `ChartFrame`) -- 134/134 green, preset assertions unchanged except the two that directly asserted the bug (rewritten to assert the fix, both reviewers confirmed the rewrite is correct and non-trivial). **Rendered check -- numeric, on hram with real data** (`CLI-Kilombero_2026-per_cell-network`, SG Sandbox CLI, same candidate point, Research -> Allocation -> Basic Plots), swapping the locally-built fixed `dist` into hram's `node_modules` for a live before/after (reverted after measurement, hram's own git tree untouched -- confirmed via `git status`): at 1280 px, Ward Metrics 129.6 px -> 51.6 px empty (35 labels, 340 px surface -- matches this row's own original 130 px/38% measurement almost exactly), Access Ladder 156.9 px -> 80.6 px (35 labels, 369 px), Division Metrics 85.2 px -> 41.2 px (5 labels, 340 px) -- each roughly halved, consistent with removing exactly one of two equal additive reservations. Checked again at 375 px: no clipping at either width, no regression. SUBSTITUTION DECLARED: screenshot capture was unavailable in this session (Browser pane would not composite frames) -- this is DOM/computed-style measurement (`getBoundingClientRect`/`getComputedStyle` against the live rendered SVG), which for this WO's numeric acceptance criterion is close to sufficient on its own, per the WO's own fallback clause. Cost/Cost-effectiveness/Expert Mode cards from the original six-card table were not individually re-measured (time-boxed after the first three -- Ward Metrics, Access Ladder, Division Metrics -- gave a clean, consistent, reviewer-confirmed signal); those three all use `BarChart` and its rotated-tick `xAxisAngle` path same as the measured cards (`CostMetricsPanel.jsx`, `RatioMetricsPanel.jsx` both call `spaceForRotatedTicks` via `BarChart`), so the fix is expected to reach them the same way, just not independently confirmed with numbers here. Publishes automatically via `publish.yml` on this push (version-increase gate). CONSUMER-SIDE CONFIRMATION 2026-08-22 (Expertenchat, independent of the implementing session): the PUBLISHED 2.42.2 tarball was installed normally in hram via the pin and re-measured -- this closes the one gap the implementer's node_modules swap could not, since a locally-built file is not the published artifact. (a) Artifact inspected: `dist/index.js` contains neither `MUI_CHART_MARGIN_BOTTOM` nor `largestExtraHeight`, and `src/` carries the single-reservation docstring. (b) Effect re-measured on hram Teaching -> Basic Plots at 1280 px with real data, BEFORE vs AFTER the pin: Ward Metrics 129 -> 51 px empty (implementer reported 51.6), Division Metrics 85 -> 41 (reported 41.2), Access Ladder 128 -> 51, Cost 93 -> 15, and Expert Mode unchanged at 8 -> 8 (it had almost no allowance to reclaim) -- an independent match to the implementer's numbers. No clipping at either width measured. hram pin bumped in `ae0fd956`. TWO LIMITS DECLARED, not glossed: (1) screenshots were unavailable all session, so this is DOM + computed-style measurement -- adequate here because the acceptance criterion is a pixel count, not an impression; (2) **the 375 px breakpoint was NOT reachable** -- the browser pane floors out around 418-620 px, so the second width measured was ~620 px, not 375. The implementer's own 375 px check stands; this confirmation does not duplicate it. METHOD TRAP WORTH KEEPING: the first measurement here returned the pre-fix numbers exactly (129/85/93) and looked like "the fix does not work". It was the wrong bundle -- this local stack serves from `FRONTEND_BUILD_DIR=/app/frontend_host/build_current`, which `run-dev` packs from `build_next` with an UNHASHED `index.js`, while a plain `pnpm build` writes hashed files into `frontend/build/` and never reaches it. Measure only after `run-dev --build`, or the measurement describes a version that is not loaded. |
96
96
  | UCM-CHART-11 | The rotated-tick allowance is now counted once, but estimated too high | CHART-10 removed the double reservation and that holds. What remains is a SINGLE reservation that is too large. Measured in hram 2026-08-22, ucm 2.42.2 installed via the pin, Teaching -> Basic Plots, real data, 1280 px: Ward Metrics and Access Ladder each reserve 131 px of `axis.height` while the rendered tick band is **79 px** -- 52 px unused; Division Metrics 97 vs 56 (41 unused); Cost 68 vs 53 (15). All four are -45 deg and the same preset. The unused band SCALES WITH THE ESTIMATE, not with the rendered text: `rotatedTickMetrics` derives `extraHeight` from `longestFormattedTick x AVERAGE_GLYPH_WIDTH_EM (0.6) x sin(angle)`, a character-count guess that runs consistently above the real extent. **Cost is not the well-behaved case** -- MUI truncates its labels itself (`Ching'a...`), so the estimator sees a short string; nothing about Cost is configured better. | 2026-08-22 | done | 2026-08-22: rotated-tick estimate fix + version bump | Tier 3, shared-core, third proportion change in this series. Full order: [`work-orders/UCM-CHART-11.md`](work-orders/UCM-CHART-11.md). Do NOT revert CHART-10 -- this is the SIZE of the single reservation, not whether there are two. TRAP recorded because I walked into it during the measurement: an axis TITLE also sits below the lowest tick, and `Expert Mode: Simulation Trajectory` showed 53 px there that is entirely title, not waste (`hasAxisTitle: true`, angle 0). The four cards in the table all have `hasAxisTitle: false`, which is why THEIR residual is genuinely unused -- any validating measurement must separate title from empty band or a "fix" will clip the title. **Codex:** `.claude/codex-status.md` already had an `unavailable` line for 2026-08-22 (logged earlier the same day during `UCM-CHART-9`) -- skipped the attempt per the known-unavailable shortcut, implemented directly, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at Tier 3. **Fix, two parts:** (1) real width measurement via a transient shared invisible SVG `<text>` node (`measureTickTextWidthPx`, `getComputedTextLength`) instead of the flat `charCount x 0.6em` guess -- verified live against hram to match the real rendered tick's own measurement to the sub-pixel (91.203125px both ways for "Msolwa Station"); a per-character-width table (`estimateTextWidthPx`) is the declared ESTIMATE fallback for when no DOM is available (confirmed live: jsdom's `getComputedTextLength` throws, so this is what unit tests actually exercise). (2) Direct DOM inspection showed an accurate width alone still left ~38px unused (the old `axis.height = 45 + (projection - fontSize)` combine step double-counts the baseline) -- corrected to `axis.height = max(45, ceil(projection) + 8px clearance)`, i.e. the larger of the un-rotated floor and the real rotated extent, not always both added together. **Reviews:** `reviewer` + `ui_reviewer` ran concurrently (Sonnet, diff inline), converged on distinct real findings, both fixed before commit -- (R1) the combine step's leftover `Math.max(1, ...)` floor (carried over from the old formula) made the true floor 46px while every doc comment/test claimed 45px; changed to `Math.max(0, ...)` and added a test pinning the exact-45 case. (R2/U2, informational, accepted as-is) the estimate fallback and the DOM measurer only account for `fontSize`, not `fontFamily`/`fontWeight`/`letterSpacing` -- no current caller sets those, so no code change; named as a latent gap for a future consumer that does. (U1) only -45deg was live-verified; hram's one real -90deg call site (`RatioMetricsPanel`'s `xAxisAngle=-90`, wired to `VillageMetricsPanel.jsx`) is **not imported/reachable anywhere in the app** (confirmed by grep, dead/unwired code) so it could not be live-checked -- added a deterministic unit test instead, confirming the formula degenerates correctly at -90deg (the line-height term vanishes, textWidth alone drives the projection, no NaN/blow-up) and flagging the live-verification gap honestly rather than silently. **Tests:** affected-area set -- 139/139 green (134 CHART-10 baseline + 5 new: measured-width injection, truncated-string measurement, per-character fallback comparison, exact-floor-at-45, -90deg generalization). **Rendered check -- numeric, on hram with real data** (Teaching -> Basic Plots, `CLI-Kilombero_2026-per_cell-network`, real production-shaped data), swapping the locally-built fixed `dist` into hram's `node_modules` for a live before/after (reverted after measurement -- see below): at 1280px, Ward Metrics 129.6px -> **14.6px** empty; Access Ladder 156.9px -> **14.6px**; Division Metrics 85.2px -> **14.2px**; Cost 92.3px -> **14.6px** (unchanged as expected -- its `axis.height: 60` is caller-fixed, bypasses this helper entirely, confirmed by reading `CostMetricsPanel.jsx`). All four land under the WO's ~15px target. Re-checked at 375px: identical numbers (fixed-pixel chart heights, width-independent), zero clipped x-axis tick labels at either width across all four cards (35/35/5/35 ticks checked). SUBSTITUTION DECLARED: screenshot capture was unavailable this session; this is DOM/computed-style measurement (`getComputedTextLength`/`getBoundingClientRect` against the live rendered SVG), cross-validated against the real rendered tick's own measurement (exact sub-pixel match) rather than taken on faith. **Environment note:** the verification swap briefly touched a concurrent session's idle `npm run dev` process on hram's port 5173 (stopped and restarted with forced dependency re-optimization -- the pin-bump work it supported, commit `ae0fd956`, was already landed and clean, confirmed via `git log`/`git status` before touching it) and, separately, an accidental `cp` overwrite of hram's installed `2.42.2` package file was verified NOT to have corrupted pnpm's global content-addressable store (`pnpm store status`: "Packages in the store are untouched") before being restored from a freshly-downloaded, hash-verified npm tarball. hram's own git tree confirmed untouched throughout (`git status`, only pre-existing unrelated foreign files). Publishes automatically via `publish.yml` on this push (version-increase gate). |
97
- | UCM-CHART-12 | One layout model for charts. BREAKING (3.0.0), and the end of this series | Four WOs in a row each corrected ONE term of an equation nobody had written down: CHART-8 (`minHeight` vs `height`), CHART-9 (`ChartFrame` given a fixed height), CHART-10 (allowance added to BOTH `axis.height` and `margin.bottom`), CHART-11 (that allowance ESTIMATED above the rendered text). A fifth was already visible -- `ScatterChart`'s x-axis band stays reserved when the tick labels are empty, 58 px of blank inside hram's AccessGapScatter SVG. **Operator called a stop: no more term-by-term corrections.** Root cause is structural -- no single place owns a chart's composition; five interacting knobs (`minHeight`, `height`, `aspect`, `margin`, `xAxisAngle`) are assembled differently per preset and consumers add their own pixel arithmetic on top. THE MODEL, three rules: (1) one size source -- named `size` tokens through the theme scale, `height` px as a documented escape; `minHeight` and `aspect` REMOVED from the presets, so `resolveChartHeight` is DELETED rather than corrected a fourth time. (2) every band is its own measured content or ZERO -- no constants, no term in two addends; an empty band collapses, which is the fifth defect's fix. (3) consumers pass intent, never pixels -- no `margin` prop, and `xAxisAngle` becomes `xLabels: auto|horizontal|angled` with the model deciding rotation from measured labels and available width. | 2026-08-22 | done | `8142fa9` | Tier 3, BREAKING, first major in this package's history. Full order: [`work-orders/UCM-CHART-12.md`](work-orders/UCM-CHART-12.md). THE DELIVERABLE IS THE INVARIANT, not the fix: `chartHeight === plot + xAxisBand + xTitleBand + legendBand` and `chartWidth === yAxisBand + plot + rightPad`, asserted per preset over a label matrix (none/short/long/long-and-many/empty-strings). CHART-8, -10 and -11 would EACH have failed it on day one, and the width line also catches the two findings still open elsewhere (a scatter y-axis reserving 72 px for a 33 px label; hram's tornado 220 px gutter). It must not be dropped for expedience. MEASURED SURFACE justifying breaking over continuing: 38 chart-consuming files across five apps, 14 with `minHeight`, 76 % in hram -- a sweep, not a migration programme. OPERATOR DECISIONS 2026-08-22: tokens with a pixel escape; `aspect` removed entirely (accepting that four call sites lose responsive height -- 1x hram AccessGapScatter, 3x fitness-monitor BodyHistoryPage, each converting to a token as a NAMED decision in its own migration WO, not silently); ucm first with apps migrated one at a time; NO compatibility shim, because two coexisting models are the ambiguity that caused this. `ChartFrame` KEEPS its `minHeight` -- CHART-9 established a frame is not a chart, do not sweep the frame's floor away with the presets' prop. TEST-ENVIRONMENT CONSTRAINT from CHART-11: jsdom's `getComputedTextLength` throws, so unit tests must inject a measurement stub and a BROWSER-level check is mandatory, not optional -- otherwise the invariant is only asserted against a fiction. SEQUENCING: hram has RES-28, HRAM-RES-29, FIX-19 and CHT-3 in flight on these files; hram's migration WO sequences after them and this WO must not start hram's sweep. **Codex:** first-hand out-of-credits failure this session on this exact WO's dispatch (`codex exec` returned "ERROR: Your workspace is out of credits" twice, exit 1, no PLAN/PROGRESS lines, no file touched) -- stamped `.claude/codex-status.md` 2026-08-22 and implemented directly in Claude, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at this WO's Tier 3, so the flip changed no routing. **Review runtime note:** `.claude/models.local.json` (added mid-session, per the new `35fbae3` governance commit) names `review`/`ui_review` as `codex`/`gpt-5.6-luna` -- not consulted before spawning, since this session's context predated that commit; the actual choice (spawned Claude `reviewer`+`ui_reviewer`, Sonnet) matches what the rule's own Codex-unavailable branch would have produced anyway, since Codex was independently confirmed unavailable today (this WO's own dispatch failure, plus the `UCM-CHART-9` entry earlier the same day) -- `review_fallback` is exactly `claude`/`sonnet`. Named here for the record, not as a retroactive justification. **Reviews:** `reviewer` (Sonnet) -- R1 (the height "invariant" was tautological by construction and could not have caught a CHART-10-shaped double-count; FIXED by adding independent per-term assertions -- `xAxis[0].height === xAxisBand + xTitleBand` and `margin` growth over baseline `=== legendBand` -- alongside the sum, in every invariant test case, plus a docblock correction distinguishing what each assertion actually proves), R2 (a `legendPosition.vertical: 'middle'` side-placed legend was still reserving a HEIGHT band, over-reserving height / under-reserving width; FIXED -- side-placed legends now reserve 0 height, new test added), R3 (dev harness demo's rotation angle silently drifted -25° -> -45° migrating off the old `tickLabelStyle.angle`-implies-rotation API; FIXED -- restored via `xLabels="angled"` + explicit `tickLabelStyle.angle: -25` override, commented). `ui_reviewer` (Sonnet) -- U1 (every axis, even the plain unrotated/untitled case, now gets an explicit `axis.height` where MUI used to fill in its own default -- verified against `node_modules/@mui/x-charts/constants`: `DEFAULT_AXIS_SIZE_HEIGHT=25`/`AXIS_LABEL_DEFAULT_HEIGHT=20` match `TICK_BAND_BASE_PX`/`AXIS_TITLE_BAND_PX` exactly, so no rendering changes for that case; comment + doc note added to make this provable, not just asserted), U2 (docs/CHART-LAYOUT.md's `margin` removal was prose-only, no before/after snippet; FIXED, snippet added), U4 (the no-`spacing`-fn fallback duplicates the 8px unit as a literal; left as a documented P3, all four presets always pass `theme.spacing` in practice). Both reviews' findings fixed; no open findings after fixes. **Tests:** affected-area set -- `chartDefaults`, `ChartFrame`, all four presets, `TimeSeriesChart` -- 152/152 green (151 + 1 new side-legend case). Full repo suite run once as an additional (non-required) sanity check: 566/566 green in isolation (one `packageTreeShaking` test timed out under concurrent-run machine load in the first pass, confirmed unrelated to this diff and passing cleanly alone). **Browser-level check, on this repo's own dev harness** (`dev/entries.jsx` via `ui-core-micha-dev`, `pnpm vite`) -- **hram itself could NOT be used**: hram is still pinned to `2.41.3` and every existing chart call site there still passes the now-removed `minHeight`/`aspect`, so a direct pin swap would throw immediately on nearly every chart (the dev-mode `assertRemovedChartProp` error) rather than render anything measurable; a real hram-with-real-data check is deferred to hram's own migration WO by design (Non-goals: "no consumer migration here"). Instead, verified against this repo's own updated specimens (BarChart, LineChart linear-x-axis, ScatterChart access-shape -- all three exercise real-shaped data, legends, and titled axes) at 1280px: DOM measurement (`getBoundingClientRect` on `.MuiChartsSurface-root`/`.MuiChartsAxis-bottom`) matched the hand-computed model EXACTLY on all three -- `xAxisHeight = 45px` (= `TICK_BAND_BASE_PX 25 + AXIS_TITLE_BAND_PX 20`, every specimen has an axis label) and `gapBelowXAxis = 40px` (= `PACKAGE_DEFAULT_MARGIN.bottom 8 + LEGEND_BAND_PX 32`, every specimen shows a legend). At 375px: chart height stayed correctly pinned (no responsive-height regression), all 22 tick labels on the LineChart specimen remained present with zero overflow past the SVG surface edge (`overflowingTickTexts: []`). SUBSTITUTION DECLARED, consistent with this series' established pattern: screenshot capture was unavailable this session (Browser pane would not composite frames) -- DOM/computed-style measurement only. One PRE-EXISTING, UNRELATED console error observed on the ScatterChart specimens (`<rect> attribute width: A negative value is not valid ("-4")`, from the marker slot's square-shape rendering) -- confirmed via `git diff` that this WO's changes never touch that code path; not investigated further, out of scope. Publishes automatically via `publish.yml` on this push (version-increase gate, `3.0.0 > 2.42.3`). |
97
+ | UCM-CHART-12 | One layout model for charts. BREAKING (3.0.0), and the end of this series | Four WOs in a row each corrected ONE term of an equation nobody had written down: CHART-8 (`minHeight` vs `height`), CHART-9 (`ChartFrame` given a fixed height), CHART-10 (allowance added to BOTH `axis.height` and `margin.bottom`), CHART-11 (that allowance ESTIMATED above the rendered text). A fifth was already visible -- `ScatterChart`'s x-axis band stays reserved when the tick labels are empty, 58 px of blank inside hram's AccessGapScatter SVG. **Operator called a stop: no more term-by-term corrections.** Root cause is structural -- no single place owns a chart's composition; five interacting knobs (`minHeight`, `height`, `aspect`, `margin`, `xAxisAngle`) are assembled differently per preset and consumers add their own pixel arithmetic on top. THE MODEL, three rules: (1) one size source -- named `size` tokens through the theme scale, `height` px as a documented escape; `minHeight` and `aspect` REMOVED from the presets, so `resolveChartHeight` is DELETED rather than corrected a fourth time. (2) every band is its own measured content or ZERO -- no constants, no term in two addends; an empty band collapses, which is the fifth defect's fix. (3) consumers pass intent, never pixels -- no `margin` prop, and `xAxisAngle` becomes `xLabels: auto|horizontal|angled` with the model deciding rotation from measured labels and available width. | 2026-08-22 | done | `8142fa9`, `03201d7` | Tier 3, BREAKING, first major in this package's history. Full order: [`work-orders/UCM-CHART-12.md`](work-orders/UCM-CHART-12.md). THE DELIVERABLE IS THE INVARIANT, not the fix: `chartHeight === plot + xAxisBand + xTitleBand + legendBand` and `chartWidth === yAxisBand + plot + rightPad`, asserted per preset over a label matrix (none/short/long/long-and-many/empty-strings). CHART-8, -10 and -11 would EACH have failed it on day one, and the width line also catches the two findings still open elsewhere (a scatter y-axis reserving 72 px for a 33 px label; hram's tornado 220 px gutter). It must not be dropped for expedience. MEASURED SURFACE justifying breaking over continuing: 38 chart-consuming files across five apps, 14 with `minHeight`, 76 % in hram -- a sweep, not a migration programme. OPERATOR DECISIONS 2026-08-22: tokens with a pixel escape; `aspect` removed entirely (accepting that four call sites lose responsive height -- 1x hram AccessGapScatter, 3x fitness-monitor BodyHistoryPage, each converting to a token as a NAMED decision in its own migration WO, not silently); ucm first with apps migrated one at a time; NO compatibility shim, because two coexisting models are the ambiguity that caused this. `ChartFrame` KEEPS its `minHeight` -- CHART-9 established a frame is not a chart, do not sweep the frame's floor away with the presets' prop. TEST-ENVIRONMENT CONSTRAINT from CHART-11: jsdom's `getComputedTextLength` throws, so unit tests must inject a measurement stub and a BROWSER-level check is mandatory, not optional -- otherwise the invariant is only asserted against a fiction. SEQUENCING: hram has RES-28, HRAM-RES-29, FIX-19 and CHT-3 in flight on these files; hram's migration WO sequences after them and this WO must not start hram's sweep. **Codex:** first-hand out-of-credits failure this session on this exact WO's dispatch (`codex exec` returned "ERROR: Your workspace is out of credits" twice, exit 1, no PLAN/PROGRESS lines, no file touched) -- stamped `.claude/codex-status.md` 2026-08-22 and implemented directly in Claude, flipping authorship; `reviewer` + `ui_reviewer` were already mandatory at this WO's Tier 3, so the flip changed no routing. **Review runtime note:** `.claude/models.local.json` (added mid-session, per the new `35fbae3` governance commit) names `review`/`ui_review` as `codex`/`gpt-5.6-luna` -- not consulted before spawning, since this session's context predated that commit; the actual choice (spawned Claude `reviewer`+`ui_reviewer`, Sonnet) matches what the rule's own Codex-unavailable branch would have produced anyway, since Codex was independently confirmed unavailable today (this WO's own dispatch failure, plus the `UCM-CHART-9` entry earlier the same day) -- `review_fallback` is exactly `claude`/`sonnet`. Named here for the record, not as a retroactive justification. **Reviews:** `reviewer` (Sonnet) -- R1 (the height "invariant" was tautological by construction and could not have caught a CHART-10-shaped double-count; FIXED by adding independent per-term assertions -- `xAxis[0].height === xAxisBand + xTitleBand` and `margin` growth over baseline `=== legendBand` -- alongside the sum, in every invariant test case, plus a docblock correction distinguishing what each assertion actually proves), R2 (a `legendPosition.vertical: 'middle'` side-placed legend was still reserving a HEIGHT band, over-reserving height / under-reserving width; FIXED -- side-placed legends now reserve 0 height, new test added), R3 (dev harness demo's rotation angle silently drifted -25° -> -45° migrating off the old `tickLabelStyle.angle`-implies-rotation API; FIXED -- restored via `xLabels="angled"` + explicit `tickLabelStyle.angle: -25` override, commented). `ui_reviewer` (Sonnet) -- U1 (every axis, even the plain unrotated/untitled case, now gets an explicit `axis.height` where MUI used to fill in its own default -- verified against `node_modules/@mui/x-charts/constants`: `DEFAULT_AXIS_SIZE_HEIGHT=25`/`AXIS_LABEL_DEFAULT_HEIGHT=20` match `TICK_BAND_BASE_PX`/`AXIS_TITLE_BAND_PX` exactly, so no rendering changes for that case; comment + doc note added to make this provable, not just asserted), U2 (docs/CHART-LAYOUT.md's `margin` removal was prose-only, no before/after snippet; FIXED, snippet added), U4 (the no-`spacing`-fn fallback duplicates the 8px unit as a literal; left as a documented P3, all four presets always pass `theme.spacing` in practice). Both reviews' findings fixed; no open findings after fixes. **Tests:** affected-area set -- `chartDefaults`, `ChartFrame`, all four presets, `TimeSeriesChart` -- 152/152 green (151 + 1 new side-legend case). Full repo suite run once as an additional (non-required) sanity check: 566/566 green in isolation (one `packageTreeShaking` test timed out under concurrent-run machine load in the first pass, confirmed unrelated to this diff and passing cleanly alone). **Browser-level check, on this repo's own dev harness** (`dev/entries.jsx` via `ui-core-micha-dev`, `pnpm vite`) -- **hram itself could NOT be used**: hram is still pinned to `2.41.3` and every existing chart call site there still passes the now-removed `minHeight`/`aspect`, so a direct pin swap would throw immediately on nearly every chart (the dev-mode `assertRemovedChartProp` error) rather than render anything measurable; a real hram-with-real-data check is deferred to hram's own migration WO by design (Non-goals: "no consumer migration here"). Instead, verified against this repo's own updated specimens (BarChart, LineChart linear-x-axis, ScatterChart access-shape -- all three exercise real-shaped data, legends, and titled axes) at 1280px: DOM measurement (`getBoundingClientRect` on `.MuiChartsSurface-root`/`.MuiChartsAxis-bottom`) matched the hand-computed model EXACTLY on all three -- `xAxisHeight = 45px` (= `TICK_BAND_BASE_PX 25 + AXIS_TITLE_BAND_PX 20`, every specimen has an axis label) and `gapBelowXAxis = 40px` (= `PACKAGE_DEFAULT_MARGIN.bottom 8 + LEGEND_BAND_PX 32`, every specimen shows a legend). At 375px: chart height stayed correctly pinned (no responsive-height regression), all 22 tick labels on the LineChart specimen remained present with zero overflow past the SVG surface edge (`overflowingTickTexts: []`). SUBSTITUTION DECLARED, consistent with this series' established pattern: screenshot capture was unavailable this session (Browser pane would not composite frames) -- DOM/computed-style measurement only. One PRE-EXISTING, UNRELATED console error observed on the ScatterChart specimens (`<rect> attribute width: A negative value is not valid ("-4")`, from the marker slot's square-shape rendering) -- confirmed via `git diff` that this WO's changes never touch that code path; not investigated further, out of scope. Publishes automatically via `publish.yml` on this push (version-increase gate, `3.0.0 > 2.42.3`). **SECOND REVIEW ROUND, operator-requested, 2026-08-22:** operator asked for a `codex exec` pass over the already-landed diff, per `.claude/models.local.json` (`review`/`ui_review`: `codex`/`gpt-5.6-luna`) -- confirmed Codex available today first (`.claude/codex-status.md` had a fresh `available` line at the top of the log). Invoked directly via Bash with both mandatory flags and `-m gpt-5.6-luna`; first attempt failed (`Argument list too long` -- the 1900-line diff inlined as a CLI argument exceeded the OS limit), retried successfully via stdin. Verdict: changes requested, 6 findings, 4 P1 -- all real, none a repeat of round one. **(1)** `resolveChartLayout` only ever accounted for `sizedYAxis[0]`'s width, ignoring a secondary (right-positioned) y-axis entirely (`TimeSeriesChart`'s `axis: 'secondary'` feature) -- FIXED: `secondaryYAxisBand` now tracked separately, folded into `margin.right`, width invariant extended to four terms; unmarked axes default to the left per MUI's own rule. **(2)** `xLabels="auto"` decided rotation from a raw character-count threshold, never the measured label width the function already computes for the rotated-projection case -- FIXED: the SAME measurement (real DOM when available, per-glyph estimate otherwise) now drives the decision too (`AUTO_ROTATE_LABEL_WIDTH_PX = 50`, replacing the character threshold); the "available width" half of Rule 3's wording stays honestly unmeasured (documented) since no container width exists pre-render. **(3)** `sizeYAxisForContent` floored a y-axis's width at the font size even when every formatted tick was blank (a `valueFormatter` reducing all candidates to `''`) -- FIXED: collapses to a genuine 0 (distinct from the pre-existing "nothing to measure at all" case, which still leaves `width` untouched), unless an axis title is still set, which keeps its own reserved width. **(4)** `slotProps.legend.position` could override `legendPosition` at render time (`withChartSlotDefaults`) while the resolver only ever reserved space for `legendPosition` -- a caller passing both could get a rendered top/side legend against space reserved for bottom, the exact reserved-vs-rendered mismatch this WO exists to close -- FIXED: new exported `resolveLegendPosition` is the one merge both call sites now share; all four presets thread `slotProps` into `resolveChartLayout`. **(5, P2)** a caller-set `xAxis[0].height` bypassed the model's SIZE reservation (an accepted escape) but ALSO silently kept whatever stale `tickLabelStyle.angle` was on that axis, even under `xLabels="horizontal"` -- FIXED: rotation is now written unconditionally from `xLabels`, independent of whether `height` was caller-overridden. **(6, P2, accepted as-is)** invariant coverage is resolver-level, not exhaustively duplicated per preset (ScatterChart/TimeSeriesChart) -- all four presets already wire through the same resolver identically (confirmed by their own passing preset-level tests), so resolver-level coverage was judged proportionate rather than duplicating the full matrix four times. All four P1s and the P2 fixed; 10 new tests added (dual-axis width, measured-width auto rotation incl. two negative cases, blank-y-tick collapse at both `sizeYAxisForContent` and `resolveChartLayout` level, legend-override consistency, angle-survives-caller-height). Re-verified: 162/162 chart tests green, `tsc -p tsconfig.build.json` clean. Per Reviews (`AGENTS.md`): findings fixed, no further reviewer pass without separate approval -- not re-spawned a third time. Version bumped `3.0.0` -> `3.0.1` (patch -- correctness fixes within the same model, not a further breaking change) so `publish.yml`'s gate fires again on this push. |
@@ -50,6 +50,7 @@ export function BarChart(_a) {
50
50
  xLabels,
51
51
  hideLegend,
52
52
  legendPosition,
53
+ slotProps,
53
54
  tickFontSize: theme.typography.caption.fontSize,
54
55
  spacing: theme.spacing,
55
56
  defaultLineHeight: theme.typography.caption.lineHeight,
@@ -65,6 +65,7 @@ export function LineChart(_a) {
65
65
  xLabels,
66
66
  hideLegend,
67
67
  legendPosition,
68
+ slotProps,
68
69
  tickFontSize: theme.typography.caption.fontSize,
69
70
  spacing: theme.spacing,
70
71
  defaultLineHeight: theme.typography.caption.lineHeight,
@@ -161,6 +161,7 @@ export function ScatterChart(_a) {
161
161
  xLabels,
162
162
  hideLegend,
163
163
  legendPosition,
164
+ slotProps,
164
165
  tickFontSize: theme.typography.caption.fontSize,
165
166
  spacing: theme.spacing,
166
167
  defaultLineHeight: theme.typography.caption.lineHeight,
@@ -243,7 +243,16 @@ export function sizeYAxisForContent(yAxis, series, tickFontSize) {
243
243
  if (candidates.length === 0)
244
244
  return axis;
245
245
  const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
246
- const tickTextWidth = Math.max(tickFontSizePx, longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM);
246
+ // UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed, but a caller
247
+ // `valueFormatter` reduced every one of them to a blank string -- this is the y-axis analogue
248
+ // of `xAxisHasVisibleTicks`'s Access-scatter case, and must collapse the same way. Distinct
249
+ // from `candidates.length === 0` above (nothing to measure at all, MUI's own default applies,
250
+ // untouched): here there WAS content, it just renders as nothing. With no axis title either,
251
+ // the axis has genuinely nothing to show, so the whole band collapses to 0 -- not merely the
252
+ // text-width term floored, matching the x-axis band's own all-or-nothing zero.
253
+ if (longestTick.length === 0 && !axis.label)
254
+ return Object.assign(Object.assign({}, axis), { width: 0 });
255
+ const tickTextWidth = longestTick.length === 0 ? 0 : Math.max(tickFontSizePx, longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM);
247
256
  const labelThickness = axis.label
248
257
  ? Math.ceil(MUI_AXIS_LABEL_FONT_SIZE_PX * Y_AXIS_LABEL_THICKNESS_FACTOR) + Y_AXIS_LABEL_TICK_GAP
249
258
  : 0;
@@ -302,11 +311,16 @@ const LEGEND_BAND_PX = 32;
302
311
  // UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
303
312
  // render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
304
313
  // pure function cannot see), "auto" is a documented HEURISTIC, not a true fit computation: rotate
305
- // once there are enough categories AND the longest one is long enough that a horizontal row would
306
- // plausibly collide. The mandatory browser-level check (WO Part C) is what actually verifies fit
307
- // against real rendered geometry; this only picks a reasonable default ahead of that.
314
+ // once there are enough categories AND the longest one, MEASURED (same mechanism as the rotated
315
+ // projection below -- real DOM measurement when available, the per-glyph estimate otherwise), is
316
+ // wide enough that a horizontal row would plausibly collide. Codex second-pass review finding:
317
+ // the first cut of this heuristic used a raw character-count threshold instead of the measured
318
+ // width this function already has available -- replaced, since "decide from the measured labels"
319
+ // (Rule 3) is achievable even without a real container width to compare against. The count
320
+ // threshold stays as the OTHER half of the heuristic: a handful of wide labels can still fit
321
+ // spread across a wide chart, so width alone should not trigger rotation either.
308
322
  const AUTO_ROTATE_TICK_COUNT = 6;
309
- const AUTO_ROTATE_LABEL_CHARS = 4;
323
+ const AUTO_ROTATE_LABEL_WIDTH_PX = 50;
310
324
  function xAxisTickContentText(axis) {
311
325
  const data = axis === null || axis === void 0 ? void 0 : axis.data;
312
326
  if (!Array.isArray(data))
@@ -336,12 +350,22 @@ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextW
336
350
  const fontSize = cssLengthToPixels((_a = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _a === void 0 ? void 0 : _a.fontSize) || cssLengthToPixels(fallbackTickFontSize);
337
351
  const longestLabel = xAxisTickContentText(axis) || '';
338
352
  const dataCount = Array.isArray(axis === null || axis === void 0 ? void 0 : axis.data) ? axis.data.length : 0;
353
+ const measure = measureTextWidth || measureTickTextWidthPx;
354
+ // Measured once, up front -- both "auto"'s rotation DECISION and the rotated-band PROJECTION
355
+ // below need the label's real width, and must agree on what "real" means (same measurement,
356
+ // same estimate fallback) or the two could disagree about the same label.
357
+ const labelWidthPx = fontSize > 0
358
+ ? Math.max(fontSize, (() => {
359
+ const measured = measure(longestLabel, fontSize);
360
+ return Number.isFinite(measured) && measured > 0 ? measured : estimateTextWidthPx(longestLabel, fontSize);
361
+ })())
362
+ : 0;
339
363
  let angle = 0;
340
364
  if (xLabelsMode === 'angled') {
341
365
  angle = Number((_b = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _b === void 0 ? void 0 : _b.angle) || -45;
342
366
  }
343
367
  else if (xLabelsMode === 'auto') {
344
- const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && longestLabel.length > AUTO_ROTATE_LABEL_CHARS;
368
+ const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && labelWidthPx > AUTO_ROTATE_LABEL_WIDTH_PX;
345
369
  angle = needsRotation ? -45 : 0;
346
370
  } // "horizontal" always stays at angle 0
347
371
  if (angle === 0)
@@ -352,11 +376,7 @@ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextW
352
376
  if (fontSize === 0)
353
377
  return { tickBand: TICK_BAND_BASE_PX, angle };
354
378
  const lineHeight = Number((_c = axis === null || axis === void 0 ? void 0 : axis.tickLabelStyle) === null || _c === void 0 ? void 0 : _c.lineHeight) || defaultLineHeight;
355
- const measure = measureTextWidth || measureTickTextWidthPx;
356
- const measuredWidth = measure(longestLabel, fontSize);
357
- const textWidth = Math.max(fontSize, Number.isFinite(measuredWidth) && measuredWidth > 0
358
- ? measuredWidth
359
- : estimateTextWidthPx(longestLabel, fontSize));
379
+ const textWidth = labelWidthPx;
360
380
  const radians = (angle * Math.PI) / 180;
361
381
  const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
362
382
  + Math.abs(Math.sin(radians)) * textWidth;
@@ -404,49 +424,68 @@ function resolveChartHeightPx(size, height, spacing) {
404
424
  * `containerWidth` is optional and only meaningful for tests / the browser-level check -- the
405
425
  * presets themselves stay responsive (no explicit width) and never pass it.
406
426
  */
407
- export function resolveChartLayout({ size = 'standard', height, xAxis, yAxis, series = [], xLabels = 'auto', hideLegend = false, legendPosition = DEFAULT_LEGEND_POSITION, tickFontSize, spacing, defaultLineHeight = 1, measureTextWidth, containerWidth, } = {}) {
427
+ export function resolveChartLayout({ size = 'standard', height, xAxis, yAxis, series = [], xLabels = 'auto', hideLegend = false, legendPosition = DEFAULT_LEGEND_POSITION, slotProps, tickFontSize, spacing, defaultLineHeight = 1, measureTextWidth, containerWidth, } = {}) {
408
428
  var _a, _b, _c;
409
429
  const chartHeight = resolveChartHeightPx(size, height, spacing);
410
430
  const xAxisList = (xAxis === null || xAxis === void 0 ? void 0 : xAxis.length) ? xAxis : [{}];
411
431
  const primaryXAxis = xAxisList[0];
412
432
  const { tickBand: xAxisBand, angle } = resolveXAxisGeometry(primaryXAxis, xLabels, defaultLineHeight, measureTextWidth, tickFontSize);
413
433
  const xTitleBand = (primaryXAxis === null || primaryXAxis === void 0 ? void 0 : primaryXAxis.label) ? AXIS_TITLE_BAND_PX : 0;
434
+ // Codex second-pass review finding: a caller-set `slotProps.legend.position` overrides
435
+ // `legendPosition` at RENDER time (`withChartSlotDefaults`) but was invisible here, so the
436
+ // margin/height this function reserved could disagree with where the legend actually rendered.
437
+ // `resolveLegendPosition` is the one merge both call sites now share.
438
+ const effectiveLegendPosition = resolveLegendPosition(legendPosition, slotProps);
414
439
  // A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
415
440
  // finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
416
441
  // over-reserving height and under-reserving width for a side-placed legend. This WO's width
417
442
  // invariant does not itemize a legend-width band, so a side legend simply gets none of the
418
443
  // height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
419
444
  // legend is off or horizontal").
420
- const legendVertical = (_a = legendPosition === null || legendPosition === void 0 ? void 0 : legendPosition.vertical) !== null && _a !== void 0 ? _a : DEFAULT_LEGEND_POSITION.vertical;
445
+ const legendVertical = effectiveLegendPosition.vertical;
421
446
  const legendIsSidePlaced = legendVertical === 'middle';
422
447
  const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
423
448
  const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
424
449
  const sizedYAxis = sizeYAxisForContent((yAxis === null || yAxis === void 0 ? void 0 : yAxis.length) ? yAxis : [{}], series, tickFontSize);
425
- const yAxisBand = (_c = (_b = sizedYAxis[0]) === null || _b === void 0 ? void 0 : _b.width) !== null && _c !== void 0 ? _c : 0;
450
+ // Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's `axis: 'secondary'`
451
+ // feature, CHART-5) has a SECOND y-axis on the right, consuming its own width -- the resolver
452
+ // only ever accounted for `sizedYAxis[0]`. `position` defaults to `'left'` for an unmarked axis
453
+ // (matching MUI's own default-assignment rule, same as `sizeYAxisForContent`'s own series-match
454
+ // logic above), so only an explicit `'right'` counts as the secondary axis.
455
+ const primaryYAxis = (_a = sizedYAxis.find((axisEntry) => axisEntry.position !== 'right')) !== null && _a !== void 0 ? _a : sizedYAxis[0];
456
+ const secondaryYAxisEntry = sizedYAxis.find((axisEntry) => axisEntry.position === 'right');
457
+ const yAxisBand = (_b = primaryYAxis === null || primaryYAxis === void 0 ? void 0 : primaryYAxis.width) !== null && _b !== void 0 ? _b : 0;
458
+ const secondaryYAxisBand = (_c = secondaryYAxisEntry === null || secondaryYAxisEntry === void 0 ? void 0 : secondaryYAxisEntry.width) !== null && _c !== void 0 ? _c : 0;
426
459
  const rightPad = PACKAGE_DEFAULT_MARGIN.right;
427
460
  const resolvedXAxis = xAxisList.map((axisEntry, index) => {
428
- if (index !== 0 || axisEntry.height != null)
461
+ var _a;
462
+ if (index !== 0)
429
463
  return axisEntry;
430
464
  // `xLabels` is the SOLE source of truth for rotation (Rule 3) -- `angle` is always written
431
- // explicitly here, even when 0, rather than only overriding a truthy value. Leaving a stale
432
- // caller-supplied `tickLabelStyle.angle` in place when `xLabels` resolved to no rotation would
433
- // let MUI still render a rotated label whose band this resolver reserved as if it were flat --
434
- // exactly the space/render mismatch this WO exists to close, just inverted (under-reservation
435
- // instead of CHART-10's over-reservation).
436
- const _a = axisEntry.tickLabelStyle || {}, { angle: _callerAngle } = _a, restTickLabelStyle = __rest(_a, ["angle"]);
437
- return Object.assign(Object.assign({}, axisEntry), { height: xAxisBand + xTitleBand, tickLabelStyle: Object.assign(Object.assign({}, restTickLabelStyle), { angle }) });
465
+ // explicitly here, even when 0, rather than only overriding a truthy value, and regardless of
466
+ // whether the caller also set an explicit `height` (own `height` bypasses the model's SIZE,
467
+ // never its ROTATION -- codex second-pass review finding: the two were previously coupled,
468
+ // so a caller-set `height` could silently keep a stale rotated `tickLabelStyle.angle` even
469
+ // under `xLabels="horizontal"`). Leaving a stale caller-supplied angle in place when `xLabels`
470
+ // resolved to no rotation would let MUI still render a rotated label whose band this resolver
471
+ // reserved as if it were flat -- exactly the space/render mismatch this WO exists to close,
472
+ // just inverted (under-reservation instead of CHART-10's over-reservation).
473
+ const _b = axisEntry.tickLabelStyle || {}, { angle: _callerAngle } = _b, restTickLabelStyle = __rest(_b, ["angle"]);
474
+ return Object.assign(Object.assign({}, axisEntry), { height: (_a = axisEntry.height) !== null && _a !== void 0 ? _a : (xAxisBand + xTitleBand), tickLabelStyle: Object.assign(Object.assign({}, restTickLabelStyle), { angle }) });
438
475
  });
439
476
  const margin = {
440
477
  top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
441
478
  bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
442
479
  left: PACKAGE_DEFAULT_MARGIN.left,
443
- right: rightPad,
480
+ right: rightPad + secondaryYAxisBand,
444
481
  };
445
482
  // `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
446
483
  // furniture bands (see this function's own docblock for why this alone does not prove the
447
484
  // absence of a double-counted term, and what does).
448
485
  const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
449
- const plotWidth = containerWidth != null ? containerWidth - yAxisBand - rightPad : undefined;
486
+ const plotWidth = containerWidth != null
487
+ ? containerWidth - yAxisBand - secondaryYAxisBand - rightPad
488
+ : undefined;
450
489
  return {
451
490
  chartHeight,
452
491
  sx: { width: '100%', height: chartHeight },
@@ -455,6 +494,7 @@ export function resolveChartLayout({ size = 'standard', height, xAxis, yAxis, se
455
494
  yAxis: sizedYAxis,
456
495
  bands: Object.assign({ plotHeight,
457
496
  xAxisBand,
497
+ secondaryYAxisBand,
458
498
  xTitleBand, legendBand: legendHeightBand, yAxisBand,
459
499
  rightPad }, (containerWidth != null ? { plotWidth, containerWidth } : {})),
460
500
  };
@@ -473,8 +513,21 @@ export function assertRemovedChartProp(componentName, propName, value, replaceme
473
513
  throw new Error(`[ui-core-micha] <${componentName}> no longer accepts "${propName}" (removed in UCM-CHART-12, `
474
514
  + `v3.0.0 -- see docs/CHART-LAYOUT.md). ${replacement}`);
475
515
  }
476
- export function withChartSlotDefaults(slotProps, legendPosition) {
516
+ /**
517
+ * UCM-CHART-12 (codex second-pass review finding): the single source of truth for where the
518
+ * legend actually renders -- `legendPosition` merged with a `slotProps.legend.position` override,
519
+ * same precedence `withChartSlotDefaults` already applied. Before this fix, `resolveChartLayout`
520
+ * only ever looked at `legendPosition` when deciding how much space to reserve and on which side,
521
+ * while `withChartSlotDefaults` (below) separately let `slotProps.legend.position` win at RENDER
522
+ * time -- a caller passing both could get a legend rendered top/side while the resolver had
523
+ * reserved space for bottom (or none), the exact reserved-vs-rendered mismatch this WO exists to
524
+ * close. Both call sites now merge through this one function.
525
+ */
526
+ export function resolveLegendPosition(legendPosition, slotProps) {
477
527
  var _a;
478
- const position = Object.assign(Object.assign(Object.assign({}, DEFAULT_LEGEND_POSITION), legendPosition), (_a = slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend) === null || _a === void 0 ? void 0 : _a.position);
528
+ return Object.assign(Object.assign(Object.assign({}, DEFAULT_LEGEND_POSITION), legendPosition), (_a = slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend) === null || _a === void 0 ? void 0 : _a.position);
529
+ }
530
+ export function withChartSlotDefaults(slotProps, legendPosition) {
531
+ const position = resolveLegendPosition(legendPosition, slotProps);
479
532
  return Object.assign(Object.assign({}, slotProps), { tooltip: Object.assign({ trigger: 'axis' }, slotProps === null || slotProps === void 0 ? void 0 : slotProps.tooltip), legend: Object.assign(Object.assign({}, slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend), { position }) });
480
533
  }
@@ -25,11 +25,20 @@ chartHeight === plotHeight + xAxisBand + xTitleBand + legendBand
25
25
  - `xTitleBand` — the axis title. Zero unless the x-axis carries a `label`.
26
26
  - `legendBand` — one row for the legend. Zero when `hideLegend` is set.
27
27
  - `plotHeight` is the residual: `chartHeight` minus the three bands above.
28
-
29
- Width follows the same shape (`yAxisBand + plotWidth + rightPad`), but the presets stay
30
- responsive — they never fix a pixel width — so `plotWidth`/`containerWidth` are only meaningful
31
- when a caller explicitly supplies `containerWidth` (tests, or the browser-level check); the
32
- presets themselves never pass it.
28
+ - The y-axis band collapses to zero the same way the x-axis one does: candidates existed (the
29
+ axis has real `min`/`max` or plotted data) but a caller `valueFormatter` blanks every formatted
30
+ tick, and there is no axis title either — the axis has nothing to show, so it reserves nothing.
31
+ Distinct from "nothing to measure at all" (no `min`/`max`/data), where MUI's own default applies
32
+ untouched.
33
+
34
+ Width follows the same shape (`yAxisBand + plotWidth + secondaryYAxisBand + rightPad`), but the
35
+ presets stay responsive — they never fix a pixel width — so `plotWidth`/`containerWidth` are only
36
+ meaningful when a caller explicitly supplies `containerWidth` (tests, or the browser-level check);
37
+ the presets themselves never pass it. A **secondary (right-positioned) y-axis** — `TimeSeriesChart`'s
38
+ `axis: 'secondary'` feature, or any preset given a two-entry `yAxis` array with one `position:
39
+ 'right'` — reserves its own width on the right, added to `margin.right` alongside the fixed
40
+ breathing-room pad; an axis with no explicit `position` defaults to the left, matching MUI's own
41
+ default-assignment rule.
33
42
 
34
43
  ## Removed props, and their replacement
35
44
 
@@ -90,11 +99,13 @@ on the wrapper), only the height stops tracking width.
90
99
 
91
100
  `"auto"` (the default) is a **heuristic**, not a true fit computation — the presets stay
92
101
  responsive, so the resolver has no real container width to measure against before render. It
93
- rotates when there are more than 6 categories *and* the longest formatted label is longer than 4
94
- characters; otherwise it stays horizontal. `"horizontal"` always stays flat; `"angled"` always
95
- rotates (at the caller's own `tickLabelStyle.angle`, or -45° if unset). Verify a specific chart's
96
- real rendered fit with the browser-level check described below — a unit test cannot see real
97
- container width or real font metrics either.
102
+ rotates when there are more than 6 categories *and* the longest label's real MEASURED width
103
+ (same DOM measurement the rotated-band projection itself uses — a real browser measurement when
104
+ available, a per-glyph estimate otherwise) exceeds 50px; otherwise it stays horizontal.
105
+ `"horizontal"` always stays flat; `"angled"` always rotates (at the caller's own
106
+ `tickLabelStyle.angle`, or -45° if unset). "Measured labels" (Rule 3) is therefore real — what
107
+ remains unmeasured is "available width", since no container width exists before render. Verify a
108
+ specific chart's real rendered fit with the browser-level check described below.
98
109
 
99
110
  ## `margin` — no replacement
100
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [
@@ -76,6 +76,7 @@ export function BarChart({
76
76
  xLabels,
77
77
  hideLegend,
78
78
  legendPosition,
79
+ slotProps,
79
80
  tickFontSize: theme.typography.caption.fontSize,
80
81
  spacing: theme.spacing,
81
82
  defaultLineHeight: theme.typography.caption.lineHeight,
@@ -99,6 +99,7 @@ export function LineChart({
99
99
  xLabels,
100
100
  hideLegend,
101
101
  legendPosition,
102
+ slotProps,
102
103
  tickFontSize: theme.typography.caption.fontSize,
103
104
  spacing: theme.spacing,
104
105
  defaultLineHeight: theme.typography.caption.lineHeight,
@@ -230,6 +230,7 @@ export function ScatterChart({
230
230
  xLabels,
231
231
  hideLegend,
232
232
  legendPosition,
233
+ slotProps,
233
234
  tickFontSize: theme.typography.caption.fontSize,
234
235
  spacing: theme.spacing,
235
236
  defaultLineHeight: theme.typography.caption.lineHeight,
@@ -247,7 +247,15 @@ export function sizeYAxisForContent(yAxis, series, tickFontSize) {
247
247
  if (candidates.length === 0) return axis;
248
248
 
249
249
  const longestTick = longestFormattedTick(candidates, axis.valueFormatter);
250
- const tickTextWidth = Math.max(
250
+ // UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed, but a caller
251
+ // `valueFormatter` reduced every one of them to a blank string -- this is the y-axis analogue
252
+ // of `xAxisHasVisibleTicks`'s Access-scatter case, and must collapse the same way. Distinct
253
+ // from `candidates.length === 0` above (nothing to measure at all, MUI's own default applies,
254
+ // untouched): here there WAS content, it just renders as nothing. With no axis title either,
255
+ // the axis has genuinely nothing to show, so the whole band collapses to 0 -- not merely the
256
+ // text-width term floored, matching the x-axis band's own all-or-nothing zero.
257
+ if (longestTick.length === 0 && !axis.label) return { ...axis, width: 0 };
258
+ const tickTextWidth = longestTick.length === 0 ? 0 : Math.max(
251
259
  tickFontSizePx,
252
260
  longestTick.length * tickFontSizePx * AVERAGE_GLYPH_WIDTH_EM,
253
261
  );
@@ -316,11 +324,16 @@ const LEGEND_BAND_PX = 32;
316
324
  // UCM-CHART-12 `xLabels: "auto"`: with no real container-width measurement available before
317
325
  // render (the presets stay responsive -- MUI measures actual width via ResizeObserver, which this
318
326
  // pure function cannot see), "auto" is a documented HEURISTIC, not a true fit computation: rotate
319
- // once there are enough categories AND the longest one is long enough that a horizontal row would
320
- // plausibly collide. The mandatory browser-level check (WO Part C) is what actually verifies fit
321
- // against real rendered geometry; this only picks a reasonable default ahead of that.
327
+ // once there are enough categories AND the longest one, MEASURED (same mechanism as the rotated
328
+ // projection below -- real DOM measurement when available, the per-glyph estimate otherwise), is
329
+ // wide enough that a horizontal row would plausibly collide. Codex second-pass review finding:
330
+ // the first cut of this heuristic used a raw character-count threshold instead of the measured
331
+ // width this function already has available -- replaced, since "decide from the measured labels"
332
+ // (Rule 3) is achievable even without a real container width to compare against. The count
333
+ // threshold stays as the OTHER half of the heuristic: a handful of wide labels can still fit
334
+ // spread across a wide chart, so width alone should not trigger rotation either.
322
335
  const AUTO_ROTATE_TICK_COUNT = 6;
323
- const AUTO_ROTATE_LABEL_CHARS = 4;
336
+ const AUTO_ROTATE_LABEL_WIDTH_PX = 50;
324
337
 
325
338
  function xAxisTickContentText(axis) {
326
339
  const data = axis?.data;
@@ -351,12 +364,23 @@ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextW
351
364
  const fontSize = cssLengthToPixels(axis?.tickLabelStyle?.fontSize) || cssLengthToPixels(fallbackTickFontSize);
352
365
  const longestLabel = xAxisTickContentText(axis) || '';
353
366
  const dataCount = Array.isArray(axis?.data) ? axis.data.length : 0;
367
+ const measure = measureTextWidth || measureTickTextWidthPx;
368
+
369
+ // Measured once, up front -- both "auto"'s rotation DECISION and the rotated-band PROJECTION
370
+ // below need the label's real width, and must agree on what "real" means (same measurement,
371
+ // same estimate fallback) or the two could disagree about the same label.
372
+ const labelWidthPx = fontSize > 0
373
+ ? Math.max(fontSize, (() => {
374
+ const measured = measure(longestLabel, fontSize);
375
+ return Number.isFinite(measured) && measured > 0 ? measured : estimateTextWidthPx(longestLabel, fontSize);
376
+ })())
377
+ : 0;
354
378
 
355
379
  let angle = 0;
356
380
  if (xLabelsMode === 'angled') {
357
381
  angle = Number(axis?.tickLabelStyle?.angle) || -45;
358
382
  } else if (xLabelsMode === 'auto') {
359
- const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && longestLabel.length > AUTO_ROTATE_LABEL_CHARS;
383
+ const needsRotation = dataCount > AUTO_ROTATE_TICK_COUNT && labelWidthPx > AUTO_ROTATE_LABEL_WIDTH_PX;
360
384
  angle = needsRotation ? -45 : 0;
361
385
  } // "horizontal" always stays at angle 0
362
386
 
@@ -367,14 +391,7 @@ function resolveXAxisGeometry(axis, xLabelsMode, defaultLineHeight, measureTextW
367
391
  if (fontSize === 0) return { tickBand: TICK_BAND_BASE_PX, angle };
368
392
 
369
393
  const lineHeight = Number(axis?.tickLabelStyle?.lineHeight) || defaultLineHeight;
370
- const measure = measureTextWidth || measureTickTextWidthPx;
371
- const measuredWidth = measure(longestLabel, fontSize);
372
- const textWidth = Math.max(
373
- fontSize,
374
- Number.isFinite(measuredWidth) && measuredWidth > 0
375
- ? measuredWidth
376
- : estimateTextWidthPx(longestLabel, fontSize),
377
- );
394
+ const textWidth = labelWidthPx;
378
395
  const radians = (angle * Math.PI) / 180;
379
396
  const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
380
397
  + Math.abs(Math.sin(radians)) * textWidth;
@@ -434,6 +451,7 @@ export function resolveChartLayout({
434
451
  xLabels = 'auto',
435
452
  hideLegend = false,
436
453
  legendPosition = DEFAULT_LEGEND_POSITION,
454
+ slotProps,
437
455
  tickFontSize,
438
456
  spacing,
439
457
  defaultLineHeight = 1,
@@ -449,33 +467,50 @@ export function resolveChartLayout({
449
467
  );
450
468
  const xTitleBand = primaryXAxis?.label ? AXIS_TITLE_BAND_PX : 0;
451
469
 
470
+ // Codex second-pass review finding: a caller-set `slotProps.legend.position` overrides
471
+ // `legendPosition` at RENDER time (`withChartSlotDefaults`) but was invisible here, so the
472
+ // margin/height this function reserved could disagree with where the legend actually rendered.
473
+ // `resolveLegendPosition` is the one merge both call sites now share.
474
+ const effectiveLegendPosition = resolveLegendPosition(legendPosition, slotProps);
475
+
452
476
  // A `vertical: 'middle'` legend renders on the SIDE (consuming width, not height) -- reviewer
453
477
  // finding: only branching on 'top' vs. not-'top' always reserved the legend band vertically,
454
478
  // over-reserving height and under-reserving width for a side-placed legend. This WO's width
455
479
  // invariant does not itemize a legend-width band, so a side legend simply gets none of the
456
480
  // height reservation here rather than a wrong one -- consistent with the WO's own text ("0 when
457
481
  // legend is off or horizontal").
458
- const legendVertical = legendPosition?.vertical ?? DEFAULT_LEGEND_POSITION.vertical;
482
+ const legendVertical = effectiveLegendPosition.vertical;
459
483
  const legendIsSidePlaced = legendVertical === 'middle';
460
484
  const legendHeightBand = hideLegend || legendIsSidePlaced ? 0 : LEGEND_BAND_PX;
461
485
  const legendOnTop = legendHeightBand > 0 && legendVertical === 'top';
462
486
 
463
487
  const sizedYAxis = sizeYAxisForContent(yAxis?.length ? yAxis : [{}], series, tickFontSize);
464
- const yAxisBand = sizedYAxis[0]?.width ?? 0;
488
+ // Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's `axis: 'secondary'`
489
+ // feature, CHART-5) has a SECOND y-axis on the right, consuming its own width -- the resolver
490
+ // only ever accounted for `sizedYAxis[0]`. `position` defaults to `'left'` for an unmarked axis
491
+ // (matching MUI's own default-assignment rule, same as `sizeYAxisForContent`'s own series-match
492
+ // logic above), so only an explicit `'right'` counts as the secondary axis.
493
+ const primaryYAxis = sizedYAxis.find((axisEntry) => axisEntry.position !== 'right') ?? sizedYAxis[0];
494
+ const secondaryYAxisEntry = sizedYAxis.find((axisEntry) => axisEntry.position === 'right');
495
+ const yAxisBand = primaryYAxis?.width ?? 0;
496
+ const secondaryYAxisBand = secondaryYAxisEntry?.width ?? 0;
465
497
  const rightPad = PACKAGE_DEFAULT_MARGIN.right;
466
498
 
467
499
  const resolvedXAxis = xAxisList.map((axisEntry, index) => {
468
- if (index !== 0 || axisEntry.height != null) return axisEntry;
500
+ if (index !== 0) return axisEntry;
469
501
  // `xLabels` is the SOLE source of truth for rotation (Rule 3) -- `angle` is always written
470
- // explicitly here, even when 0, rather than only overriding a truthy value. Leaving a stale
471
- // caller-supplied `tickLabelStyle.angle` in place when `xLabels` resolved to no rotation would
472
- // let MUI still render a rotated label whose band this resolver reserved as if it were flat --
473
- // exactly the space/render mismatch this WO exists to close, just inverted (under-reservation
474
- // instead of CHART-10's over-reservation).
502
+ // explicitly here, even when 0, rather than only overriding a truthy value, and regardless of
503
+ // whether the caller also set an explicit `height` (own `height` bypasses the model's SIZE,
504
+ // never its ROTATION -- codex second-pass review finding: the two were previously coupled,
505
+ // so a caller-set `height` could silently keep a stale rotated `tickLabelStyle.angle` even
506
+ // under `xLabels="horizontal"`). Leaving a stale caller-supplied angle in place when `xLabels`
507
+ // resolved to no rotation would let MUI still render a rotated label whose band this resolver
508
+ // reserved as if it were flat -- exactly the space/render mismatch this WO exists to close,
509
+ // just inverted (under-reservation instead of CHART-10's over-reservation).
475
510
  const { angle: _callerAngle, ...restTickLabelStyle } = axisEntry.tickLabelStyle || {};
476
511
  return {
477
512
  ...axisEntry,
478
- height: xAxisBand + xTitleBand,
513
+ height: axisEntry.height ?? (xAxisBand + xTitleBand),
479
514
  tickLabelStyle: { ...restTickLabelStyle, angle },
480
515
  };
481
516
  });
@@ -484,14 +519,16 @@ export function resolveChartLayout({
484
519
  top: PACKAGE_DEFAULT_MARGIN.top + (legendOnTop ? legendHeightBand : 0),
485
520
  bottom: PACKAGE_DEFAULT_MARGIN.bottom + (legendOnTop ? 0 : legendHeightBand),
486
521
  left: PACKAGE_DEFAULT_MARGIN.left,
487
- right: rightPad,
522
+ right: rightPad + secondaryYAxisBand,
488
523
  };
489
524
 
490
525
  // `plotHeight`/`plotWidth`: the residual of `chartHeight`/`containerWidth` minus the three named
491
526
  // furniture bands (see this function's own docblock for why this alone does not prove the
492
527
  // absence of a double-counted term, and what does).
493
528
  const plotHeight = chartHeight - xAxisBand - xTitleBand - legendHeightBand;
494
- const plotWidth = containerWidth != null ? containerWidth - yAxisBand - rightPad : undefined;
529
+ const plotWidth = containerWidth != null
530
+ ? containerWidth - yAxisBand - secondaryYAxisBand - rightPad
531
+ : undefined;
495
532
 
496
533
  return {
497
534
  chartHeight,
@@ -502,6 +539,7 @@ export function resolveChartLayout({
502
539
  bands: {
503
540
  plotHeight,
504
541
  xAxisBand,
542
+ secondaryYAxisBand,
505
543
  xTitleBand,
506
544
  legendBand: legendHeightBand,
507
545
  yAxisBand,
@@ -527,12 +565,26 @@ export function assertRemovedChartProp(componentName, propName, value, replaceme
527
565
  );
528
566
  }
529
567
 
530
- export function withChartSlotDefaults(slotProps, legendPosition) {
531
- const position = {
568
+ /**
569
+ * UCM-CHART-12 (codex second-pass review finding): the single source of truth for where the
570
+ * legend actually renders -- `legendPosition` merged with a `slotProps.legend.position` override,
571
+ * same precedence `withChartSlotDefaults` already applied. Before this fix, `resolveChartLayout`
572
+ * only ever looked at `legendPosition` when deciding how much space to reserve and on which side,
573
+ * while `withChartSlotDefaults` (below) separately let `slotProps.legend.position` win at RENDER
574
+ * time -- a caller passing both could get a legend rendered top/side while the resolver had
575
+ * reserved space for bottom (or none), the exact reserved-vs-rendered mismatch this WO exists to
576
+ * close. Both call sites now merge through this one function.
577
+ */
578
+ export function resolveLegendPosition(legendPosition, slotProps) {
579
+ return {
532
580
  ...DEFAULT_LEGEND_POSITION,
533
581
  ...legendPosition,
534
582
  ...slotProps?.legend?.position,
535
583
  };
584
+ }
585
+
586
+ export function withChartSlotDefaults(slotProps, legendPosition) {
587
+ const position = resolveLegendPosition(legendPosition, slotProps);
536
588
  return {
537
589
  ...slotProps,
538
590
  tooltip: {
@@ -20,8 +20,10 @@ import {
20
20
  assertRemovedChartProp,
21
21
  defaultNumericTickFormatter,
22
22
  resolveChartLayout,
23
+ resolveLegendPosition,
23
24
  sizeYAxisForContent,
24
25
  withAxisDefaults,
26
+ withChartSlotDefaults,
25
27
  } from '../src/components/charts/chartDefaults';
26
28
  import { createAppTheme } from '../src/theme/createAppTheme';
27
29
 
@@ -442,6 +444,102 @@ describe('resolveChartLayout composition invariant (UCM-CHART-12)', () => {
442
444
  expect(bands.yAxisBand + bands.plotWidth + bands.rightPad).toBe(600);
443
445
  expect(bands.yAxisBand).toBeGreaterThan(0);
444
446
  });
447
+
448
+ // Codex second-pass review finding: a dual-axis chart (TimeSeriesChart's axis: 'secondary'
449
+ // feature) has a SECOND y-axis on the right, which the resolver previously ignored entirely --
450
+ // `sizedYAxis[0]` was the only width ever accounted for. The width invariant must include it.
451
+ it('accounts for a secondary (right-positioned) y-axis in both margin.right and the width invariant', () => {
452
+ const single = resolveChartLayout({
453
+ xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
454
+ yAxis: [{ scaleType: 'linear', id: 'primary', min: 0, max: 100 }],
455
+ tickFontSize: 12,
456
+ spacing: theme.spacing,
457
+ containerWidth: 600,
458
+ });
459
+ const dual = resolveChartLayout({
460
+ xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
461
+ yAxis: [
462
+ { scaleType: 'linear', id: 'primary', position: 'left', min: 0, max: 100 },
463
+ { scaleType: 'linear', id: 'secondary', position: 'right', min: 0, max: 500000 },
464
+ ],
465
+ tickFontSize: 12,
466
+ spacing: theme.spacing,
467
+ containerWidth: 600,
468
+ });
469
+
470
+ expect(dual.bands.secondaryYAxisBand).toBeGreaterThan(0);
471
+ // The wider secondary axis (500000 vs 100) must reserve MORE than the primary alone.
472
+ expect(dual.margin.right).toBeGreaterThan(single.margin.right);
473
+ expect(dual.margin.right).toBe(PACKAGE_DEFAULT_MARGIN.right + dual.bands.secondaryYAxisBand);
474
+ // Width invariant, now with the secondary band as a fourth term.
475
+ expect(dual.bands.yAxisBand + dual.bands.plotWidth + dual.bands.secondaryYAxisBand + dual.bands.rightPad)
476
+ .toBe(600);
477
+ });
478
+
479
+ it('an unmarked (no explicit position) second y-axis defaults to the LEFT, matching MUI, and reserves no secondary band', () => {
480
+ const layout = resolveChartLayout({
481
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
482
+ yAxis: [
483
+ { scaleType: 'linear', id: 'a', min: 0, max: 1 },
484
+ { scaleType: 'linear', id: 'b', min: 0, max: 1 },
485
+ ],
486
+ tickFontSize: 12,
487
+ spacing: theme.spacing,
488
+ });
489
+ expect(layout.bands.secondaryYAxisBand).toBe(0);
490
+ });
491
+
492
+ // Codex second-pass review finding: a caller-supplied `valueFormatter` that blanks every tick
493
+ // must collapse the y-axis band to zero too (Rule 2's general principle), not just the x-axis
494
+ // case the WO's Definition of Done names explicitly.
495
+ it('collapses the y-axis band to zero when every tick formats to a blank string', () => {
496
+ const layout = resolveChartLayout({
497
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
498
+ yAxis: [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '' }],
499
+ tickFontSize: 12,
500
+ spacing: theme.spacing,
501
+ });
502
+ expect(layout.bands.yAxisBand).toBe(0);
503
+ expect(layout.yAxis[0].width).toBe(0);
504
+ });
505
+
506
+ // Codex second-pass review finding: `slotProps.legend.position` can override `legendPosition` at
507
+ // render time (`withChartSlotDefaults`) -- the resolver must reserve space for wherever the
508
+ // legend ACTUALLY renders, not just what `legendPosition` alone says.
509
+ it('reserves space for the slotProps.legend.position override, not just legendPosition', () => {
510
+ const layout = resolveChartLayout({
511
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
512
+ yAxis: [{ scaleType: 'linear' }],
513
+ legendPosition: { vertical: 'bottom' },
514
+ slotProps: { legend: { position: { vertical: 'top' } } },
515
+ spacing: theme.spacing,
516
+ });
517
+ expect(layout.margin.top).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.top);
518
+ expect(layout.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
519
+ });
520
+
521
+ it('resolveLegendPosition matches withChartSlotDefaults\' own merge precedence exactly', () => {
522
+ const legendPosition = { vertical: 'bottom', horizontal: 'start' };
523
+ const slotProps = { legend: { position: { horizontal: 'end' } } };
524
+ const resolved = resolveLegendPosition(legendPosition, slotProps);
525
+ const fromSlotDefaults = withChartSlotDefaults(slotProps, legendPosition).legend.position;
526
+ expect(resolved).toEqual(fromSlotDefaults);
527
+ expect(resolved).toEqual({ vertical: 'bottom', horizontal: 'end' });
528
+ });
529
+
530
+ // Codex second-pass review finding: a caller-set `xAxis[0].height` bypasses the model's SIZE,
531
+ // but rotation must still be governed entirely by `xLabels` -- a stale caller-set angle must not
532
+ // survive under a mode that says "no rotation".
533
+ it('still governs rotation via xLabels even when the caller sets an explicit axis height', () => {
534
+ const layout = resolveChartLayout({
535
+ xAxis: [{ scaleType: 'band', data: ['Jan'], height: 60, tickLabelStyle: { angle: -60 } }],
536
+ yAxis: [{ scaleType: 'linear' }],
537
+ xLabels: 'horizontal',
538
+ spacing: theme.spacing,
539
+ });
540
+ expect(layout.xAxis[0].height).toBe(60); // the caller's own height escape still wins
541
+ expect(layout.xAxis[0].tickLabelStyle.angle).toBeFalsy(); // but the stale rotation does not survive
542
+ });
445
543
  });
446
544
 
447
545
  describe('resolveChartLayout size tokens and height escape (UCM-CHART-12)', () => {
@@ -491,12 +589,17 @@ describe('resolveChartLayout xLabels rotation (UCM-CHART-12)', () => {
491
589
  expect(layout.xAxis[0].tickLabelStyle.angle).toBe(-45);
492
590
  });
493
591
 
494
- it('"auto" stays horizontal for a few short labels, rotates for many long ones', () => {
592
+ it('"auto" stays horizontal for a few short labels, rotates for many long ones (measured width, not char count)', () => {
593
+ // codex second-pass review finding: "auto" must decide from the label's MEASURED width, not a
594
+ // raw character count -- these inject a deterministic measurer instead of relying on the
595
+ // per-glyph estimate, and a `tickFontSize` fallback so the resolver can actually measure.
495
596
  const few = resolveChartLayout({
496
597
  xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb', 'Mar'] }],
497
598
  yAxis: [{ scaleType: 'linear' }],
498
599
  xLabels: 'auto',
600
+ tickFontSize: 12,
499
601
  spacing: theme.spacing,
602
+ measureTextWidth: () => 20, // narrow -- well under the rotation threshold
500
603
  });
501
604
  expect(few.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
502
605
 
@@ -504,11 +607,37 @@ describe('resolveChartLayout xLabels rotation (UCM-CHART-12)', () => {
504
607
  xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `Ward name ${i}`) }],
505
608
  yAxis: [{ scaleType: 'linear' }],
506
609
  xLabels: 'auto',
610
+ tickFontSize: 12,
507
611
  spacing: theme.spacing,
612
+ measureTextWidth: () => 90, // wide -- over the rotation threshold
508
613
  });
509
614
  expect(many.xAxis[0].tickLabelStyle.angle).toBe(-45);
510
615
  });
511
616
 
617
+ it('"auto" does not rotate a few WIDE labels -- tick count and measured width must both cross the threshold', () => {
618
+ const layout = resolveChartLayout({
619
+ xAxis: [{ scaleType: 'band', data: ['A very wide single label'] }],
620
+ yAxis: [{ scaleType: 'linear' }],
621
+ xLabels: 'auto',
622
+ tickFontSize: 12,
623
+ spacing: theme.spacing,
624
+ measureTextWidth: () => 200, // wide, but only one category
625
+ });
626
+ expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
627
+ });
628
+
629
+ it('"auto" does not rotate many labels that are individually narrow', () => {
630
+ const layout = resolveChartLayout({
631
+ xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `${i}`) }],
632
+ yAxis: [{ scaleType: 'linear' }],
633
+ xLabels: 'auto',
634
+ tickFontSize: 12,
635
+ spacing: theme.spacing,
636
+ measureTextWidth: () => 8, // many categories, but each label is tiny
637
+ });
638
+ expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
639
+ });
640
+
512
641
  it('drives the rotated tick band from a measured width, not a flat constant', () => {
513
642
  const short = resolveChartLayout({
514
643
  xAxis: [{ scaleType: 'band', data: ['A'], tickLabelStyle: { fontSize: 12 } }],
@@ -655,6 +784,24 @@ describe('sizeYAxisForContent (THEME-9)', () => {
655
784
  expect(axis.width).toBe(42);
656
785
  });
657
786
 
787
+ // UCM-CHART-12 Rule 2 (codex second-pass review finding): candidates existed (min/max set), but
788
+ // a caller `valueFormatter` reduced every tick to a blank string -- distinct from "nothing to
789
+ // measure at all" (the untouched/undefined case below), this must collapse to a real 0, not the
790
+ // font-size floor a blank string previously still received.
791
+ it('collapses to width 0 when every formatted tick is blank and there is no axis title', () => {
792
+ const [axis] = sizeYAxisForContent(
793
+ [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '' }], [], '12px',
794
+ );
795
+ expect(axis.width).toBe(0);
796
+ });
797
+
798
+ it('still reserves width for the axis TITLE when ticks are blank but a label is set', () => {
799
+ const [axis] = sizeYAxisForContent(
800
+ [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: () => '', label: 'Coverage' }], [], '12px',
801
+ );
802
+ expect(axis.width).toBeGreaterThan(0);
803
+ });
804
+
658
805
  it('leaves a categorical (band/point) axis alone -- its ticks are names, not numbers', () => {
659
806
  const [axis] = sizeYAxisForContent(
660
807
  [{ scaleType: 'band', data: ['a', 'b'] }], [], '12px',