@micha.bigler/ui-core-micha 2.42.0 → 2.42.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.
package/WORK_ORDERS.md CHANGED
@@ -91,3 +91,5 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
91
91
  | THEME-10 | Ein Scatter-Preset, entworfen an drei realen Konsumenten | hram zeichnet **drei** Scatter vollständig von Hand — eigene Achsenlinien, Gitter, Ticks, Tick-Beschriftungen und gedrehte Achsentitel auf fest verdrahteten Leinwänden; aus diesem Paket kommt nur `ChartFrame`. `AllocationPerformancePanel` (720×420, Wolke + Hüllkurve + drei markierte Punkte, kontinuierliche Färbung), `AccessGapScatterPanel` (Bubble-Wolke mit datengetriebenem Radius + beschriftete y=x-Diagonale, **kategoriale** Färbung mit diskreter Legende, beide Achsen fix 0–1) und `OptimizationResultsPanel` (Wolke + gestrichelte Referenzlinie, Status quo als hohler Marker). Das ist keine Nachlässigkeit — **es gibt hier kein Scatter-Preset**, also gab es nichts zu greifen; jede Verbesserung dieses Kits (Achsengrössen, Tick-Formatierung, Label-Platzierung, Rollenpalette) geht an allen dreien vorbei. Der Operator hat diesen Weg bewusst gewählt, statt die Apps MUI X direkt nutzen zu lassen (hram `FIX-15`, Gabelung b). Voller Auftrag [`work-orders/THEME-10.md`](work-orders/THEME-10.md) | 2026-08-14 | done (npm publish 2.41.0, gebündelt mit THEME-11; Konsumenten-Pin-Bumps offen) | ff67fc7 | Tier 3 (Shared-Core, neue öffentliche API). Codex bereits `unavailable` für heute — direkt in Claude implementiert, Autorenschaft geflippt, `reviewer` + `ui_reviewer` liefen parallel (ohnehin Pflicht bei Tier 3). **`reviewer` R1 (P1, behoben):** `sizeYAxisForContent` erwartet rohe Zahlen in `series[].data`, Scatter liefert `{x,y,z,id}`-Objekte — der Fallback fand still NULL Kandidaten, solange kein `yAxis.min/max` gesetzt war (stiller Rückfall auf MUIs Flat-Default). Behoben per Adapter am Call-Site (`yValueSeries`), NICHT am geteilten Helper — BarChart/LineChart bleiben unangetastet. **R2 (P2, behoben):** `dataIndex`-Semantik im Bubble-Modus (post-Sort-Index, nicht Original-Array) war nicht dokumentiert — jetzt im Docstring. **`ui_reviewer` U1 (P2, aufgeklärt statt Code geändert):** `shape:'square'` schien nicht auf die drei Panels rückführbar — tatsächlich rückführbar auf Allocation-Panels `isTargeted`-Punkte (`ShapeGlyph`), im WO nur nicht explizit zitiert; WO-Text nachgezogen. **U2 (P2, behoben):** Optimization-Form war live nicht eigens gerendert — dritte Dev-Harness-Fixture ergänzt und live verifiziert (genau diese Kombination — kein `yAxis.min/max`, keine explizite Palette — deckte R1 auf, die beiden anderen Fixtures umgingen den Fehler zufällig). **U3 (P3, behoben):** Test ergänzt, dass eine Einzelserie mit eigener `.color` nicht als neutral behandelt wird. **U5 (P3, dokumentiert):** WO-Text stellte klar, dass Allocations eigener Status-quo-Punkt ebenfalls hohl ist, nicht nur Optimizations. **U4 (P3, akzeptierter Rest):** fehlende Nicht-visuelle Kennzeichnung von hohl/Form — vorbestehende Lücke im ganzen Chart-Kit, nicht neu eingeführt, nicht in dieser WO behoben. Alle drei benannten Konsumenten-Formen live im eigenen Dev-Harness gerendert und DOM-vermessen (Mark-Counts 6/6, 7/7, 4/4 — keine stillen Drops); ein zweiter echter Bug (Bubble-Radius pro Serie statt Chart-weit) beim Live-Rendern gefunden und vor Commit behoben, Regressionstest ergänzt. Affected-Set grün (78 Tests). **Version noch nicht gebumpt, Publish zurückgehalten** — Operator kündigte THEME-11 an, Version-Bump + Publish sollen gebündelt werden. **Das ursprünglich benannte Einzelkonsumenten-Risiko ist weitgehend erledigt.** Der erste Entwurf (2026-08-14) warnte, eine Abstraktion aus einem einzigen Beispiel friere die falsche Form ein; danach nannte der Operator das Access-Panel, und eine Suche fand ein drittes. Drei unabhängige Konsumenten unterscheiden eine echte Variationsachse von einer Vermutung — und **zwei der Achsen wurden gefunden, nicht entworfen**: kategoriale Färbung mit diskreter Legende und datengetriebener Bubble-Radius mit Z-Order kommen im Allocation-Panel überhaupt nicht vor. Aus einem Panel gebaut hätte das Preset beide verfehlt. Ersetzt wird das Risiko durch die engere Disziplin: **jede Option muss auf eines der drei Panels zurückführbar sein**, sonst kommt sie nicht rein; wächst die API während der Umsetzung, stop and report. **Neue Hauptgefahr ist die Übergeneralisierung:** dieselbe Suche fand zwei weitere handgemalte Panels, die ebenfalls Kreise zeichnen, aber **ein anderes Diagramm sind** — `CalibrationPanel` (Forest-Plot, `MARGIN.left=150`, Punktschätzer + Intervall je Zeile) und `SensitivityAnalysisPanel` (Tornado, `MARGIN.left=220`, `<rect>`-Balken), dazu die Divergenz-Ansicht in `OptimizationResultsPanel`. Kategoriale y-Achse, 150–220 px Zeilenlabels, Zeilendichte als Layoutproblem — **ausdrücklich ausgeschlossen**, eigenes Preset, eigener WO. **Wichtigster Default bleibt:** die undifferenzierte Wolke ist neutral (Rollenregister), NICHT eine KPI-Identitätsfarbe — genau das macht das Allocation-Panel falsch. Das Access-Panel färbt dagegen **zu Recht** kategorial (Division/Settlement sind echte Datendimensionen und kommen bereits aus dem eingefrorenen Familienregister); neutral ist der Default für „keine Dimension kodiert“, kein Farbverbot. Muss THEME-9s Helfer wiederverwenden (`sizeYAxisForContent`, `defaultNumericTickFormatter`, `withAxisDefaults`, `spaceForRotatedTicks`); ein Preset mit eigener Achsenlogik würde genau das wieder aufreissen, was THEME-9 geschlossen hat. Konkrete Regression, die es stoppt: das Access-Panel verdrahtet sechs Grautöne fest (`#F0F0F0`, `#D0D0D0`, `#999`, `#666`, `#555`, `#B0B0B0`), die keine Theme-Änderung erreicht. Nicht am Publish abschliessen: hram `FIX-15` ist der erste Konsument und blockiert darauf; Access und Optimization sind bekannte Konsumenten **ohne eigenen WO** — separat zu planen, nicht hier einzufalten. Pin-Bump zusammen mit den bereits wartenden THEME-8 und THEME-9 fahren. |
