@micha.bigler/ui-core-micha 2.42.1 → 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,4 +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. |
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). |
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.42.1",
3
+ "version": "2.42.2",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [
@@ -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) +
@@ -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)', () => {
@@ -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.