92
92
  | THEME-11 | Das Kit hat keine Meinung zu Chart-Rändern, also erbt jedes Diagramm im Estate die von MUI | hrams vier KPI-Karten wurden pixelweise zerlegt: Chart 270 px hoch, davon **202 px Plotfläche** — die restlichen 167 px sind Beiwerk, und die Bilanz geht auf die beobachteten ~370 px Kartenhöhe exakt auf. Der Grund für 20 px auf allen vier Seiten: **dieses Paket bildet nie eine Meinung**. `spaceForRotatedTicks` gibt `margin` unverändert zurück, solange die x-Ticks nicht rotiert sind — reicht der Aufrufer nichts, geht `margin: undefined` an `MuiBarChart` und `DEFAULT_MARGINS = {20,20,20,20}` greift. THEME-9 hat dem Kit Achsen*grössen* gegeben; Ränder waren nie in seinem Scope. Neu: `top: 10`, `bottom: 8`, `left: 8`, `right: 16`, plus `ChartFrame`s Titel-`mb: 2` → `mb: 1`. Voller Auftrag [`work-orders/THEME-11.md`](work-orders/THEME-11.md) | 2026-08-14 | done (npm publish 2.41.0, gebündelt mit THEME-10; Konsumenten-Pin-Bumps offen) | bb7794d | Tier 3 (Shared-Core). Codex bereits `unavailable` — direkt in Claude implementiert, Autorenschaft geflippt, `reviewer` + `ui_reviewer` liefen parallel (ohnehin Pflicht bei Tier 3). **Beide Fallen sauber vermieden** (`reviewer`, an beiden Dateien einzeln verifiziert, keine Befunde): `spaceForRotatedTicks` läuft weiterhin auf dem unveränderten Aufrufer-`margin`, `withMarginDefaults` erst danach gemerged — der rotierte Pfad verliert seinen Zusatzplatz nicht. `MUI_CHART_MARGIN_BOTTOM` (20→8) mitgezogen. **`ui_reviewer` U1-U4 (alle P2/P3, dokumentiert statt Code geändert):** die live gemessene Fast-Kollision (gruppierte Zahl "1,000" bei nur ~1px Abstand vom neuen `right:16`) stammt aus MUIs eigener Standard-Tick-Formatierung, nicht aus THEME-9s Formatter (Fehlzuschreibung korrigiert); **`reviewer` verifizierte zusätzlich**, dass KEIN aktueller hram-Konsument diesen Pfad überhaupt trifft (alle nutzen `scaleType:'band'`; `AllocationPerformancePanel` — der ursprünglich befürchtete Fall — importiert nur `ChartFrame`, zeichnet eigenes SVG, geht gar nicht über `BarChart`/`LineChart`) — "kein Konsument hat das" trifft also exakt zu, nicht nur plausibel. Restrisiko dokumentiert für den ersten künftigen linearen Konsumenten. `ChartFrame`s `mb`-Trim bewusst unbedingt (nicht an Chart-Preset gekoppelt) — Titelzeile und Plot-Rand sind unabhängiges Möbel. **Scope-Lücke benannt, nicht eingefaltet:** `ScatterChart` (THEME-10) bekommt noch keinen `withMarginDefaults` — Folge-WO. Affected-Set grün (91 Tests). Gerendert live verifiziert: rotierter Tick-Chart ohne Overlap (15px Abstand zum Achsentitel), linearer x-Achsen-Chart ohne echtes Clipping (schmal: 5px Abstand; breit: MUIs eigene Kürzung greift bei ~1px, nie Overflow). **Warum die Werte pro Seite verschieden sind — das ist der Kern, kein Feinschliff:** `margin.left` liegt **ausserhalb** der y-Achsenbreite (`drawingArea.left = margin.left + axisSizeLeft`, Kommentar des Pakets selbst), ist also reines Polster; `margin.bottom` liegt **unter** dem x-Achsenblock, dessen `DEFAULT_AXIS_SIZE_HEIGHT` das Tick-Label bereits enthält; `margin.top` trägt dagegen echten Inhalt — das oberste y-Tick-Label ist auf seiner Gitterlinie zentriert und ragt eine halbe Caption-Zeilenhöhe (~9 px) über die Plotfläche, **deshalb 10 und nicht 8**. `right` wird bewusst **weniger** getrimmt (16): bei einer Bandachse sitzen die Ticks mittig, bei einer **linearen** x-Achse sitzt das letzte Label auf der Kante und ragt eine halbe Labelbreite (~13 px) hinaus. Nicht vom Skalentyp abhängig machen — das wäre abgeleitetes Verhalten ohne benannten Konsumenten. **Zwei benannte Fallen.** (1) `spaceForRotatedTicks` erkennt „Aufrufer hat gesetzt“ an `margin?.bottom != null`; wird ein Paket-Default **vor** dieser Prüfung injiziert, sehen rotierte Charts ihn als aufrufergesetzt und verlieren ihren Extraplatz — hrams `RatioMetricsPanel` fährt Labels bei −45° und −90° und würde sie beschneiden. (2) `MUI_CHART_MARGIN_BOTTOM = 20` in `chartDefaults.js` ist eine hartkodierte Kopie des MUI-Defaults und Basis für den rotierten Pfad — muss mitwandern. **Per-Seite mergen, nicht das ganze Objekt ersetzen:** Panels, die schon von Hand tunen (`{left:60, right:8, top:20, …}`), müssen auf ihren gesetzten Seiten unberührt bleiben. `CHART_FRAME_ROOT_SX = { p: 2 }` bleibt bewusst stehen — `ChartFrame` umschliesst auch Nicht-Chart-Inhalte, das ist ein eigener Vorschlag mit anderer Streuweite, kein Anhängsel. **Streuweite:** trifft jedes Diagramm in jeder konsumierenden App, und zwar beim Pin-Bump, nicht beim Publish — ein falscher Seitenwert ist damit eine estate-weite Regression. Der gerenderte Check muss deshalb **einen rotierten** und **einen linearen** x-Achsen-Chart enthalten, nicht nur die KPI-Karten. Pin-Bump zusammen mit THEME-8/9/10 fahren. |
93
93
  | UCM-CHART-8 | Make the chart presets stop producing dead space below the chart | `minHeight` and `height` are two different things on these presets and the API lets a caller pass both: `minHeight` lands on the WRAPPER Box (`ScatterChart.jsx:260`, same construction in `BarChart.jsx`), `height` sizes the CHART. When `minHeight > height` the difference is dead space BELOW the chart. Measured in hram 2026-08-20: StructuralReachability 420/380 (40 px), ExpertTimeline 340/280 (60 px), ResultsTimeline 320/280 (40 px), GroupMetrics 300/none (chart unsized). The equal-value majority is WHY it survived -- passing both looks harmless and costs nothing when the numbers match, so it spread by copy-paste. Diagnosed and fixed POINTWISE TWICE in hram (`AllocationPerformancePanel.jsx:462-478`, `OptimizationResultsPanel.jsx:928-940`) with the explanation living as a comment inside those two files, where no new panel reads it. One shared API permitting an incoherent pair, not N panel bugs. | 2026-08-20 | done | 11d362c | Tier 3, shared-core -- every consumer inherits it. Full order: [`work-orders/UCM-CHART-8.md`](work-orders/UCM-CHART-8.md). Deliberately NON-breaking: `minHeight` is deprecated in the docstring and COERCED in code, not removed -- removing it would make every consumer's pin bump a Tier-3 change with unnoticed layout regressions, worse than the defect. `minHeight` alone sizes the chart; `minHeight > height` reserves no wrapper padding; equal values byte-identical; a DEV-ONLY warning names the disagreeing pair (no throw -- a shared package must not break a consumer's page over a layout nit). Before publishing, check the other consumers (dcm-based apps, survey-renderer) for `minHeight` on a chart preset and report what was found -- the one way this could read as a regression is a consumer using the padding as spacing. Publishes from `main` with no staging step, so verify the version is live before any consumer bump. hram's call-site sweep is a separate follow-up GATED on that pin bump. Raised by the operator as "why is there still too much whitespace everywhere" -- the recurrence, not the instances, is the subject. **Envelope amended 2026-08-21 after review — the original rule would have broken four live call sites.** The resolution is **three-way, not two-way**: `aspect` is a third input and it changes the answer. Four consumers pass `aspect` **with** `minHeight` and **no** `height` — `hram/AccessGapScatterPanel.jsx:182` (`aspect={1.8}`, `minHeight={320}`) and three sites in `fitness-monitor/BodyHistoryPage.jsx` — and there `minHeight` is a legitimate **floor under an aspect-derived height**, not stray padding. "`minHeight` alone sizes the chart" as first written would have turned all four into fixed-height charts. Same prop shape, opposite intent, which is why the rule cannot be stated on `minHeight`/`height` alone. Also corrected: `ChartFrame` takes `minHeight`/`aspect` too and was missing from the file list, and **`BarChart`, `LineChart` and `ChartFrame` never destructure `height`** — for them the pair exists only via the rest spread, so there is no declared prop to look for. The Risks section's hypothetical consumer check is now **done**, with the four sites as its result; `survey-renderer` is named there as a consumer to check but the standalone repo is retired (2026-07-31). Tests gained the `aspect` case explicitly — it is the one that must fail if the resolution is later re-simplified. **Implemented 2026-08-21, Codex known-unavailable that day** (`.claude/codex-status.md` already carried an `unavailable` line for today at task start, from `cockpit/LIVE-4` and `hram/OPT-8`) — Orchestrator implemented directly in Claude, flipping authorship, so the independent reviewer + ui_reviewer (both mandatory at Tier 3 regardless) covered a diff the Orchestrator itself wrote. `resolveChartHeight`/`warnOnHeightMismatch` added to `chartDefaults.js`; wired into ScatterChart/BarChart/LineChart (destructuring `height` out of the rest spread, forwarding it explicitly) and ChartFrame (new optional `height` prop, applied to its own content box since it has no inner MUI chart to forward to). TimeSeriesChart.jsx left unmodified — it never exposes `minHeight`/`height`/`aspect` to its own caller, only forwards a single internal `CHART_HEIGHT` constant identically to ChartFrame's `minHeight` and BarChart's `height`, confirmed unchanged by the full existing `TimeSeriesChart.test.jsx` suite passing untouched. **`reviewer` (Sonnet)**: no bugs; two P3s — the `height`+`aspect` combination on Bar/Line/Scatter is pre-existing, untested, and not fully governed by the three-way contract (wrapper still gets `aspectRatio` unconditionally while the chart gets a hardcoded `height`), and the TimeSeriesChart 320/320 coincidence is two independently-resolved branches landing on the same value, not literally the "equal values" case — both accepted as out of this WO's scope, no live call site hits either. **`ui_reviewer` (Sonnet)**: one P1 noted-not-a-bug (the minHeight-alone branch now gives every such consumer a fixed chart height, which is exactly Part A's Definition of Done, not an undisclosed change — flagged here as a residual cross-app risk since only the 4 aspect+minHeight sites were confirmed, not every minHeight-alone call site) and one real P2, **fixed**: `warnOnHeightMismatch` was called in the render body, so it re-fired the console.warn on every re-render of a mismatched instance; moved into a `useEffect` keyed on `[minHeight, height]` in all four components. Re-verified 128/128 scoped tests green after the fix (BarChart/LineChart/ScatterChart/ChartFrame/chartDefaults/TimeSeriesChart/packageTreeShaking). Version bumped 2.41.3 → 2.42.0 (new optional `ChartFrame.height` prop + new exports, non-breaking). **Not done in this WO**: the app-wide check for other `minHeight`-alone consumers ui_reviewer flagged (U1) — no consumer sweep is in scope per Part A's own "Deliberately NOT done"; flagging for the record, not a blocker. Publish-then-verify-then-bump-consumer-pins sequencing from the Envelope still applies before hram's own call-site cleanup. |
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
+ | 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). |
@@ -4,16 +4,17 @@ import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography, } from
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { useTheme } from '@mui/material/styles';
6
6
  import { exportChartPng, exportChartSvg } from './exportChart';
7
- import { resolveChartHeight, warnOnHeightMismatch } from './chartDefaults';
7
+ import { warnOnHeightMismatch } from './chartDefaults';
8
8
  export const CHART_FRAME_ROOT_SX = { p: 2 };
9
9
  export const CHART_FRAME_ALERT_SX = { mt: 2 };
10
10
  /**
11
11
  * Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
12
12
  *
13
- * `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
14
- * set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the content box itself.
15
- * Once `height` is set, `height` sizes the box and it never reserves more than that, even if
16
- * `minHeight` is also passed and larger.
13
+ * `minHeight` (CHART-9): always just a floor on the content box, which holds a whole card --
14
+ * title, toolbar, chart, legend, footnotes, export links -- not a single chart. `height` is never
15
+ * applied to the box (unlike the four chart presets, whose box holds exactly one chart and do use
16
+ * `resolveChartHeight`'s `height`/`aspect` resolution); it is only compared against `minHeight` for
17
+ * `warnOnHeightMismatch`'s dev-mode note.
17
18
  */
18
19
  export function ChartFrame({ title, subtitle, toolbar, controls, loading = false, error = false, isEmpty = false, emptyMessage, minHeight, height, aspect, exportOptions = false, onExportSvg, onExportPng, meta, ariaLabel, children, variant = 'outlined', titleVariant = 'h6', sx, }) {
19
20
  const { t } = useTranslation();
@@ -27,8 +28,7 @@ export function ChartFrame({ title, subtitle, toolbar, controls, loading = false
27
28
  const hasCustomAriaLabel = Boolean(ariaLabel);
28
29
  const chartLabel = ariaLabel || title;
29
30
  const callerSx = Array.isArray(sx) ? sx : [sx];
30
- useEffect(() => warnOnHeightMismatch('ChartFrame', { minHeight, height }), [minHeight, height]);
31
- const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
31
+ useEffect(() => warnOnHeightMismatch('ChartFrame', { minHeight, height }, { heightWins: false }), [minHeight, height]);
32
32
  const runExport = async (type) => {
33
33
  setExportError(false);
34
34
  try {
@@ -55,8 +55,7 @@ export function ChartFrame({ title, subtitle, toolbar, controls, loading = false
55
55
  }
56
56
  return (_jsxs(Paper, { variant: variant, sx: [CHART_FRAME_ROOT_SX, ...callerSx], children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1 }, children: [_jsxs(Box, { children: [_jsx(Typography, { id: titleId, variant: titleVariant, children: title }), subtitle && _jsx(Typography, { variant: "body2", color: "text.secondary", children: subtitle })] }), toolbar && _jsx(Box, { children: toolbar })] }), controls && _jsx(Box, { sx: { mb: 2 }, children: controls }), _jsx(Box, { ref: chartRef, role: "img", "aria-label": chartLabel, "aria-labelledby": hasCustomAriaLabel ? undefined : titleId, sx: {
57
57
  width: '100%',
58
- minHeight: wrapperMinHeight,
59
- height: chartHeight,
58
+ minHeight,
60
59
  aspectRatio: aspect,
61
60
  display: 'flex',
62
61
  alignItems: 'center',
@@ -33,12 +33,6 @@ function cssLengthToPixels(value) {
33
33
  return parsed;
34
34
  }
35
35
  const MUI_LABELLED_X_AXIS_HEIGHT = 45;
36
- // THEME-11: this used to mirror MUI's own DEFAULT_MARGINS.bottom (20) -- the "resting" bottom
37
- // margin the rotated-tick path adds its extra height on top of. Now that PACKAGE_DEFAULT_MARGIN
38
- // below sets the package's own non-rotated bottom to 8, this constant must track THAT value, not
39
- // MUI's, or a rotated chart would add its extra height to the wrong (too generous) base while
40
- // every non-rotated chart got trimmed -- an inconsistent baseline between the two paths.
41
- const MUI_CHART_MARGIN_BOTTOM = 8;
42
36
  const AVERAGE_GLYPH_WIDTH_EM = 0.6;
43
37
  // THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
44
38
  // MUI's own DEFAULT_MARGINS (20px on all four sides) -- measured live (hram's KPI cards) as 167
@@ -53,10 +47,11 @@ export const PACKAGE_DEFAULT_MARGIN = {
53
47
  top: 10, bottom: 8, left: 8, right: 16,
54
48
  };
55
49
  /**
56
- * Applies the package's own margin default, per side -- a caller's own value on any side (or
57
- * the rotated-tick path's own computed `bottom`) always wins on that side; only an UNSET side
58
- * gets the package default. A caller passing `{ left: 60 }` therefore keeps that 60 on the
59
- * left and gets the package defaults on the other three sides, never MUI's wider ones.
50
+ * Applies the package's own margin default, per side -- a caller's own value on any side always
51
+ * wins on that side; only an UNSET side gets the package default. A caller passing `{ left: 60 }`
52
+ * therefore keeps that 60 on the left and gets the package defaults on the other three sides,
53
+ * never MUI's wider ones. CHART-10: this applies identically whether or not the x-axis carries
54
+ * rotated labels -- `spaceForRotatedTicks` no longer computes a `bottom` of its own.
60
55
  */
61
56
  export function withMarginDefaults(margin) {
62
57
  const explicit = typeof margin === 'number'
@@ -97,26 +92,28 @@ function rotatedTickMetrics(axis, defaultLineHeight) {
97
92
  return { extraHeight };
98
93
  }
99
94
  /**
100
- * Rotated labels project both their line box and text width vertically.
101
- * X-Charts uses xAxis.height when deciding whether a label fits, while margin
102
- * only separates the axis from the SVG edge, so both surfaces must grow.
103
- * Explicit caller height and bottom-margin values always remain untouched.
95
+ * CHART-10: rotated labels project both their line box and text width vertically, and X-Charts
96
+ * uses `xAxis.height` -- not `margin.bottom` -- to size the band that actually CONTAINS them.
97
+ * `margin.bottom` only separates that axis band from the SVG edge; it plays no part in whether a
98
+ * label fits. Measured live (hram, 2026-08-22, real data): once `axis.height` carries the
99
+ * allowance, the rendered tick labels sit fully INSIDE the axis band with nothing below them --
100
+ * `margin.bottom` growing by the same allowance on top was reserving space nothing ever used, up
101
+ * to 130px (38% of a 340px card) on long rotated labels. Only `axis.height` grows here now;
102
+ * `margin` is returned untouched (whatever the caller passed, or didn't) so `withMarginDefaults`
103
+ * downstream applies the package's ordinary bottom default in every case, rotated or not -- a
104
+ * caller-set `margin.bottom` was never touched by this function even before this fix, and still
105
+ * isn't.
104
106
  */
105
107
  export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
106
- let largestExtraHeight = 0;
107
108
  const spacedXAxis = xAxis.map((axis) => {
108
109
  const metrics = rotatedTickMetrics(axis, defaultLineHeight);
109
110
  if (!metrics)
110
111
  return axis;
111
- largestExtraHeight = Math.max(largestExtraHeight, metrics.extraHeight);
112
112
  if (axis.height != null)
113
113
  return axis;
114
114
  return Object.assign(Object.assign({}, axis), { height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight });
115
115
  });
116
- const callerSetBottom = typeof margin === 'number' || (margin === null || margin === void 0 ? void 0 : margin.bottom) != null;
117
- const spacedMargin = largestExtraHeight > 0 && !callerSetBottom
118
- ? Object.assign(Object.assign({}, margin), { bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }) : margin;
119
- return { xAxis: spacedXAxis, margin: spacedMargin };
116
+ return { xAxis: spacedXAxis, margin };
120
117
  }
121
118
  // THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
122
119
  // 20px (label, if present) = 65px regardless of what the ticks actually say
@@ -275,16 +272,26 @@ export function resolveChartHeight({ minHeight, height, aspect }) {
275
272
  * -- the incoherent pair this WO closes. Not a throw: a shared package throwing on a prop
276
273
  * combination in production would break a consumer's page over a layout nit. Silent on the
277
274
  * equal-value majority and on the legitimate `minHeight` + `aspect` (no `height`) combination.
275
+ *
276
+ * CHART-9: `heightWins` distinguishes the two shapes this fires from. The four chart presets
277
+ * (default, `heightWins: true`) DO apply `resolveChartHeight`'s `height` to their box, so the
278
+ * original "height wins" wording is accurate there. `ChartFrame` (`heightWins: false`) never
279
+ * applies `height` to its box at all -- `minHeight` always keeps reserving the floor -- so it gets
280
+ * its own accurate wording instead of reusing the presets' story.
278
281
  */
279
- export function warnOnHeightMismatch(componentName, { minHeight, height }) {
282
+ export function warnOnHeightMismatch(componentName, { minHeight, height }, { heightWins = true } = {}) {
280
283
  if (process.env.NODE_ENV === 'production')
281
284
  return;
282
285
  if (minHeight == null || height == null || minHeight === height)
283
286
  return;
287
+ const resolution = heightWins
288
+ ? 'they disagree, so height wins and the wrapper no longer reserves the extra space below the '
289
+ + 'chart. Pass only height, or drop minHeight if it should just track height.'
290
+ : 'they disagree; height is ignored here and minHeight keeps sizing the wrapper as a floor. '
291
+ + 'Pass only minHeight, or drop height if it was meant to size the whole card.';
284
292
  // eslint-disable-next-line no-console
285
293
  console.warn(`[ui-core-micha] <${componentName}> received both minHeight={${minHeight}} and height={${height}}; `
286
- + 'they disagree, so height wins and the wrapper no longer reserves the extra space below the '
287
- + 'chart. Pass only height, or drop minHeight if it should just track height.');
294
+ + resolution);
288
295
  }
289
296
  export function withChartSlotDefaults(slotProps, legendPosition) {
290
297
  var _a;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.42.0",
3
+ "version": "2.42.2",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [
@@ -11,7 +11,7 @@ import {
11
11
  import { useTranslation } from 'react-i18next';
12
12
  import { useTheme } from '@mui/material/styles';
13
13
  import { exportChartPng, exportChartSvg } from './exportChart';
14
- import { resolveChartHeight, warnOnHeightMismatch } from './chartDefaults';
14
+ import { warnOnHeightMismatch } from './chartDefaults';
15
15
 
16
16
  export const CHART_FRAME_ROOT_SX = { p: 2 };
17
17
  export const CHART_FRAME_ALERT_SX = { mt: 2 };
@@ -19,10 +19,11 @@ export const CHART_FRAME_ALERT_SX = { mt: 2 };
19
19
  /**
20
20
  * Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
21
21
  *
22
- * `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
23
- * set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the content box itself.
24
- * Once `height` is set, `height` sizes the box and it never reserves more than that, even if
25
- * `minHeight` is also passed and larger.
22
+ * `minHeight` (CHART-9): always just a floor on the content box, which holds a whole card --
23
+ * title, toolbar, chart, legend, footnotes, export links -- not a single chart. `height` is never
24
+ * applied to the box (unlike the four chart presets, whose box holds exactly one chart and do use
25
+ * `resolveChartHeight`'s `height`/`aspect` resolution); it is only compared against `minHeight` for
26
+ * `warnOnHeightMismatch`'s dev-mode note.
26
27
  */
27
28
  export function ChartFrame({
28
29
  title,
@@ -58,8 +59,10 @@ export function ChartFrame({
58
59
  const chartLabel = ariaLabel || title;
59
60
  const callerSx = Array.isArray(sx) ? sx : [sx];
60
61
 
61
- useEffect(() => warnOnHeightMismatch('ChartFrame', { minHeight, height }), [minHeight, height]);
62
- const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
62
+ useEffect(
63
+ () => warnOnHeightMismatch('ChartFrame', { minHeight, height }, { heightWins: false }),
64
+ [minHeight, height],
65
+ );
63
66
 
64
67
  const runExport = async (type) => {
65
68
  setExportError(false);
@@ -113,8 +116,7 @@ export function ChartFrame({
113
116
  aria-labelledby={hasCustomAriaLabel ? undefined : titleId}
114
117
  sx={{
115
118
  width: '100%',
116
- minHeight: wrapperMinHeight,
117
- height: chartHeight,
119
+ minHeight,
118
120
  aspectRatio: aspect,
119
121
  display: 'flex',
120
122
  alignItems: 'center',
@@ -50,12 +50,6 @@ function cssLengthToPixels(value) {
50
50
  }
51
51
 
52
52
  const MUI_LABELLED_X_AXIS_HEIGHT = 45;
53
- // THEME-11: this used to mirror MUI's own DEFAULT_MARGINS.bottom (20) -- the "resting" bottom
54
- // margin the rotated-tick path adds its extra height on top of. Now that PACKAGE_DEFAULT_MARGIN
55
- // below sets the package's own non-rotated bottom to 8, this constant must track THAT value, not
56
- // MUI's, or a rotated chart would add its extra height to the wrong (too generous) base while
57
- // every non-rotated chart got trimmed -- an inconsistent baseline between the two paths.
58
- const MUI_CHART_MARGIN_BOTTOM = 8;
59
53
  const AVERAGE_GLYPH_WIDTH_EM = 0.6;
60
54
 
61
55
  // THEME-11: this package never formed an opinion on chart margins, so every chart fell back to
@@ -72,10 +66,11 @@ export const PACKAGE_DEFAULT_MARGIN = {
72
66
  };
73
67
 
74
68
  /**
75
- * Applies the package's own margin default, per side -- a caller's own value on any side (or
76
- * the rotated-tick path's own computed `bottom`) always wins on that side; only an UNSET side
77
- * gets the package default. A caller passing `{ left: 60 }` therefore keeps that 60 on the
78
- * left and gets the package defaults on the other three sides, never MUI's wider ones.
69
+ * Applies the package's own margin default, per side -- a caller's own value on any side always
70
+ * wins on that side; only an UNSET side gets the package default. A caller passing `{ left: 60 }`
71
+ * therefore keeps that 60 on the left and gets the package defaults on the other three sides,
72
+ * never MUI's wider ones. CHART-10: this applies identically whether or not the x-axis carries
73
+ * rotated labels -- `spaceForRotatedTicks` no longer computes a `bottom` of its own.
79
74
  */
80
75
  export function withMarginDefaults(margin) {
81
76
  const explicit = typeof margin === 'number'
@@ -117,28 +112,27 @@ function rotatedTickMetrics(axis, defaultLineHeight) {
117
112
  }
118
113
 
119
114
  /**
120
- * Rotated labels project both their line box and text width vertically.
121
- * X-Charts uses xAxis.height when deciding whether a label fits, while margin
122
- * only separates the axis from the SVG edge, so both surfaces must grow.
123
- * Explicit caller height and bottom-margin values always remain untouched.
115
+ * CHART-10: rotated labels project both their line box and text width vertically, and X-Charts
116
+ * uses `xAxis.height` -- not `margin.bottom` -- to size the band that actually CONTAINS them.
117
+ * `margin.bottom` only separates that axis band from the SVG edge; it plays no part in whether a
118
+ * label fits. Measured live (hram, 2026-08-22, real data): once `axis.height` carries the
119
+ * allowance, the rendered tick labels sit fully INSIDE the axis band with nothing below them --
120
+ * `margin.bottom` growing by the same allowance on top was reserving space nothing ever used, up
121
+ * to 130px (38% of a 340px card) on long rotated labels. Only `axis.height` grows here now;
122
+ * `margin` is returned untouched (whatever the caller passed, or didn't) so `withMarginDefaults`
123
+ * downstream applies the package's ordinary bottom default in every case, rotated or not -- a
124
+ * caller-set `margin.bottom` was never touched by this function even before this fix, and still
125
+ * isn't.
124
126
  */
125
127
  export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
126
- let largestExtraHeight = 0;
127
128
  const spacedXAxis = xAxis.map((axis) => {
128
129
  const metrics = rotatedTickMetrics(axis, defaultLineHeight);
129
130
  if (!metrics) return axis;
130
-
131
- largestExtraHeight = Math.max(largestExtraHeight, metrics.extraHeight);
132
131
  if (axis.height != null) return axis;
133
132
  return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight };
134
133
  });
135
134
 
136
- const callerSetBottom = typeof margin === 'number' || margin?.bottom != null;
137
- const spacedMargin = largestExtraHeight > 0 && !callerSetBottom
138
- ? { ...margin, bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }
139
- : margin;
140
-
141
- return { xAxis: spacedXAxis, margin: spacedMargin };
135
+ return { xAxis: spacedXAxis, margin };
142
136
  }
143
137
 
144
138
  // THEME-9: MUI X-Charts' own default y-axis width is a flat 45px (ticks) +
@@ -302,15 +296,25 @@ export function resolveChartHeight({ minHeight, height, aspect }) {
302
296
  * -- the incoherent pair this WO closes. Not a throw: a shared package throwing on a prop
303
297
  * combination in production would break a consumer's page over a layout nit. Silent on the
304
298
  * equal-value majority and on the legitimate `minHeight` + `aspect` (no `height`) combination.
299
+ *
300
+ * CHART-9: `heightWins` distinguishes the two shapes this fires from. The four chart presets
301
+ * (default, `heightWins: true`) DO apply `resolveChartHeight`'s `height` to their box, so the
302
+ * original "height wins" wording is accurate there. `ChartFrame` (`heightWins: false`) never
303
+ * applies `height` to its box at all -- `minHeight` always keeps reserving the floor -- so it gets
304
+ * its own accurate wording instead of reusing the presets' story.
305
305
  */
306
- export function warnOnHeightMismatch(componentName, { minHeight, height }) {
306
+ export function warnOnHeightMismatch(componentName, { minHeight, height }, { heightWins = true } = {}) {
307
307
  if (process.env.NODE_ENV === 'production') return;
308
308
  if (minHeight == null || height == null || minHeight === height) return;
309
+ const resolution = heightWins
310
+ ? 'they disagree, so height wins and the wrapper no longer reserves the extra space below the '
311
+ + 'chart. Pass only height, or drop minHeight if it should just track height.'
312
+ : 'they disagree; height is ignored here and minHeight keeps sizing the wrapper as a floor. '
313
+ + 'Pass only minHeight, or drop height if it was meant to size the whole card.';
309
314
  // eslint-disable-next-line no-console
310
315
  console.warn(
311
316
  `[ui-core-micha] <${componentName}> received both minHeight={${minHeight}} and height={${height}}; `
312
- + 'they disagree, so height wins and the wrapper no longer reserves the extra space below the '
313
- + 'chart. Pass only height, or drop minHeight if it should just track height.',
317
+ + resolution,
314
318
  );
315
319
  }
316
320
 
@@ -212,17 +212,37 @@ describe('ChartFrame', () => {
212
212
  expect(screen.getByText('Aucune donnée disponible.')).toBeTruthy();
213
213
  });
214
214
 
215
- // CHART-8: minHeight/height/aspect resolution on ChartFrame's own content box.
216
- describe('minHeight/height resolution (CHART-8)', () => {
215
+ // CHART-9: ChartFrame's content box is a whole CARD (title, toolbar, chart, legend, footnotes,
216
+ // export links), not a single chart -- unlike the four chart presets, which do apply
217
+ // resolveChartHeight's height/aspect resolution to their own box. ChartFrame's box must never
218
+ // take a fixed `height`; `minHeight` is always just a floor content can exceed.
219
+ describe('minHeight/height resolution (CHART-9)', () => {
217
220
  function contentBoxStyle() {
218
221
  return window.getComputedStyle(screen.getByTestId('chart-body').parentElement);
219
222
  }
220
223
 
221
- it('sizes the content box from minHeight alone when neither height nor aspect is set', () => {
224
+ it('sizes the content box from minHeight alone as a floor, never a fixed height', () => {
222
225
  renderFrame({ minHeight: 300 });
223
226
  const style = contentBoxStyle();
224
227
  expect(style.minHeight).toBe('300px');
225
- expect(style.height).toBe('300px');
228
+ expect(style.height).toBe('auto');
229
+ });
230
+
231
+ it('does not receive a fixed height when content exceeds minHeight -- the regression itself', () => {
232
+ const i18n = i18next.createInstance();
233
+ i18n.init({ lng: 'en', resources, interpolation: { escapeValue: false } });
234
+ render(
235
+ <ThemeProvider theme={createTheme()}>
236
+ <I18nextProvider i18n={i18n}>
237
+ <ChartFrame title="Chart title" minHeight={100}>
238
+ <svg data-testid="chart-body" style={{ height: 500 }} />
239
+ </ChartFrame>
240
+ </I18nextProvider>
241
+ </ThemeProvider>,
242
+ );
243
+ const style = contentBoxStyle();
244
+ expect(style.minHeight).toBe('100px');
245
+ expect(style.height).toBe('auto');
226
246
  });
227
247
 
228
248
  it('leaves minHeight as a floor and gives no fixed height when aspect is set (no height)', () => {
@@ -233,25 +253,29 @@ describe('ChartFrame', () => {
233
253
  expect(style.aspectRatio).toBe('1.8 / 1');
234
254
  });
235
255
 
236
- it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
237
- renderFrame({ minHeight: 420, height: 380 });
256
+ it('does not apply height as a fixed box height even when passed explicitly, and does not throw', () => {
257
+ expect(() => renderFrame({ minHeight: 420, height: 380 })).not.toThrow();
238
258
  const style = contentBoxStyle();
239
- expect(style.minHeight).toBe('380px');
240
- expect(style.height).toBe('380px');
241
- });
242
-
243
- it('is byte-identical when minHeight equals height', () => {
244
- renderFrame({ minHeight: 320, height: 320 });
245
- const style = contentBoxStyle();
246
- expect(style.minHeight).toBe('320px');
247
- expect(style.height).toBe('320px');
259
+ expect(style.minHeight).toBe('420px');
260
+ expect(style.height).toBe('auto');
248
261
  });
249
262
 
250
- it('is unchanged when height is passed alone', () => {
263
+ it('ignores height when passed alone, with no minHeight floor either', () => {
251
264
  renderFrame({ height: 280 });
252
265
  const style = contentBoxStyle();
253
266
  expect(style.minHeight).toBe('auto');
254
- expect(style.height).toBe('280px');
267
+ expect(style.height).toBe('auto');
268
+ });
269
+
270
+ it('warns with wording accurate to its own behaviour -- minHeight wins, not height', () => {
271
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
272
+ renderFrame({ minHeight: 420, height: 380 });
273
+ expect(warnSpy).toHaveBeenCalledOnce();
274
+ const message = warnSpy.mock.calls[0][0];
275
+ expect(message).toContain('ChartFrame');
276
+ expect(message).toContain('height is ignored');
277
+ expect(message).not.toContain('height wins');
278
+ warnSpy.mockRestore();
255
279
  });
256
280
  });
257
281
  });
@@ -19,6 +19,7 @@ import {
19
19
  PACKAGE_DEFAULT_MARGIN,
20
20
  resolveChartHeight,
21
21
  sizeYAxisForContent,
22
+ spaceForRotatedTicks,
22
23
  warnOnHeightMismatch,
23
24
  withAxisDefaults,
24
25
  withMarginDefaults,
@@ -162,10 +163,10 @@ describe('chart chrome defaults', () => {
162
163
  expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
163
164
  });
164
165
 
165
- it('adds a font-and-angle-derived EXTRA bottom margin, on top of the package default, only for rotated tick labels', () => {
166
+ it('CHART-10: grows only xAxis.height for rotated tick labels, never margin.bottom on top of it', () => {
166
167
  renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: -45 } }] });
167
168
  const rotatedProps = barSpy.mock.calls.at(-1)[0];
168
- expect(rotatedProps.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
169
+ expect(rotatedProps.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
169
170
  expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
170
171
 
171
172
  cleanup();
@@ -181,13 +182,14 @@ describe('chart chrome defaults', () => {
181
182
  expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
182
183
  });
183
184
 
184
- // Trap 1 named in the WO: a package-level default injected BEFORE spaceForRotatedTicks'
185
- // own `margin?.bottom != null` check would make every rotated-tick chart think the caller
186
- // set bottom, and lose its extra rotation space entirely.
187
- it('still gives a rotated-tick chart its enlarged bottom margin, not the flat package default', () => {
185
+ // CHART-10: the axis band, not the margin, carries the rotated-tick allowance -- a
186
+ // rotated-tick chart gets the SAME flat package bottom margin as any other chart. Named
187
+ // "still gives" in CHART-8/9 history; the correct behaviour is now the opposite of that name.
188
+ it('gives a rotated-tick chart the same flat package bottom margin as an unrotated one', () => {
188
189
  renderBar({ xAxis: [{ data: ['January', 'February'], tickLabelStyle: { angle: -90 } }] });
189
190
  const props = barSpy.mock.calls.at(-1)[0];
190
- expect(props.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom + 10);
191
+ expect(props.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
192
+ expect(props.xAxis[0].height).toBeGreaterThan(45);
191
193
  });
192
194
 
193
195
  // Scope 1's merge semantics: a caller-supplied PARTIAL margin keeps its own value on the
@@ -436,6 +438,48 @@ describe('withMarginDefaults (THEME-11)', () => {
436
438
  });
437
439
  });
438
440
 
441
+ describe('spaceForRotatedTicks (CHART-10)', () => {
442
+ const rotatedAxis = [{ data: ['January', 'February'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
443
+
444
+ it('reserves the allowance once, on axis.height, and leaves margin untouched', () => {
445
+ const result = spaceForRotatedTicks(rotatedAxis, undefined);
446
+ expect(result.xAxis[0].height).toBeGreaterThan(45);
447
+ expect(result.margin).toBeUndefined();
448
+ });
449
+
450
+ it('is byte-identical at angle 0 -- each axis entry and the margin pass through untouched', () => {
451
+ const flatAxis = [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }];
452
+ const margin = { top: 1 };
453
+ const result = spaceForRotatedTicks(flatAxis, margin);
454
+ expect(result.xAxis[0]).toBe(flatAxis[0]);
455
+ expect(result.margin).toBe(margin);
456
+ });
457
+
458
+ it('preserves a caller-set margin.bottom untouched -- the allowance is never added on top of it', () => {
459
+ const margin = { bottom: 50 };
460
+ const result = spaceForRotatedTicks(rotatedAxis, margin);
461
+ expect(result.margin).toBe(margin);
462
+ expect(result.margin.bottom).toBe(50);
463
+ });
464
+
465
+ it('drives the height estimate from the longest formatted tick, not a flat constant', () => {
466
+ const shortLabels = [{ data: ['A', 'B'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
467
+ const longLabels = [{
468
+ data: ['A much longer ward name than the others', 'B'],
469
+ tickLabelStyle: { angle: -45, fontSize: 12 },
470
+ }];
471
+ const shortResult = spaceForRotatedTicks(shortLabels, undefined);
472
+ const longResult = spaceForRotatedTicks(longLabels, undefined);
473
+ expect(longResult.xAxis[0].height).toBeGreaterThan(shortResult.xAxis[0].height);
474
+ });
475
+
476
+ it('does not override an axis that already sets its own height', () => {
477
+ const axis = [{ data: ['January'], tickLabelStyle: { angle: -45 }, height: 200 }];
478
+ const result = spaceForRotatedTicks(axis, undefined);
479
+ expect(result.xAxis[0].height).toBe(200);
480
+ });
481
+ });
482
+
439
483
  // CHART-8 -- pure-function contract of the minHeight/height/aspect resolver, independent of any
440
484
  // chart component wiring (component-level wiring is covered per-preset in each own test file).
441
485
  describe('resolveChartHeight (CHART-8)', () => {
@@ -505,4 +549,14 @@ describe('warnOnHeightMismatch (CHART-8)', () => {
505
549
  warnOnHeightMismatch('BarChart', { minHeight: undefined, height: 280 });
506
550
  expect(warn).not.toHaveBeenCalled();
507
551
  });
552
+
553
+ it('gives ChartFrame-accurate wording when heightWins is false (CHART-9)', () => {
554
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
555
+ warnOnHeightMismatch('ChartFrame', { minHeight: 420, height: 380 }, { heightWins: false });
556
+ expect(warn).toHaveBeenCalledOnce();
557
+ const message = warn.mock.calls[0][0];
558
+ expect(message).toContain('ChartFrame');
559
+ expect(message).toContain('height is ignored');
560
+ expect(message).not.toContain('height wins');
561
+ });
508
562
  });
@@ -0,0 +1,150 @@
1
+ # UCM-CHART-10 — The rotated-tick allowance is reserved twice, and the surplus is dead space
2
+
3
+ - **Repo:** `ui-core-micha`, branch `main` (shared-core; publishes from `main`, no staging step)
4
+ - **Tier:** 3 — shared-core; every consumer's chart proportions change.
5
+ - **Status:** planned
6
+ - **Workstream:** `CHART-*`
7
+ - **Files:** `src/components/charts/chartDefaults.js` (`spaceForRotatedTicks`, `rotatedTickMetrics`)
8
+ and its tests. The presets only *call* the helper — do not change them.
9
+
10
+ > **Why — measured in hram, 2026-08-22, with ucm 2.42.1 loaded and real data.** `spaceForRotatedTicks`
11
+ > adds the same `extraHeight` in **two** places:
12
+ >
13
+ > ```js
14
+ > return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight }; // (1) the axis band
15
+ > ...
16
+ > ? { ...margin, bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight } // (2) the chart margin
17
+ > ```
18
+ >
19
+ > **In MUI v8 the axis `height` and the chart `margin.bottom` are additive.** The allowance for
20
+ > rotated tick labels is therefore counted twice, the axis band already holds the labels, and the
21
+ > margin copy is left over as an empty band below the plot.
22
+ >
23
+ > Measured on the Research → Allocation drill-down at 1280 px:
24
+ >
25
+ > | card | surface height | plot ends at | empty below the axis | x tick labels |
26
+ > |---|---|---|---|---|
27
+ > | Ward Metrics | 340 | 131 | **130 px (38 %)** | 35 |
28
+ > | Access Ladder | 340 | 131 | **130 px (38 %)** | 35 |
29
+ > | Cost | 340 | 194 | 92 px | 35 |
30
+ > | Division Metrics | 340 | 199 | 85 px | 5 |
31
+ > | Cost-effectiveness | 420 | 331 | 44 px | 22 |
32
+ > | Expert Mode: Simulation Trajectory | 340 | 287 | 8 px | 31 |
33
+ >
34
+ > Two things make this conclusive rather than suggestive. First, the empty band **scales with the
35
+ > label load** — 35 long rotated ward names cost 130 px, short labels cost 8 px. Second,
36
+ > `emptyBelowLowestLabel` equals `emptyBelowAxisLine` on **every** chart measured: the tick labels sit
37
+ > inside the axis band, and the space below them is occupied by nothing at all.
38
+ >
39
+ > **This is the same failure class as hram's tornado** (`RES-28`), where `margin.left: 220` was
40
+ > reserved *in addition to* an unsized `yAxis`, and as `UCM-CHART-8`'s `minHeight`/`height` pair:
41
+ > **space reserved in two places that add up.** Third instance in this file. Neither `UCM-CHART-8`
42
+ > nor `UCM-CHART-9` could have caught it — both worked on the wrapper box; this one is in the axis
43
+ > margin arithmetic.
44
+
45
+ ---
46
+
47
+ ## Part A — Envelope (authoritative WHAT/WHY)
48
+
49
+ ### Goal
50
+
51
+ The rotated-tick allowance is reserved **once**. A chart with long rotated x labels uses its surface
52
+ for the plot, not for an empty band under the axis.
53
+
54
+ ### Definition of Done
55
+
56
+ - [x] **`extraHeight` is applied once, not twice.** Kept on `axis.height` only; `margin.bottom`'s
57
+ addition removed. MUI needs `axis.height` — the live measurement below shows rendered labels
58
+ sit fully inside the axis band with nothing below them, i.e. the band already fully
59
+ accommodates the rotation; `margin.bottom`'s copy was reserving space nothing used.
60
+ - [x] **The plot area grows by the reclaimed amount.** Measured live on hram (`CLI-Kilombero_2026-per_cell-network`,
61
+ real data, 1280 px), same three cards, same candidate, before vs. after swapping in the fixed
62
+ build: Ward Metrics (35 labels, 340 px) 129.6 px → 51.6 px empty; Access Ladder (35 labels,
63
+ 369 px) 156.9 px → 80.6 px; Division Metrics (5 labels, 340 px) 85.2 px → 41.2 px — each
64
+ roughly halved, matching "remove exactly one of two equal additive reservations." The residual
65
+ is the legitimate rotation-geometry padding this WO's non-goals say not to chase.
66
+ - [x] **Tick labels are not clipped.** Checked at both 1280 px and 375 px on the same three live
67
+ cards (lowest label's rendered bottom never exceeded the SVG's own bottom edge at either
68
+ width) — no clipping introduced by reserving less.
69
+ - [x] **Charts with no rotation are byte-identical.** Unit-tested directly against
70
+ `spaceForRotatedTicks` (angle-0 axis entry and margin object both pass through by reference,
71
+ not just by value) — see `tests/chartDefaults.test.js`, `spaceForRotatedTicks (CHART-10)`.
72
+ - [x] **A caller-set `margin.bottom` still wins.** Implemented as an unconditional guarantee rather
73
+ than the named `callerSetBottom` escape hatch: `spaceForRotatedTicks` no longer writes to
74
+ `margin` in any branch, so a caller-set value is never at risk regardless of rotation — a
75
+ strict superset of the old conditional guard, confirmed by both independent reviewers as a
76
+ clean simplification, not a behaviour gap. The named variable/constant it used
77
+ (`callerSetBottom`, `MUI_CHART_MARGIN_BOTTOM`) were removed as dead code once nothing reads
78
+ them.
79
+
80
+ ### Investigate in the same pass — likely the same helper
81
+
82
+ hram reports **x-axis tick marks rendering without their labels** on scatter charts
83
+ (`AccessGapScatterPanel`, the optimization cost-effectiveness scatter) — `HRAM-RES-29` item 2, still
84
+ open. A formatter exists on both axes (`ScatterChart.jsx:193,199`), so it is not a missing formatter.
85
+ **The suspicion is that it is this same function in the other direction:** `axis.height` set to a
86
+ band the labels do not fit, so MUI draws the ticks and clips the text.
87
+
88
+ - [x] **Refuted.** `ScatterChart.jsx` never calls `spaceForRotatedTicks` at all (grep-confirmed:
89
+ only `BarChart.jsx`/`LineChart.jsx` import it) — its x-axis `height` is never touched by this
90
+ helper in either direction, so this function cannot be the cause. Confirmed further at the two
91
+ named hram call sites (`AccessGapScatterPanel.jsx:203`, `OptimizationResultsPanel.jsx:1014`):
92
+ neither sets `tickLabelStyle.angle` on its `xAxis`, so `rotatedTickMetrics` would return `null`
93
+ even if the helper were in the path. This matches hram's own `HRAM-RES-29` register row
94
+ (`WORK_ORDERS.md`), which had already independently traced and closed item 2 the same day
95
+ (2026-08-22, before this WO's own "still open" line was written): the real cause was
96
+ `ScatterChart.jsx` rendering `<MuiScatterChart>` with no explicit `height` pre-`11d362c`
97
+ (`UCM-CHART-8`, `2.41.3`), racing MUI's own collision-avoidance against the wrapper's
98
+ `aspect-ratio` CSS and clipping tick text with no missing formatter involved. `2.42.0` already
99
+ fixes it for every consumer by passing a resolved `height` explicitly; hram's `develop` already
100
+ pinned `2.42.0` (why local measurement looked clean) and only `main`/`hram.ch` (still on
101
+ `2.41.3` at the time) showed it — closing on hram's next `develop -> main` promotion. No action
102
+ needed here or in `HRAM-RES-29`; this WO's own suspicion is retired, not carried forward.
103
+
104
+ ### Non-goals
105
+
106
+ - Do not change the four presets or `ChartFrame` — they only call this helper.
107
+ - Do not revisit `UCM-CHART-8`'s `minHeight`/`height`/`aspect` resolution or `UCM-CHART-9`'s frame
108
+ fix. Both are correct and verified; this is a third, independent reservation.
109
+ - Do not "fix" this by shrinking `MUI_LABELLED_X_AXIS_HEIGHT` or `MUI_CHART_MARGIN_BOTTOM` — those
110
+ constants are not the bug, the double application is.
111
+
112
+ ### Risks
113
+
114
+ - **Every consumer's chart proportions change**, and in the visible direction: plots get taller. That
115
+ is the point, but it is a shared-core publish and the rendered check below is what proves it.
116
+ - The clipping failure mode is the mirror of this one. A fix that reserves too little trades a
117
+ cosmetic defect for an information-losing one — clipped labels are worse than empty space.
118
+ - hram's `RES-28` fixes the same class in hram's own tornado (`margin.left` vs `yAxis.width`). The
119
+ two are independent; do not assume one closes the other.
120
+
121
+ ### Tests to WRITE — narrow
122
+
123
+ - `spaceForRotatedTicks` with a rotated axis: the total reserved space equals the allowance **once**,
124
+ asserted against the computed `axis.height` + `margin.bottom` sum — the assertion that fails today.
125
+ - Angle 0: output identical to input, axis and margin untouched.
126
+ - A caller-set `margin.bottom`: preserved, allowance not added on top.
127
+ - The longest-label path (`longestFormattedTick`) still drives the estimate.
128
+
129
+ **Rendered check before publish — numeric, on hram with real data.** The same six cards measured
130
+ above, at 1280 px and 375 px: the empty band below the axis, per card, before and after. Named in
131
+ the register note. If capture is unavailable, DOM measurement is acceptable **and must be declared
132
+ as a substitution** — for this WO that is nearly sufficient anyway, since the acceptance criterion
133
+ is a measured pixel count rather than an impression.
134
+
135
+ ---
136
+
137
+ ## Part B — Implementation map
138
+
139
+ > **TO BE FILLED BY THE ORCHESTRATOR** on `git pull`. Must carry `spaceForRotatedTicks` and
140
+ > `rotatedTickMetrics` with line anchors, the two constants, the callers, the absolute working
141
+ > directory, the progress contract, and the preamble. **Do not dispatch while this placeholder
142
+ > stands.**
143
+
144
+ ---
145
+
146
+ ## Part C — Orchestrator-only
147
+
148
+ > **TO BE FILLED BY THE ORCHESTRATOR.** Review routing (`reviewer` + `ui_reviewer`, full context —
149
+ > Tier 3 shared-core), the numeric rendered check as a hard commit gate, register maintenance,
150
+ > registry publish verification, commit, and the execution directive with its self-address guard.
@@ -0,0 +1,265 @@
1
+ # UCM-CHART-9 — `ChartFrame` is a card, not a chart: it must never take a fixed height
2
+
3
+ - **Repo:** `ui-core-micha`, branch `main` (shared-core; publishes from `main`, no staging step)
4
+ - **Tier:** 3 — shared-core, and this is a **live layout regression in a published version**.
5
+ - **Status:** planned
6
+ - **Workstream:** `CHART-*`
7
+ - **Files:** `src/components/charts/ChartFrame.jsx` and its tests. `chartDefaults.js` only if the
8
+ resolver needs a second shape — see below; the preset behaviour is correct and must not change.
9
+
10
+ > **Why — regression, observed on staging after hram bumped to 2.42.0.** `UCM-CHART-8` wired
11
+ > `resolveChartHeight()` through the four chart presets **and through `ChartFrame`**. For the
12
+ > presets that is right. For `ChartFrame` it is not, and the difference is what the box contains.
13
+ >
14
+ > In the `minHeight` alone / no `height` / no `aspect` branch, `resolveChartHeight` returns
15
+ > `chartHeight = minHeight`, and `ChartFrame` now applies it:
16
+ >
17
+ > ```diff
18
+ > - minHeight,
19
+ > + minHeight: wrapperMinHeight,
20
+ > + height: chartHeight,
21
+ > ```
22
+ >
23
+ > **A chart preset's box contains exactly one chart. `ChartFrame`'s box contains a whole card** —
24
+ > title, toolbar, the chart, a legend, footnotes, export links. Turning its `minHeight` floor into a
25
+ > fixed `height` makes the box shorter than its content, the content overflows, and **adjacent cards
26
+ > visibly overlap**: the operator's screenshot shows a card title rendered over the chart of the card
27
+ > above it and three sets of "Export SVG / Export PNG" stacked at different offsets.
28
+ >
29
+ > `UCM-CHART-8`'s own Files note recorded that `ChartFrame` "never destructures `height`" — and the
30
+ > implementation then gave it one. The rule was written for presets and applied to a frame.
31
+
32
+ ---
33
+
34
+ ## Part A — Envelope (authoritative WHAT/WHY)
35
+
36
+ ### Goal
37
+
38
+ `ChartFrame` stops setting a fixed `height` on its content box. Its `minHeight` goes back to meaning
39
+ what its name says — a floor that content can exceed. The four chart presets keep `UCM-CHART-8`'s
40
+ three-way resolution unchanged.
41
+
42
+ ### Definition of Done
43
+
44
+ - [ ] **`ChartFrame` never applies `chartHeight` to its content box.** Its box takes `minHeight` as a
45
+ floor, as it did before 2.42.0. Whether that is done by not calling `resolveChartHeight` at
46
+ all, or by giving the resolver an explicit frame shape, is the implementer's call — but
47
+ `ChartFrame` must not end up with a fixed `height` in any branch.
48
+ - [ ] **A `ChartFrame` whose content exceeds `minHeight` grows.** This is the regression, stated as
49
+ the acceptance criterion: content taller than the floor must extend the box, never overflow it.
50
+ - [ ] **The four presets are untouched.** `ScatterChart`, `BarChart`, `LineChart`, `TimeSeriesChart`
51
+ keep `UCM-CHART-8` exactly as it landed — including the `aspect` branch that four live call
52
+ sites depend on. This WO narrows one consumer of the resolver; it does not revisit the rule.
53
+ - [ ] **`ChartFrame`'s JSDoc is corrected.** 2.42.0 added a paragraph claiming `minHeight`
54
+ "sizes the content box itself" when there is no `height`/`aspect`. That sentence is the bug in
55
+ prose form and must go.
56
+ - [ ] **`warnOnHeightMismatch` stays** on `ChartFrame` — a caller passing both is still worth a
57
+ dev-mode note, even though the frame no longer acts on `height`.
58
+
59
+ ### Non-goals
60
+
61
+ - Do **not** revert `UCM-CHART-8`. Its preset behaviour is correct and is the fix hram needs; only
62
+ the frame's participation was wrong.
63
+ - Do not change `resolveChartHeight`'s three-way rule for presets.
64
+ - No consumer-side change in this WO.
65
+
66
+ ### Risks
67
+
68
+ - **`ChartFrame` is used by every Research card in hram and by other consumers.** The change restores
69
+ pre-2.42.0 behaviour for it, so the risk is low in direction — but it is still a shared-core
70
+ publish, and the rendered check below is what proves it.
71
+ - 2.42.0 is `latest` on the registry and carries this regression. Publish 2.42.1 promptly; note in
72
+ the register that 2.42.0 should not be adopted by a new consumer.
73
+ - **hram's Detailed Results tab is fixed as of 2.42.0** (operator-confirmed, 2026-08-21) — it
74
+ crashed with React #185 before the bump (`HRAM-RES-29` item 1) and does not any more. What is
75
+ **not** established is *why*: the plausible mechanism is that deterministic sizing removed a
76
+ resize-driven `setState` loop, but nobody has traced it.
77
+ **Consequence for this WO, and the only reason it is mentioned here:** since we do not know what
78
+ fixed it, we cannot know whether moving `ChartFrame` back toward a floor disturbs it. Re-check the
79
+ tab after this lands. If the crash returns, that is a finding for `HRAM-RES-29` and a sizing
80
+ dependency worth understanding — not a reason to undo this fix.
81
+
82
+ ### Tests to WRITE — narrow
83
+
84
+ - **The regression test:** a `ChartFrame` with `minHeight` and no `height`/`aspect`, containing
85
+ content taller than `minHeight`, does **not** receive a fixed `height` on its content box.
86
+ - `ChartFrame` with `height` explicitly passed: `height` is still not applied as a fixed box height
87
+ (the frame is not a chart), and no exception is thrown.
88
+ - The four presets' existing `UCM-CHART-8` tests stay green **unchanged** — if one of them has to be
89
+ edited, the change has leaked out of `ChartFrame` and that is a stop-and-report.
90
+
91
+ **Rendered check before the hram pin bump — mandatory, and the reason this WO exists.** The unit
92
+ tests for 2.42.0 were 188 green and said nothing about layout, because they cannot. Before hram's pin
93
+ moves again, the affected cards are checked rendered at 375 px and 1280 px: Sensitivity, Access,
94
+ Access Ladder, Allocation, Optimization. Named in the register note, with the substitution declared
95
+ if capture is unavailable.
96
+
97
+ ---
98
+
99
+ ## Part B — Implementation map — ADDRESSED TO THE IMPLEMENTER
100
+
101
+ ### Context package
102
+
103
+ **Named files to change:**
104
+ - `src/components/charts/ChartFrame.jsx` — the fix (below).
105
+ - `tests/ChartFrame.test.jsx:215-256` — the `describe('minHeight/height resolution (CHART-8)', …)`
106
+ block. It currently **asserts the bug as correct behaviour** (e.g. `minHeight` alone →
107
+ `style.height` toBe `'300px'`). Rewrite it to assert the fixed behaviour; rename the block to
108
+ `(CHART-9)`.
109
+
110
+ **Do NOT touch (verified against the landed code):**
111
+ - `src/components/charts/chartDefaults.js` — `resolveChartHeight` (lines ~289-298) and
112
+ `warnOnHeightMismatch` (lines ~306-315) keep their exact current contract. If the fix seems to need
113
+ a change here, that means the change has leaked out of `ChartFrame` — stop and report, don't edit.
114
+ - `src/components/charts/{BarChart,LineChart,ScatterChart,TimeSeriesChart}.jsx` — all four still call
115
+ `resolveChartHeight({ minHeight, height, aspect })` for their OWN box, unchanged.
116
+ - `tests/chartDefaults.test.js:441+` (`describe('resolveChartHeight (CHART-8)', …)`) and the four
117
+ presets' own test files — must stay green **unchanged**. If one needs an edit, stop and report.
118
+
119
+ **The bug, exactly:** `ChartFrame.jsx:61-62` calls `resolveChartHeight({ minHeight, height, aspect })`
120
+ and applies the result to its content box at `:109-125`:
121
+ ```jsx
122
+ sx={{
123
+ width: '100%',
124
+ minHeight: wrapperMinHeight,
125
+ height: chartHeight,
126
+ aspectRatio: aspect,
127
+ ...
128
+ }}
129
+ ```
130
+ In the "no `height`, no `aspect`" branch, `resolveChartHeight` returns `chartHeight = minHeight` —
131
+ correct for a preset's box (which holds exactly one chart) but wrong for `ChartFrame`'s box (which
132
+ holds a whole card: title, toolbar, chart, legend, footnotes, export links). A fixed `height` there
133
+ makes the box shorter than its content, and the content overflows into the next card.
134
+
135
+ **What "restore pre-2.42.0 behaviour" means concretely** — before `UCM-CHART-8` (commit `11d362c`),
136
+ `ChartFrame` had no `height` prop at all and its content box was:
137
+ ```jsx
138
+ sx={{
139
+ width: '100%',
140
+ minHeight,
141
+ aspectRatio: aspect,
142
+ display: 'flex',
143
+ alignItems: 'center',
144
+ justifyContent: 'center',
145
+ }}
146
+ ```
147
+ i.e. `minHeight` applied raw, straight from props, never touched by any resolver; no `height` was
148
+ ever set on the box.
149
+
150
+ **The fix:** `ChartFrame`'s content box must end up equivalent to that — `minHeight` (raw, from
151
+ props) as the box's only height-related sx property besides `aspectRatio`, and nothing ever sets
152
+ `height` on the box. The `height` prop stays in `ChartFrame`'s signature (do not remove it — it is
153
+ still a documented prop and `warnOnHeightMismatch('ChartFrame', { minHeight, height })` at line 61
154
+ must keep firing on a caller passing both `minHeight` and a disagreeing `height`), it is simply no
155
+ longer used to size the box. Whether you implement this by dropping the `resolveChartHeight` call
156
+ from `ChartFrame` entirely (simplest — matches the pre-2.42.0 shape exactly) or by giving the
157
+ resolver a second, frame-specific shape in `chartDefaults.js` that never returns a `chartHeight` is
158
+ your call — but per the non-goals below, the existing `resolveChartHeight` export and its three-way
159
+ preset contract must not change.
160
+
161
+ **JSDoc fix:** `ChartFrame.jsx:19-26`'s docblock currently states: *"otherwise (no `height`, no
162
+ `aspect`) it sizes the content box itself. Once `height` is set, `height` sizes the box…"* — that
163
+ sentence describes the bug as intended behaviour and must be corrected to state that `minHeight` is
164
+ always a floor on `ChartFrame`'s box and `height` is never applied to it (only used for the
165
+ dev-mode mismatch warning).
166
+
167
+ **Regression check while you're in there:** hram's Detailed Results tab crashed with React #185
168
+ before the 2.42.0 bump and stopped crashing after it (`HRAM-RES-29` item 1, unconfirmed hypothesis:
169
+ deterministic sizing removed a resize-driven `setState` loop). This fix moves `ChartFrame` back
170
+ toward non-deterministic sizing for that one prop. You are not expected to reproduce or verify the
171
+ hram-side crash — the Orchestrator does that separately after your diff lands — but do not add any
172
+ new resize-driven effect to `ChartFrame` while touching this code.
173
+
174
+ Work from this package; do not explore broadly from scratch — open only the named files to verify.
175
+ If you must dig deeper, delegate to a read-only Explore sub-agent (Haiku).
176
+
177
+ ### Target repo working directory (absolute)
178
+
179
+ `C:\Users\Micha Bigler\Documents\webapps\ui-core-micha`
180
+
181
+ ### Preamble — binding, read before doing anything else
182
+
183
+ The text in this file is the COMPLETE spec — the committed WO content, not a plan to refine; there
184
+ is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`,
185
+ and this repo's own `MEMORY.md` ONLY for conventions. Stay in scope; do not touch
186
+ auth/permissions/deps/schema/CI unless this spec says so; do not update `MEMORY.md`. **Do NOT edit
187
+ `WORK_ORDERS.md`** — the register row and the review verdicts are the orchestrator's alone. Do NOT
188
+ `git add`/`commit`/`push` — leave every change uncommitted in the working tree for the orchestrator's
189
+ independent review. WRITE the tests the "Required tests to WRITE" section (Part A) calls for AND
190
+ **RUN the tests you just wrote** to confirm they execute and pass — that is the ONLY test run you do
191
+ (NOT the app's affected/full suite, NOT any review). The orchestrator re-runs the authoritative set
192
+ and does the independent review after you finish — those are the gate; your own run does not count
193
+ as the gate.
194
+
195
+ Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
196
+ `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
197
+ completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
198
+ `RESULT: DONE|BLOCKED <reason>`.
199
+
200
+ ---
201
+
202
+ ## Part C — Orchestrator only — NOT ADDRESSED TO THE IMPLEMENTER
203
+
204
+ > **If you are the implementer reading this work order as your own specification: STOP at this
205
+ > line.** Everything below describes what the Orchestrator does AFTER you finish. You do none of it
206
+ > — no reviewers, no verification run, no register edit, no commit. You ARE the invocation described
207
+ > below; do NOT shell out to `codex exec`.
208
+
209
+ ### Execution directive
210
+
211
+ Implement through `codex exec` in the background — invoked directly via Bash (never the
212
+ `debugger`/`*_coder` Agent wrappers) with BOTH flags `--skip-git-repo-check` and
213
+ `--dangerously-bypass-approvals-and-sandbox`, `cwd` = the working directory above (never
214
+ `…\webapps`). Fallback to direct Claude implementation only on Codex quota/rate-limit/non-zero exit —
215
+ check `.claude/codex-status.md` first per its own rule (no line for 2026-08-22 → use Codex). The
216
+ fallback flips authorship, making an independent `reviewer` mandatory even if it already was.
217
+
218
+ ### Review routing (Tier 3, shared-core)
219
+
220
+ - `reviewer`: **yes**, full context, Sonnet-pinned, background.
221
+ - `ui_reviewer`: **yes** (diff touches frontend), full context, background, same batch as `reviewer`.
222
+ - `sec_reviewer`: not required — no auth/security surface in this diff.
223
+ - Both run concurrently; neither is skippable; commit waits for both clean.
224
+
225
+ ### Verification (the authoritative run — Codex's own run is not the gate)
226
+
227
+ 1. **Test gate:** `npx vitest run tests/ChartFrame.test.jsx tests/chartDefaults.test.js
228
+ tests/BarChart.test.jsx tests/LineChart.test.jsx tests/ScatterChart.test.jsx
229
+ tests/TimeSeriesChart.test.jsx` (adjust filenames to whatever actually exists — the affected-area
230
+ set per AGENTS.md "Test scope": `ChartFrame` itself plus every consumer of `resolveChartHeight`).
231
+ All must pass; the four presets' pre-existing assertions must be **byte-identical**, not just
232
+ green — a changed preset assertion means the fix leaked scope.
233
+ 2. **Rendered check — this WO's own commit gate, scoped to what this repo can render.** This repo
234
+ has no prototype artifact, so the generic prototype side-by-side gate does not apply; this WO's
235
+ own Envelope names a rendered check instead, and it is honoured here at the scope this repo can
236
+ actually reach:
237
+ - Run `npm run dev` (Vite) and open the dev harness at 375 px and 1280 px. It already contains
238
+ `ChartFrame` "shape check" stories that mirror three of the five named hram cards — "Allocation
239
+ performance", "Access gap", "Optimization results" (`dev/entries.jsx`, search `shape check`).
240
+ Confirm none of them, nor any neighbouring card, show overlapping title/content/export rows at
241
+ either width.
242
+ - Additionally render (ad hoc, in the same dev session — e.g. a temporary story or by editing an
243
+ existing one's children) a `ChartFrame` with a `minHeight` smaller than its actual content, to
244
+ directly exercise the Definition of Done's acceptance criterion: the box must grow to fit the
245
+ content, not overflow it. Revert any temporary dev-harness edit before commit (or keep it only
246
+ if it's a genuine permanent addition worth keeping — your call, but say which in the Notiz).
247
+ - This repo's harness has no "Sensitivity" or "Access Ladder" story, so the **full five-card
248
+ rendered check the Envelope describes ("before hram's pin moves again") is NOT done by this WO**
249
+ — it cannot be, without linking a built `ui-core-micha` into a running hram dev server. Declare
250
+ this explicitly in the register Notiz as a carried-forward precondition on whichever WO next
251
+ bumps hram's pin past 2.42.0, not a silently dropped gate.
252
+ - Declare screenshots-vs-DOM-inspection substitution explicitly if real screenshots aren't taken.
253
+
254
+ ### Register + commit
255
+
256
+ - `WORK_ORDERS.md` row → `done`, with: the named review verdicts (`reviewer` + `ui_reviewer`, no
257
+ open findings — or the findings and their resolution), the test gate result, the rendered-check
258
+ scope actually achieved (dev harness only, full hram five-card check carried forward), and which
259
+ evidence from `.claude/codex-status.md` was used (or that no fallback was needed).
260
+ - Note in the Notiz: **publish 2.42.1 promptly after commit** (per Part A Risks — 2.42.0 is `latest`
261
+ on the registry and carries this regression) and confirm the version bump lands in the same commit
262
+ or a clearly-linked follow-up; the register row must name whichever it was.
263
+ - Commit message: single concise English subject line (e.g. `fix(ChartFrame): stop applying a fixed
264
+ height to the card box (CHART-9)`). Push to `main` (this repo publishes from `main`, no `develop`
265
+ step) once review + tests are clean.