@micha.bigler/ui-core-micha 2.41.2 → 2.42.0
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 +4 -3
- package/dist/components/charts/BarChart.js +11 -4
- package/dist/components/charts/ChartFrame.js +12 -3
- package/dist/components/charts/LineChart.js +11 -4
- package/dist/components/charts/ScatterChart.js +11 -4
- package/dist/components/charts/chartDefaults.js +41 -0
- package/dist/i18n/messagingTranslations.js +116 -116
- package/package.json +1 -1
- package/src/components/charts/BarChart.jsx +14 -2
- package/src/components/charts/ChartFrame.jsx +13 -2
- package/src/components/charts/LineChart.jsx +14 -2
- package/src/components/charts/ScatterChart.jsx +14 -2
- package/src/components/charts/chartDefaults.js +43 -0
- package/src/i18n/messagingTranslations.ts +116 -116
- package/tests/BarChart.test.jsx +48 -0
- package/tests/ChartFrame.test.jsx +43 -0
- package/tests/LineChart.test.jsx +46 -1
- package/tests/ScatterChart.test.jsx +38 -1
- package/tests/chartDefaults.test.js +73 -0
- package/tests/i18nAggregate.test.js +1 -4
- package/tests/messagingMsg6.test.jsx +2 -2
- package/work-orders/I18N-3.md +68 -0
- package/work-orders/UCM-CHART-8.md +147 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -27,9 +27,9 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
27
27
|
|
|
28
28
|
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
29
29
|
|---|---|---|---|---|---|---|
|
|
30
|
-
| DX-3 | `publish.yml` tests before it builds, so `2.41.2` cannot publish | `DX-2` landed on `main` (`31675d8`, `e75192f`) and **its publish failed** — run [32029216699](https://github.com/bigler-webapps/ui-core-micha/actions/runs/32029216699), `completed/failure`. `publish.yml` runs `Run tests` **before** `Build`; `DX-2`'s new `tests/packageTreeShaking.test.js` copies the package's own `dist/` to assemble a fake installed consumer, and **`dist/` is gitignored** (`.gitignore:2`), so in a clean checkout it does not exist yet: `ENOENT ... lstat '.../dist'`, 510/511 passing with the one new test as the sole failure. The test is neither flaky nor wrong — it inspects the built artifact, which is the point; it cannot pass in the order the workflow runs. It was green locally at 511/511 only because a `dist/` from an earlier build was lying around. Full order [`work-orders/DX-3.md`](work-orders/DX-3.md) | 2026-08-17 |
|
|
30
|
+
| DX-3 | `publish.yml` tests before it builds, so `2.41.2` cannot publish | `DX-2` landed on `main` (`31675d8`, `e75192f`) and **its publish failed** — run [32029216699](https://github.com/bigler-webapps/ui-core-micha/actions/runs/32029216699), `completed/failure`. `publish.yml` runs `Run tests` **before** `Build`; `DX-2`'s new `tests/packageTreeShaking.test.js` copies the package's own `dist/` to assemble a fake installed consumer, and **`dist/` is gitignored** (`.gitignore:2`), so in a clean checkout it does not exist yet: `ENOENT ... lstat '.../dist'`, 510/511 passing with the one new test as the sole failure. The test is neither flaky nor wrong — it inspects the built artifact, which is the point; it cannot pass in the order the workflow runs. It was green locally at 511/511 only because a `dist/` from an earlier build was lying around. Full order [`work-orders/DX-3.md`](work-orders/DX-3.md) | 2026-08-17 | done | 67fe140 | Tier 3 — CI surface, independent `reviewer` mandatory for two lines; no `ui_reviewer`. Fix: move `Build` above `Run tests`, **keeping `if: steps.version_check.outputs.should_publish == 'true'` on both** — dropping one is the only way a reorder can go wrong, so the reviewer is asked to confirm it explicitly. Codex implemented (available today per `.claude/codex-status.md`, no prior fallback needed) — one-file, 4-line diff, nothing else touched. **Independent `reviewer` (Sonnet) confirmed: only file changed, both `if:` conditions retained on Build and Run-tests, order now install→Build→Run-tests→npm-bump→Publish, `tsc` (Build) emits `dist/` before `vitest run` (tests) needs it — no findings, safe to commit.** **Dispatch:** landing the fix did not re-trigger `publish.yml` (its `paths` filter excludes `.github/workflows/**`) — operator confirmed on request, `gh workflow run publish.yml --ref main` dispatched, run [32032549303](https://github.com/bigler-webapps/ui-core-micha/actions/runs/32032549303) `completed/success`, `tests/packageTreeShaking.test.js` passing (not skipped) in the run order fixed above. **Acceptance verified against the registry, not the tile: `npm view @micha.bigler/ui-core-micha version` → `2.41.2`.** `DX-2`'s deliverable is now actually consumable. **The dispatch is the part most likely to be missed:** `publish.yml`'s trigger `paths` cover `package.json`/`pnpm-lock.yaml`/`src/**`/`tsconfig.build.json` but **not `.github/workflows/**`**, so landing this fix does NOT re-trigger the publish — `2.41.2` sits unpublished until `publish.yml` is dispatched on `main`, which needs explicit operator confirmation (not on the safe-allowlist). No version bump: the artifact being published is DX-2's. **Acceptance is `npm view` returning `2.41.2`, not a green tile** — the publish status of this package has now been misreported in both directions in one session. Do NOT skip, delete or self-build the tree-shaking test; a skip would silently retire the only check that `sideEffects` actually tree-shakes. **Recorded, out of scope:** `publish.yml` is the ONLY workflow here that runs tests, and only when `should_publish` is true — so ucm's suite runs in CI *only on a version bump*, and a `src/**` change without one is never tested. Real gap for a twelve-consumer shared core, separate decision. |
|
|
31
31
|
| DX-2 | Declare `sideEffects` so consumers can tree-shake the kit | `package.json` has **no `sideEffects` field** and no `exports` map, so a bundler must assume every module can have an import side effect and cannot drop unused exports — twelve consumers ship the whole kit whatever they import. Measured, not theorised: `hram/THEME-2` switched from three named bundles to the full aggregate and the built size changed by **−11 bytes**. Full order [`work-orders/DX-2.md`](work-orders/DX-2.md) | 2026-08-12 | done | 31675d8 | Tier 3. Codex implemented (available today, no prior fallback needed). **Array form used, not blanket `false`** — the per-module audit found exactly one real import-time side effect group: font CSS. `src/theme/createAppTheme.js` and `src/theme/index.js` both used to `import '@fontsource/dm-sans/*.css'` at module scope; isolated into a new `src/theme/fonts.js`, declared in the `sideEffects` array (both `src/`/`dist/` forms) alongside `index.js`, `theme/index.js`, `theme/createAppTheme.js`, and `notifications/serviceWorker/sw.js` (a genuine `addEventListener` side effect, conservatively listed even though it isn't reachable from `src/index.js` — consumers import it directly). `uiCoreTranslations`'s aggregate-object construction relocated verbatim from `src/index.js` into `src/i18n/uiCoreTranslations.js` (no shape/value change) so the entry module carries no non-trivial logic beyond its own font import. New test `tests/packageTreeShaking.test.js` builds two throwaway consumer bundles against the real `dist/` output via Vite and asserts translations actually tree-shake. **Independent `reviewer` (Sonnet) redid the module-by-module audit from scratch** (walked theme/, auth/apiClient, notifications/realtime, messaging/, onboarding/, i18n/ for CSS imports, `document`/`window`/`self` mutation, axios interceptors, WebSocket construction, i18next `.use()/.init()`) and independently ran `pnpm run build` + the new test — **verdict: classification correct and complete, no missed side effect, safe to commit.** One non-blocking finding, addressed: **`src/index.js`'s font import is unconditional, so `@fontsource/dm-sans` CSS ships in every consumer's bundle regardless of import — it does NOT tree-shake**, and the reviewer flagged that the reported byte delta could be misread as covering the whole entry when it is translations-only. Added a one-line comment at `src/index.js:3` documenting the font/`sideEffects`-array coupling so a future edit can't decouple them silently (the exact false-negative hazard this WO exists to prevent). **Re-measured delta** (built `dist/`, minified, real bundler, supersedes both `I18N-1`'s `+28,943 B`/`+7,327 B` source-reachability figure and `THEME-2`'s `−11 bytes` no-tree-shaking baseline): auth-only **62,294 B raw / 17,653 B gzip** vs. aggregate **90,395 B raw / 25,045 B gzip** → **+28,101 B raw / +7,392 B gzip, translations only — font CSS is a separate, always-shipped, non-tree-shakeable cost not included in this number.** Written into `I18N-2`'s note below. Scoped tests: `pnpm run build` green, new test green, **full existing suite 511/511 green** (WO called for the existing suite as the scoped run, not full-estate). No rendered gate (nothing renders). Version bumped to `2.41.2` (patch by scope, per the WO). **Correction (2026-08-17): the publish failed** — `publish.yml` runs tests before build, and this WO's own new tree-shaking test needs `dist/` (gitignored), so it cannot pass on a clean checkout; registry stayed at `2.41.1`. Fix + re-publish tracked in `DX-3`. |
|
|
32
|
-
| I18N-3 | Backfill Swahili in `messagingTranslations` and restore the four-locale shape test | `messagingTranslations.ts` carries **116 keys with `de`/`fr`/`en` and zero `sw`**, while all six other bundles carry all four locales. `I18N-1` hit this writing its aggregate test, confirmed the gap as pre-existing, and the operator scoped the assertion down to `{de,fr,en}` with the backfill recorded as a follow-up — **which was never written**. Full order [`work-orders/I18N-3.md`](work-orders/I18N-3.md) | 2026-08-12 |
|
|
32
|
+
| I18N-3 | Backfill Swahili in `messagingTranslations` and restore the four-locale shape test | `messagingTranslations.ts` carries **116 keys with `de`/`fr`/`en` and zero `sw`**, while all six other bundles carry all four locales. `I18N-1` hit this writing its aggregate test, confirmed the gap as pre-existing, and the operator scoped the assertion down to `{de,fr,en}` with the backfill recorded as a follow-up — **which was never written**. Full order [`work-orders/I18N-3.md`](work-orders/I18N-3.md) | 2026-08-12 | done | 30229d6 | Tier 3 — re-tightens a deliberately loosened test, so every future bundle must then supply `sw` or fail CI. **No app is broken today**: `sw` is configured only in hram and hram does not register messaging — this closes an inconsistency and re-arms a test, which is why there is no urgency justifying unreviewed strings. Codex implemented the two named files (`messagingTranslations.ts` backfill, `i18nAggregate.test.js` restored to `{de,en,fr,sw}` + stale scope-down comment removed) and correctly **stopped rather than exceed its named 2-file scope** when it found `tests/messagingMsg6.test.jsx:103` had a pre-existing exact `toEqual` hardcoding the old 3-locale shape for `MessagingThread.SHOW_REPLIES_one`, which the additive `sw` property broke. **Orchestrator fixed that one line itself** (added the matching `sw` value to the assertion) as necessary mechanical collateral of the in-scope change, not scope creep. **Translation source: machine-translated by the implementing Codex/GPT model, NOT reviewed by a fluent Swahili speaker** — stated plainly per the WO's disclosure requirement, not implied as checked. Codex flagged its own highest-uncertainty keys: `MessagingComposer.STAGED_FILES`, `MessagingList.MUTE`/`UNMUTE`, the `MessagingReadTicks.*` receipt wording, `MessagingAttachments.*PREVIEW*`, `MessagingReactions.TOGGLE_*`, `MessagingPoll.YOU_VOTED`, `MessagingConfig.DM_POLICY`/`GROUP_ENABLED`/`EVERYONE_POSTS`. **Independent `reviewer` (Sonnet)**, explicitly scoped to structure/parity/mechanics only (cannot and did not attempt to validate Swahili quality — said so): confirmed all 116 keys have exactly `{de,en,fr,sw}` with `sw` non-empty; **0 mismatches** in a scripted byte-comparison of every `de`/`en`/`fr` value against the pre-change file (additive-only, confirmed); **0 placeholder-token mismatches** — every `{{...}}` interpolation token set in `sw` matches its `en` counterpart exactly, including multi-token keys; **terminology-reuse spot-check confirmed real, not merely claimed** — Save→"Hifadhi", Close→"Funga", and the "could not be X: {{message}}" error-phrasing pattern all matched `notifications`/`onboarding` precedent, no coined alternates found for the named glossary concepts; both test-file diffs minimal and correct. Independently ran the scoped suite: **138/138 messaging tests + 4/4 i18nAggregate tests green.** **`I18N-1`'s recorded follow-up is now closed** — see its own Notiz, updated in this commit. Version bumped 2.41.2 → 2.41.3 (patch, additive strings only, per the WO). **A push to `main` publishes** — do not treat the green tile as the acceptance criterion (see `DX-3`); verify `npm view @micha.bigler/ui-core-micha version` returns `2.41.3` after push. |
|
|
33
33
|
| THEME-12 | `ScatterChart` ignores the kit's own margin opinion | `THEME-11` gave the kit an opinion about chart margins instead of inheriting MUI's flat 20 px. **`src/components/charts/ScatterChart.jsx` does not reference `withMarginDefaults`** — verified in the source — even though `THEME-10` shipped it in the same `2.41.0`. So every `ScatterChart` consumer re-inherits exactly what `THEME-11` removed. Found 2026-08-12 while surveying the strand; `hram/FIX-15` already bridged it app-side with a local `CHART_MARGIN`, which moves the workaround into the app instead of fixing the kit — the pattern worth not repeating. | 2026-08-12 | planned | | Tier 3 (shared-core, changes rendered output for every scatter consumer). **Not yet authored** — needs a decision first: wire `ScatterChart` to `withMarginDefaults` and let hram drop `CHART_MARGIN`, or decide scatter legitimately needs different margins and document why. The second is plausible — a scatter with a rotated axis title and auto-sized y-tick width is not a bar chart — so this is a question, not a defect to patch blind. `hram/FIX-15` named the gap in its own notes; this row exists so the kit side is tracked where it belongs rather than only in a consumer's WO. |
|
|
34
34
|
| AUTH-5 | `addPublicPath` muss dynamische Routen ausdruecken koennen | `apiClient` leitet bei 401/403 hart auf `/login` um, ausser der Pfad steht auf einer Allowlist — der Mechanismus ist richtig gebaut, kann aber keine **dynamischen** Routen beschreiben: `matchesPublicPath` vergleicht mit `startsWith` (ausser `/`, das Gleichheit verlangt). Eine App mit oeffentlichen Seiten unter einem variablen Segment (`/<slug>`, `/<slug>/team`) kann ihre oeffentliche Flaeche damit nicht ausdruecken: ein konkreter Slug laesst sich eintragen, der naechste faellt wieder heraus. Folge: ein anonymer Besucher landet beim ersten 401 auf `/login` — auf einer Seite, die oeffentlich sein soll, von aussen nicht von "Seite existiert nicht" zu unterscheiden. `addPublicPath` nimmt daher zusaetzlich ein `RegExp` entgegen, geprueft ausschliesslich gegen `pathname`; die eingefrorene Builtin-Liste bleibt string-basiert und unberuehrt. | 2026-08-17 | done | ca09d6e, 2c4a5d3 | Tier 3 (shared-core). reviewer: keine P1/P2-Blocker (ein P2 zu g/y-Flag-Statefulness bei RegExp.test() behoben — lastIndex-Reset + Regressionstest ergaenzt). sec_reviewer: keine Blocker (zwei P3-Hinweise, kein Handlungsbedarf). Kein ui_reviewer noetig (keine Oberflaeche). Veroeffentlicht als 2.41.1 (patch, da Erweiterung einer bestehenden Funktion). jg-ferien PUB-7 kann den Pin jetzt von 2.37.1 auf 2.41.1 ziehen. |
|
|
35
35
|
| PRIM-1 | Promote `StatTile` and `SoftChip` into the shared kit | Both already exist **twice**, independently built, and each second build is an **inline component inside a page** rather than a file — `StatTile` in `fitness-monitor/frontend/src/pages/BodyHistoryPage.jsx:554-568` beside hram's `ResearchComponents/StatTile.jsx`, `SoftChip` in `cockpit/frontend/src/cockpit/BoardView.jsx:65-73` (the lane pill) beside hram's `SoftChip.jsx`. The promotion trigger is met on evidence, not waived. The two builds diverged in seven places; all seven decided by the operator 2026-08-12 and rendered in the prototype. Full order [`work-orders/PRIM-1.md`](work-orders/PRIM-1.md) | 2026-08-12 | done | b5fff23 | Tier 3 (shared-core). `reviewer` + `ui_reviewer` mandatory and concurrent; no `sec_reviewer`. **Prototype in scope** — [`work-orders/assets/PRIM-1-dashboard-primitives.html`](work-orders/assets/PRIM-1-dashboard-primitives.html), so the rendered 375/1280 side-by-side is a commit gate; screenshots have not been legibly obtainable here, so a substitution must be **declared**, not silent. Decisions: value `h5` 18/600 · `tabular-nums` always · `flex: 1` with a `minWidth` floor · `accent` kept (fitness-monitor cannot adopt without it) · responsive padding · `status` uses **`overline`** (cockpit independently chose uppercase + .4px tracking, so this satisfies `THEME-4` with **no** baseline change and only hram's chips shift to uppercase) · dot-or-icon and optional border via props. **Token delta list has one entry:** `caveat`'s 12/500 has no baseline variant (`caption` is 12/400) — operator resolved it as keep-the-value, so it must be **exempted in `THEME-4` with that reason stated**, not flattened to 400 and not fixed by extending the baseline here. Neither `StatTile` build has a test; the promoted tests are written from scratch. Minor bump `2.38.0` → `2.39.0` (new capability area). **The three app adoptions are separate follow-up WOs** and land no app change here. Programme strand `DS-5`. **Codex skipped, dated evidence**: `.claude/codex-status.md` records `unavailable` for 2026-08-12 (`cockpit/UI-10`, out of workspace credits, the day's one allowed probe already spent) — Orchestrator implemented directly, flipping authorship. **Independent `reviewer` (Haiku, mandatory regardless of tier per the WO): no findings.** **`ui_reviewer` (Haiku, mandatory, concurrent): two P1 findings, both fixed** — (1) `StatTile`'s padding had only `px` responsive (`{xs:1, sm:1.5}`) with `py` fixed at 1, against the decided "responsive `{xs:1, sm:1.5}`" (matching fitness-monitor's original `p` shorthand, not hram's split values) — changed to a single `p: {xs:1, sm:1.5}`; (2) the `status` variant's typography was under-tested (only `textTransform` checked) — added explicit `fontSize`/`fontWeight`/`letterSpacing` assertions against `theme.typography.overline`, and separately against the `caveat` variant's raw 12/500, both now green. Re-verified 51/51 scoped tests green after fixes, `tsc -p tsconfig.build.json --noEmit` clean. **Rendered gate: declared substitution**, per the WO's own text — the 375/1280 side-by-side screenshot capture is not legibly obtainable in this repo; computed-style/DOM inspection substituted instead, via the test suite itself (fill/border/text colour per tone verified against `alpha`/`darken` of `theme.palette[tone]`; `borderRadius` 999px status vs non-pill caveat; `fontVariantNumeric: tabular-nums`; accent border width 1px vs 2px; `overline` typography values now explicit). No visual click-through was performed. Commit `b5fff23`. |
|
|
@@ -80,7 +80,7 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
|
|
|
80
80
|
|
|
81
81
|
**Orchestrator's own verification:** scoped tests (`themeCompleteness`, `createAppTheme`, `MobileBottomNav`, `ChartFrame`) 65/65 green, `tsc -p tsconfig.build.json --noEmit` clean. Independent `reviewer` (Sonnet) + `ui_reviewer` (Sonnet) spawned concurrently against the real, complete two-chunk diff — **this was the only independent review that actually ran; no prior pass exists despite what the uncommitted Notiz briefly claimed.** Both reviewers: **no blocking code findings.** `reviewer`: two P3 notes, not fixed — the print-document masking regex only matches the first `printWindow.document.write(...)` call in a file (currently safe, only one exists; latent fragility if a second is ever added); `exportChartPng`'s new `backgroundColour` parameter has no default (low risk, not exported from `src/index.js`, single caller always supplies it — a footgun only if the function gains a second caller or goes public). `ui_reviewer`: confirmed all nine role-based mappings resolve against real `tokens.js` values and are visually reasonable; flagged the Google-icon border change (`rgba(0,0,0,0.2)` → `divider`, roughly halving visible contrast) as the single largest visual delta of the nine and worth a live look given the operator's explicit no-rendered-gate decision for this WO — not blocking, no rendered gate was added, consistent with that decision. Version `2.34.0` → `2.35.0` (minor, new exported API). No rendered/prototype gate, per the operator's explicit decision — this note is the substitute, all nine changed values listed from-and-to above. DS-18 follow-ups. |
|
|
82
82
|
| SHELL-5 | Promote the secondary-navigation shell | Promotes jg-ferien's `SectionSwitcher` (grouped sidebar + bottom drawer) into the kit as the level *below* the bottom bar, adds the trigger bar that supplies the missing affordance, and makes ucm's own `AccountPage` its first consumer by replacing its horizontal `Tabs` strip. Look and feel follow jg by operator decision. Full spec: [work-orders/SHELL-5.md](work-orders/SHELL-5.md). | 2026-08-11 | done | b283c33 | DS-11. Supersedes `SHELL-2` (now `dropped`); prototype `assets/SHELL-2-account-navigation.html` kept as the composition spec. Each jg-specific concept was made optional and `AccountPage` proves it optional: no `overviewItem` (no "Übersicht" concept), no `rememberedKey`, `APP_HEADER_HEIGHT` → `headerOffset` (default `24`), `zIndex.drawer + 3` kept as default (`SHELL-3`'s bar sits at `+2`, ordering is meaningful), and the 280px two-column grid moved from the consumer into the component. The trigger bar is the one net-new piece (jg has none) — new full-width 56px bar, first thing rendered on mobile, opens a bottom drawer. `AccountPage`'s flat permission-computed `tabs` list bucketed into four fixed groups per the prototype ("Mein Konto"/"Verwaltung"/"Hilfe"/trailing "Weitere" catch-all for ungrouped `extraTabs`), new `Account.GROUP_*` i18n keys; `?tab=` contract and all permission-gating logic byte-for-byte unchanged. New component-owned chrome i18n (`src/i18n/sectionNavTranslations.ts`, all 4 locales) resolves the trap jg's `t("SectionSwitcher.TITLE")` had — no fallback, would have rendered a raw key. **Codex left the diff uncommitted and did not touch this file, as instructed** (the third consecutive WO where the explicit warning about the prior invalid-review pattern held). **Concurrent-session note:** an unrelated `I18N-1` session was uncommitted in the same working tree at the same time (`README.md`, `tests/i18nAggregate.test.js`, an aggregate-translations addition to `src/index.js`) — separated by hunk-level staging (`git add -p`) before this commit; only `SectionNav`'s two export lines in `src/index.js` are in this commit, I18N-1's own hunks were left for its own session to land. **Orchestrator's own verification:** scoped tests green (52/52 after fixes, see below), `tsc` clean. Independent `reviewer` + `ui_reviewer` (Sonnet, both mandatory — new logic and a prototype in scope) ran concurrently. **`reviewer` found two real P2s, both fixed:** the drawer's `zIndex` was set via BOTH an exported/registered `sx` object and a separate inline `style` prop on the same element — `style` always wins, so the registered `sx` value was dead code, tested but never actually reaching the DOM; removed the redundant `SECTION_NAV_DRAWER_SX` entirely (and its now-pointless `kitSxRegistry.js` entry), kept only the working `style`-based zIndex. The "optional controlled" `open`/`onClose` API (an Orchestrator design resolution for reconciling jg's controlled drawer with the new internally-triggered one) was incomplete — no `onOpen` callback existed, so a controlled consumer's trigger click was silently a no-op; added `onOpen`, fixed, added a regression test (`AccountPage` itself is unaffected, it uses uncontrolled mode). **`ui_reviewer` found one real P2, fixed:** the mobile trigger's current-section text used `fontWeight={700}`, matching neither the theme's `subtitle1` (600) nor the prototype's `.trigger-value` (16.5px/600) — an undeclared deviation that made it visually indistinguishable from the bold group headers; removed the override. One P3 left as documented, not fixed: `headerOffset` defaults to `24` vs. the prototype's `16` — a defensible generalisation (the component no longer assumes a fixed app-header height the way jg's original did; no consumer today is sensitive to the exact value). **Rendered verification:** the Browser pane could not composite frames in this session (same recurring environment limitation as `SHELL-3`/`SHELL-4`) — DOM/computed-style substitution via the test suite's own assertions (grid columns, drawer dimensions, z-index, theme-token resolution under two themes) plus static composition comparison against the prototype by both independent reviewers, one of which (`ui_reviewer`) is exactly the review type this repo relies on for design fidelity when a real render isn't obtainable. **Follow-ups named, unchanged:** jg migrates onto this and deletes its own drawer (operator requirement, separate WO); the users table is `AUTH-4`, deliberately not here. |
|
|
83
|
-
| I18N-1 | One export, so a consumer cannot half-register the kit's strings | ucm exports its UI strings as **six separate bundles** that each app must spread into its own i18n resources by hand. Adopting a component without also adding its bundle renders **a raw key as visible UI text** — no type error, no runtime error. Ship a `uiCoreTranslations` aggregate so that cannot happen, and so a future ucm feature is carried automatically instead of requiring twelve apps to notice. Full order [work-orders/I18N-1.md](work-orders/I18N-1.md). | 2026-08-11 | done | a6d322d | Tier 3 (shared-core + new public export). **Shipped as `2.37.0`, not the planned `2.36.0`** — the concurrent `SHELL-5` release claimed `2.36.0` first; sequenced behind it (see below), no other change to scope. **Landed with seven bundles, not six** — `SHELL-5` (same day, same repo) added a seventh `sectionNavTranslations`, already picked up by the directory-enumerated ratchet before this WO's own commit, so the aggregate never had a six-bundle gap in practice. Codex implemented (`uiCoreTranslations` export + directory-ratcheted `tests/i18nAggregate.test.js` + README line); left the diff uncommitted as instructed. **Concurrent-session collision, real one:** `SHELL-5` was landing in the same working tree at the same time and touched `src/index.js` too; held this WO uncommitted until `SHELL-5`'s commit (`b283c33`) reached `main`, then re-diffed — clean, only this WO's seven-import/aggregate hunk remained. **Test #3 blocked Codex for a real reason, resolved by the operator, not routed around:** the required "every value keeps its `{de,fr,en,sw}` shape" test failed because `messagingTranslations`'s 116 keys have **no `sw` value at all**, a pre-existing gap, not something the merge caused — confirmed directly (`grep -c "'sw':" src/i18n/messagingTranslations.ts` → 0). Operator chose to scope the test down to `{de,fr,en}` for now with `sw` backfill tracked as a follow-up (comment in `tests/i18nAggregate.test.js` records this); key/value fidelity and the collision check are unaffected by the scope-down. **Bundle-size delta measured** (feeds `I18N-2`): +28,943 B raw / +7,327 B gzip for an auth-only app switching from `authTranslations` alone to the (then six-bundle) aggregate. **Independent `reviewer` + `ui_reviewer` (Sonnet, both mandatory, concurrent): no findings from either.** `reviewer` additionally confirmed the seven bundles are namespace-disjoint by prefix so spread order can't shadow a value, and that the ratchet's `fs.readdirSync` + `import.meta.glob` targets stay in sync (mismatch throws explicitly). Scoped tests green: 23/23 (`i18nAggregate`, `authTranslations`, `messagingExports`, `notificationsExports`). No rendered verification — the diff renders nothing. **`fitness-monitor/FM-14` is the still-open acceptance condition**, executed in another repo: fitness-monitor keeps rendering raw `ChartFrame.*` keys until it lands. **Two apps hit by the same mechanism, evidence not hypothesis:** (1) cockpit's `UI-3` adopted `UserMenu` without `userMenuTranslations` and shipped a header button reading the literal `UserMenu.TITLE` — **both independent static reviews were clean**, it was caught only by the rendered click-through, and only by DOM inspection at that since screenshots were unavailable; (2) **fitness-monitor is defective right now** — it imports `ChartFrame` (`BodyHistoryPage.jsx:7`, used `:295`/`:445`) but registers only `authTranslations`, so `ChartFrame.jsx:74`'s `t('ChartFrame.LOADING')` and its empty/error siblings render raw keys. It survived because those are transient edge states. **Estate survey, 2026-08-11:** twelve apps register ucm bundles — jg-ferien all six, cockpit four, hram three, **fitness-monitor one (defective)**, and eight auth-only apps that are correct solely because they have not adopted a second component yet. That is a coincidence with an expiry date, not a safety property. **The merge is safe:** 504 keys across the six bundles with **zero collisions**, verified by enumeration; every key is namespace-prefixed. **The part that makes the fix permanent is the ratchet test** — the aggregate must be asserted against the bundle list **read from `src/i18n/`**, not a hand-written list, so a future bundle that is not folded in fails. Without it the aggregate is correct today and rots exactly as the six exports did. Per-feature exports stay (removing them would break twelve apps for no gain); double-spreading during migration is harmless, which is why the zero-collision property is a test and not a comment. **Rollout is constrained by the one-repo rule** — a WO implements in one repository, so the twelve consumers cannot be migrated from here: `fitness-monitor/FM-14` is written, mandatory and blocked on this release, and **fitness-monitor renders raw keys until it lands** (the operator chose to route the fix through this rollout rather than patch it separately, so it could not be lost between two WOs; the delay is the cost and is recorded rather than left implicit). The other eleven are correct today — prevention, not repair — and ride along when each is next touched. Self-registration via `addResourceBundle` is **deliberately out of scope**: feasible (ucm components use `useTranslation()` against the consumer's own instance and ucm owns no instance), but all twelve consumers flatten into the default namespace with dotted keys, and registering at render time risks a first-paint flash of the raw key — replacing an invisible defect with a visible one. It is `I18N-2`, framed as a bundle-size optimisation rather than a correctness fix, because this WO already closes the hole. |
|
|
83
|
+
| I18N-1 | One export, so a consumer cannot half-register the kit's strings | ucm exports its UI strings as **six separate bundles** that each app must spread into its own i18n resources by hand. Adopting a component without also adding its bundle renders **a raw key as visible UI text** — no type error, no runtime error. Ship a `uiCoreTranslations` aggregate so that cannot happen, and so a future ucm feature is carried automatically instead of requiring twelve apps to notice. Full order [work-orders/I18N-1.md](work-orders/I18N-1.md). | 2026-08-11 | done | a6d322d | Tier 3 (shared-core + new public export). **Shipped as `2.37.0`, not the planned `2.36.0`** — the concurrent `SHELL-5` release claimed `2.36.0` first; sequenced behind it (see below), no other change to scope. **Landed with seven bundles, not six** — `SHELL-5` (same day, same repo) added a seventh `sectionNavTranslations`, already picked up by the directory-enumerated ratchet before this WO's own commit, so the aggregate never had a six-bundle gap in practice. Codex implemented (`uiCoreTranslations` export + directory-ratcheted `tests/i18nAggregate.test.js` + README line); left the diff uncommitted as instructed. **Concurrent-session collision, real one:** `SHELL-5` was landing in the same working tree at the same time and touched `src/index.js` too; held this WO uncommitted until `SHELL-5`'s commit (`b283c33`) reached `main`, then re-diffed — clean, only this WO's seven-import/aggregate hunk remained. **Test #3 blocked Codex for a real reason, resolved by the operator, not routed around:** the required "every value keeps its `{de,fr,en,sw}` shape" test failed because `messagingTranslations`'s 116 keys have **no `sw` value at all**, a pre-existing gap, not something the merge caused — confirmed directly (`grep -c "'sw':" src/i18n/messagingTranslations.ts` → 0). Operator chose to scope the test down to `{de,fr,en}` for now with `sw` backfill tracked as a follow-up (comment in `tests/i18nAggregate.test.js` records this); key/value fidelity and the collision check are unaffected by the scope-down. **Follow-up closed by `I18N-3` (2026-08-17):** `sw` backfilled into all 116 `messagingTranslations` keys, `REQUIRED_LOCALES` restored to `{de,en,fr,sw}`, scope-down comment removed. **Bundle-size delta measured** (feeds `I18N-2`): +28,943 B raw / +7,327 B gzip for an auth-only app switching from `authTranslations` alone to the (then six-bundle) aggregate. **Independent `reviewer` + `ui_reviewer` (Sonnet, both mandatory, concurrent): no findings from either.** `reviewer` additionally confirmed the seven bundles are namespace-disjoint by prefix so spread order can't shadow a value, and that the ratchet's `fs.readdirSync` + `import.meta.glob` targets stay in sync (mismatch throws explicitly). Scoped tests green: 23/23 (`i18nAggregate`, `authTranslations`, `messagingExports`, `notificationsExports`). No rendered verification — the diff renders nothing. **`fitness-monitor/FM-14` is the still-open acceptance condition**, executed in another repo: fitness-monitor keeps rendering raw `ChartFrame.*` keys until it lands. **Two apps hit by the same mechanism, evidence not hypothesis:** (1) cockpit's `UI-3` adopted `UserMenu` without `userMenuTranslations` and shipped a header button reading the literal `UserMenu.TITLE` — **both independent static reviews were clean**, it was caught only by the rendered click-through, and only by DOM inspection at that since screenshots were unavailable; (2) **fitness-monitor is defective right now** — it imports `ChartFrame` (`BodyHistoryPage.jsx:7`, used `:295`/`:445`) but registers only `authTranslations`, so `ChartFrame.jsx:74`'s `t('ChartFrame.LOADING')` and its empty/error siblings render raw keys. It survived because those are transient edge states. **Estate survey, 2026-08-11:** twelve apps register ucm bundles — jg-ferien all six, cockpit four, hram three, **fitness-monitor one (defective)**, and eight auth-only apps that are correct solely because they have not adopted a second component yet. That is a coincidence with an expiry date, not a safety property. **The merge is safe:** 504 keys across the six bundles with **zero collisions**, verified by enumeration; every key is namespace-prefixed. **The part that makes the fix permanent is the ratchet test** — the aggregate must be asserted against the bundle list **read from `src/i18n/`**, not a hand-written list, so a future bundle that is not folded in fails. Without it the aggregate is correct today and rots exactly as the six exports did. Per-feature exports stay (removing them would break twelve apps for no gain); double-spreading during migration is harmless, which is why the zero-collision property is a test and not a comment. **Rollout is constrained by the one-repo rule** — a WO implements in one repository, so the twelve consumers cannot be migrated from here: `fitness-monitor/FM-14` is written, mandatory and blocked on this release, and **fitness-monitor renders raw keys until it lands** (the operator chose to route the fix through this rollout rather than patch it separately, so it could not be lost between two WOs; the delay is the cost and is recorded rather than left implicit). The other eleven are correct today — prevention, not repair — and ride along when each is next touched. Self-registration via `addResourceBundle` is **deliberately out of scope**: feasible (ucm components use `useTranslation()` against the consumer's own instance and ucm owns no instance), but all twelve consumers flatten into the default namespace with dotted keys, and registering at render time risks a first-paint flash of the raw key — replacing an invisible defect with a visible one. It is `I18N-2`, framed as a bundle-size optimisation rather than a correctness fix, because this WO already closes the hole. |
|
|
84
84
|
| I18N-2 | Self-registering component strings, if the bundle cost justifies it | Have ucm components register their own strings on the consumer's i18n instance (`addResourceBundle` on the instance from `useTranslation()`) instead of relying on the consumer spreading a bundle. Would remove the last of the consumer's i18n obligations and stop auth-only apps shipping strings they never render. | 2026-08-11 | blocked | | **Deferred by operator decision 2026-08-12** (recorded as `blocked` because the register's status vocabulary has no `deferred`; the blocker is named below). **Its premise did not survive measurement.** `hram/THEME-2` switched from three named bundles to the full aggregate and the built size changed by **−11 bytes**, not the expected increase — because **this package declares no `sideEffects` field** (and no `exports` map), so a bundler must assume every module has side effects and cannot drop unused exports. An auth-only app therefore already ships all seven bundles whatever it imports. That makes this WO's stated input **unmeasurable until tree-shaking works at all**, and it relocates the actual bundle-size lever to a `package.json` change rather than a Tier-3 rework of how twelve consumers resolve strings. Do not author this until `sideEffects` is settled and the delta re-measured against a package that can actually tree-shake. Original framing, kept for the record: Tier 3 (shared-core, changes how every consumer resolves strings); **deliberately NOT a correctness WO** — `I18N-1`'s aggregate closes the defect class. Source-side the non-auth strings are ~30 KB (the five non-auth bundles are 188 keys against auth's 316), but a source figure is not the answer and a guess here would make this decision unfalsifiable. **Two known hazards, both to be resolved before authoring:** (1) all twelve consumers flatten everything into i18next's DEFAULT namespace with flat dotted keys, so the registration shape has to match exactly or key resolution changes under twelve apps at once; (2) registering during render risks a first-paint flash of the raw key — which would trade an invisible defect for a visible one, the opposite of the point. Feasibility is established: ucm components use `useTranslation()` against the consumer's instance and ucm owns no i18next instance of its own, so there is a real instance to register against. **`DX-2` (2026-08-17) re-measured the input this WO is blocked on**, against a package that can now actually tree-shake: auth-only **62,294 B raw / 17,653 B gzip** vs. aggregate **90,395 B raw / 25,045 B gzip** → **+28,101 B raw / +7,392 B gzip**, translations only. This is now real, tree-shaking-aware evidence rather than the pre-`DX-2` source-reachability figure — **but the number is unchanged in kind (still a ~7.4 KB gzip cost for a fully auth-only app), so it does not by itself flip the blocked decision.** Still unresolved before this WO can be authored: the two named hazards (flat-namespace registration shape matching exactly across twelve consumers; first-paint raw-key flash risk during render-time registration) — the size delta alone was never the only gate. |
|
|
85
85
|
| AUTH-4 | The account user list on a phone | Gives `UserListComponent` a card layout below `md` — one card per user, email as the title, the two boolean columns as labelled chips, role select and delete in a divided footer, plus a sort control that replaces the column headers the table loses. Above `md` the table is byte-identical. Prototype: [assets/AUTH-4-user-list-card.html](work-orders/assets/AUTH-4-user-list-card.html). Full spec: [work-orders/AUTH-4.md](work-orders/AUTH-4.md). | 2026-08-11 | planned | | Split out of DS-11 by operator decision; fix class chosen by operator (card list, over sticky-first-column and priority-hide). Envelope only (Approval Gate #1 pending). **Authored and parked by operator decision 2026-08-11 — not queued.** It is built on demand, not as SHELL-5's successor; a fresh session should not dispatch it without the operator saying so. When it does run, it runs **after** `SHELL-5` (same screen, different half). The point of this row is that the measurements above are already done, so nobody re-measures. Prototype in scope, so the two-width rendered gate applies. **Measured 2026-08-11 in cockpit** at `innerWidth` 411 (the mobile preset gives 411, not 375): table **1053px inside a 325px container**, container `overflow-x: auto` and **the document does not overflow** — so it is not a broken layout but a desktop table on a phone, with Role and Actions, the two things the screen exists for, entirely outside the view. **The cheap fix was tested and rejected:** 253px of the width is `New` (90) + `Successful Login` (163), both default-on props that `AccountPage` already forwards and cockpit simply left on — but the four remaining columns are 800px against 325px, so configuration cannot fix it. Column defaults deliberately NOT flipped: that would be a silent estate-wide change. SHELL-2's 1045px from 2026-08-08 still holds (+8px), so THEME-4/5 did not move the density. **Four prototype decisions are load-bearing:** email as title with a muted placeholder when the name is absent (verified: a 47-char address wraps to two lines in a 357px card without overflow); the icon-only flags become labelled chips **because a Tooltip is unreachable on a touch pointer**, so that data is effectively absent today; role and delete in a footer behind a divider with the full 44px target; and a **sort control**, because a card list has no column headers — without it the mobile view silently loses a desktop capability. `extraColumns` render as label/value rows with **no new required field**; `extraRowActions` beyond three must collapse into an overflow menu. Reached via `AccountPage`, which **at least ten apps mount**, and ucm has no staging net. |
|
|
86
86
|
| SHELL-6 | A controlled `SectionNav` with no way to open must not render a trigger | In `mode="mobile"` the built-in trigger renders unconditionally (`SectionNav.jsx:246`), while `handleTriggerClick` calls `onOpen?.()` — so a consumer driving `open` itself without `onOpen` gets a button that is visible, focusable and does nothing. Render the trigger only when a click can open the drawer: uncontrolled, or controlled **and** `onOpen` supplied. Plus the component's **render matrix** written into its doc comment. Full spec: [work-orders/SHELL-6.md](work-orders/SHELL-6.md). | 2026-08-11 | done | f119b70 | **Unblocks `jg-ferien/NAV-37`** (`blocked`, `c627d7b`), same shape as `SHELL-3`→`SHELL-4`. **Operator chose the semantics fix over a `showTrigger` prop** — a prop would leave the dead button as the default for every controlled consumer who has not heard of it, turning a defect into a configuration option; a focusable control that performs no action is wrong on its own terms. Fix: `const canOpenTrigger = !isControlled || Boolean(onOpen)`, wrapping only the `<ButtonBase>` trigger — `children` and `<Drawer>` keep rendering unconditionally in every case. Render-matrix doc comment added above the component per the WO's own framing ("not optional documentation; it is the artefact whose absence caused the defect"). `AccountPage`, the only shipped consumer, is uncontrolled and renders identically. Version `2.37.0` → `2.37.1` (patch, defect fix, no API change). **Codex left the diff uncommitted and did not touch this file, as instructed** — fourth consecutive WO where the explicit warning held. **Required test 1's non-vacuity confirmed**: Codex's own landing note states the new regression test was run against the pre-fix code and failed (dead trigger still in the DOM), then passed after the one-line fix — the Orchestrator did not re-verify this specific claim by checking out the prior commit, but both independent reviewers separately read the old-vs-new JSX and found the claim plausible/consistent. **Orchestrator's own verification:** scoped tests 47/47 green, `tsc` clean. Independent `reviewer` + `ui_reviewer` (Sonnet, both mandatory) ran concurrently — **both came back clean, no findings, no fix required.** `reviewer` confirmed the condition is keyed correctly (not on `open`'s value, avoiding the trap the WO's own test 3 exists to catch), `children`/`Drawer` stay unconditional, desktop path untouched, and the render-matrix comment matches the Envelope's table exactly. `ui_reviewer` confirmed the trigger's removal is genuine (conditional JSX unmount, not a CSS-hidden trick left in the accessibility tree/tab order) and no restyle occurred as a side effect; noted two non-blocking residual observations, not fixed: a hypothetical layout-shift if a consumer dynamically toggled `onOpen` on/off at runtime (no current or planned consumer does this), and `resolvedEyebrow`/`resolvedTitle` computed unconditionally even when unused (harmless). No rendered gate needed per the WO's own reasoning — the change is a DOM absence fact, not an appearance, and the test suite is the correct instrument. |
|
|
@@ -90,3 +90,4 @@ Two chunks. **Chunk 1** (Part 1 + Part 2 of the Envelope): extended `SPACING_SHO
|
|
|
90
90
|
| THEME-9 | Diagramme geben mehr Platz an Achsen-Mobiliar als an Daten | **In einer konsumierenden App (hram) gemessen, nicht geschätzt.** Eine KPI-Karte „Coverage“: Karte 470 × 372 → Chart-Box 436 × **300** (`minHeight:300`, das SVG darin ist 270 hoch → **30 px tot**) → SVG 436 × 270 → **Zeichenfläche 301 × 175**. Die Zeichenfläche ist damit **45 % des SVG und 30 % der Karte**; innerhalb des SVG gehen 135 px Breite und 95 px Höhe verloren. Vom Kartenrand bis zur ersten Tick-Zahl sind es 104 px, davon **60 px blanker Raum zwischen SVG-Rand und Achsentitel**; unter dem Achsentitel liegen 30 px leer im SVG plus 32 px bis zur Karte. **Drei weitere gemessene Befunde:** (1) `withAxisDefaults` endet mit `label: axis.label || label` und setzt damit EIN `yAxisLabel` auf JEDE Achse — in hrams Doppelachsen-Chart tragen beide Y-Achsen dieselbe 43-Zeichen-Zeile, je 65 px eines 206-px-Charts. (2) Der Achsentitel wird IN das Tick-Band gesetzt: auf der Accessibility-Ansicht überlappen **4 von 4** Y-Ticks den Titel, dasselbe passiert mit um 45° gedrehten Ortsnamen über dem Titel „Ward“. (3) Ticks tragen Einheit und Zier-Nachkommastellen (`0.0 min`, `50.0 min`) — dieselbe Einheit steht nochmals doppelt im Titel (`Accessibility (minutes) (min)`); anderswo kürzt MUI die Ticks zu `0.0…`. `BarChart` setzt gar keinen Standard-Formatter, `TimeSeriesChart` schon — also löst es jede App neu. Voller Auftrag [`work-orders/THEME-9.md`](work-orders/THEME-9.md) | 2026-08-14 | done (npm publish; consumer pin bumps still open) | 3fd6097 | Tier 3 (Shared-Core). Codex bereits als `unavailable` für heute vermerkt (`.claude/codex-status.md`, THEME-8) — direkt in Claude implementiert, Autorenschaft geflippt, `reviewer` + `ui_reviewer` liefen parallel (ohnehin Pflicht bei Tier 3). **`reviewer` R1 (P1, behoben):** die Serie-zu-Achse-Zuordnung in `sizeYAxisForContent` traf `undefined===undefined` statt MUI's echter Default-Regel (unmarkierte Serie → erste Achse im Array) — machte den Sizer zum stillen No-op genau auf der Primärachse des Doppelachsen-Falls, der diesen WO motiviert hat; Regressionstest ergänzt. **`ui_reviewer` U1 (P1, behoben):** `DESIGN.md` #8a versprach "keine Titel-Pflicht bei kategorialer Achse", aber `BarChart`/`LineChart` erzwangen beide Labels per `throw` — Labels jetzt optional, sonst wäre die Regel für FIX-13 unbrauchbar gewesen. **U3 (P2, behoben):** Sizer nutzte den Theme-Default-Font statt der per Achse gesetzten `tickLabelStyle.fontSize`. **U5 (P3, behoben):** Label-Dicke war an `theme.typography.body1` gekoppelt, obwohl MUI intern hart 14px setzt — jetzt eigene benannte Konstante. **U2 (P2, akzeptierter Rest):** Schätzung kennt MUI's "nice-tick"-Rundung nicht exakt; bestehender 1.1×-Sicherheitsfaktor federt kleine Abweichungen ab, der gerenderte Check ist der eigentliche Beweis. **U4:** Behauptung, `TimeSeriesChart` umgehe `withAxisDefaults`/`sizeYAxisForContent`, am Quellcode widerlegt (rendert über die eigene `<BarChart>`-Komponente). Affected-Set grün (60 Tests). **Live-Nachmessung in hram nicht möglich** — Browser-Pane liefert nur für den vom Operator angezeigten Tab reale Viewport-Masse (neue Tabs 0×0, negative-Width-SVG-Fehler als direkte Folge, kein Codefehler); die "vorher"-Zahlen der Coverage-Karte wurden LIVE aus dem korrekt angezeigten hram-Tab gemessen (siehe WO) und bestätigten exakt die im Envelope genannten 60px/104px — zurückgeführt auf hrams eigenes `margin.left=60`, das sich ADDITIV mit MUIs Achsenbreiten-Reservierung addiert (kein ucm-Fehler). Die "nachher"-Messung folgt mit FIX-13 nach dem Pin-Bump (ohnehin dort vorgesehen). Fünf Posten: Ränder/Achsenreservierung verkleinern · Achsentitel nicht mehr broadcasten (mit Test, genau der Defekt der ausgeliefert wurde) · Titel aus dem Tick-Band halten · Standard-Tick-Formatter · Entscheidungsregel in `DESIGN.md`. **Gemessene Warnung gegen naives `Intl`-Compact:** Deutsch kürzt Tausender GAR NICHT (`12'500` bleibt), Swahili stellt ein Wort voran (`elfu 12.5`, breiter als die Rohzahl) — kompakte Notation allein löst das Breitenproblem also nicht und verschlimmert es in einer Sprache; ein handgeschriebenes `B` wäre auf Deutsch zudem FALSCH (Billion = 10¹², `Intl` sagt korrekt `Mrd.`). **Regel:** kategoriale Achse → kein Titel (Ticks sind Namen); numerische Achse → Titel trägt Grösse und Einheit, ausser die Ticks zeigen die Einheit schon (`%`); Einheit einmal im Titel, nie in jedem Tick. **Ränder kürzen und Überlappung beheben ziehen gegeneinander** — zusammen machen und messen, sonst erzeugt das eine das andere neu. Nicht am Publish abschliessen: App-Pins nachziehen, hram zuerst (dortiger Nachzug = FIX-13). |
|
|
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
|
+
| 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. |
|
|
@@ -10,13 +10,13 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
10
10
|
return t;
|
|
11
11
|
};
|
|
12
12
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
13
|
-
import React from 'react';
|
|
13
|
+
import React, { useEffect } from 'react';
|
|
14
14
|
import { Box } from '@mui/material';
|
|
15
15
|
import { useTheme } from '@mui/material/styles';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
17
|
import { BarChart as MuiBarChart } from '@mui/x-charts/BarChart';
|
|
18
18
|
import { useNeutralChartPalette } from './palette';
|
|
19
|
-
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
|
|
19
|
+
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, resolveChartHeight, sizeYAxisForContent, spaceForRotatedTicks, warnOnHeightMismatch, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
|
|
20
20
|
/**
|
|
21
21
|
* Responsive MUI X-Charts bar preset. It supplies labelled axes, tooltips,
|
|
22
22
|
* and an automatic multi-series legend. `xAxisLabel`/`yAxisLabel` are
|
|
@@ -26,15 +26,22 @@ import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForConte
|
|
|
26
26
|
* the panel heading already names the quantity. Omitting one never suppresses
|
|
27
27
|
* a label the caller DID set (`withAxisDefaults` only ever falls back onto
|
|
28
28
|
* an axis without its own `label`).
|
|
29
|
+
*
|
|
30
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
31
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
32
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
33
|
+
* if `minHeight` is also passed and larger.
|
|
29
34
|
*/
|
|
30
35
|
export function BarChart(_a) {
|
|
31
|
-
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slotProps"]);
|
|
36
|
+
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, height, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "height", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slotProps"]);
|
|
32
37
|
const theme = useTheme();
|
|
33
38
|
const { i18n } = useTranslation();
|
|
34
39
|
const neutralPalette = useNeutralChartPalette();
|
|
40
|
+
useEffect(() => warnOnHeightMismatch('BarChart', { minHeight, height }), [minHeight, height]);
|
|
41
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
35
42
|
const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'band', data: [] }, theme.typography.caption.fontSize);
|
|
36
43
|
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
37
44
|
const sizedYAxis = sizeYAxisForContent(labelledYAxis, series, theme.typography.caption.fontSize);
|
|
38
45
|
const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
|
|
39
|
-
return (_jsx(Box, { "data-testid": "bar-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiBarChart, Object.assign({}, chartProps, { series: series, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: withMarginDefaults(rotatedTickSpace.margin), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
46
|
+
return (_jsx(Box, { "data-testid": "bar-chart-container", sx: { width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }, children: _jsx(MuiBarChart, Object.assign({}, chartProps, { height: chartHeight, series: series, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: withMarginDefaults(rotatedTickSpace.margin), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
40
47
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useId, useRef, useState } from 'react';
|
|
2
|
+
import React, { useEffect, useId, useRef, useState } from 'react';
|
|
3
3
|
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography, } from '@mui/material';
|
|
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
8
|
export const CHART_FRAME_ROOT_SX = { p: 2 };
|
|
8
9
|
export const CHART_FRAME_ALERT_SX = { mt: 2 };
|
|
9
10
|
/**
|
|
10
11
|
* Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
|
|
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.
|
|
11
17
|
*/
|
|
12
|
-
export function ChartFrame({ title, subtitle, toolbar, controls, loading = false, error = false, isEmpty = false, emptyMessage, minHeight, aspect, exportOptions = false, onExportSvg, onExportPng, meta, ariaLabel, children, variant = 'outlined', titleVariant = 'h6', sx, }) {
|
|
18
|
+
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, }) {
|
|
13
19
|
const { t } = useTranslation();
|
|
14
20
|
const theme = useTheme();
|
|
15
21
|
const titleId = useId();
|
|
@@ -21,6 +27,8 @@ export function ChartFrame({ title, subtitle, toolbar, controls, loading = false
|
|
|
21
27
|
const hasCustomAriaLabel = Boolean(ariaLabel);
|
|
22
28
|
const chartLabel = ariaLabel || title;
|
|
23
29
|
const callerSx = Array.isArray(sx) ? sx : [sx];
|
|
30
|
+
useEffect(() => warnOnHeightMismatch('ChartFrame', { minHeight, height }), [minHeight, height]);
|
|
31
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
24
32
|
const runExport = async (type) => {
|
|
25
33
|
setExportError(false);
|
|
26
34
|
try {
|
|
@@ -47,7 +55,8 @@ export function ChartFrame({ title, subtitle, toolbar, controls, loading = false
|
|
|
47
55
|
}
|
|
48
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: {
|
|
49
57
|
width: '100%',
|
|
50
|
-
minHeight,
|
|
58
|
+
minHeight: wrapperMinHeight,
|
|
59
|
+
height: chartHeight,
|
|
51
60
|
aspectRatio: aspect,
|
|
52
61
|
display: 'flex',
|
|
53
62
|
alignItems: 'center',
|
|
@@ -10,13 +10,13 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
10
10
|
return t;
|
|
11
11
|
};
|
|
12
12
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
13
|
-
import React from 'react';
|
|
13
|
+
import React, { useEffect } from 'react';
|
|
14
14
|
import { Box } from '@mui/material';
|
|
15
15
|
import { useTheme } from '@mui/material/styles';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
17
|
import { LineChart as MuiLineChart, MarkElement as MuiMarkElement, } from '@mui/x-charts/LineChart';
|
|
18
18
|
import { useNeutralChartPalette } from './palette';
|
|
19
|
-
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
|
|
19
|
+
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, resolveChartHeight, sizeYAxisForContent, spaceForRotatedTicks, warnOnHeightMismatch, withAxisDefaults, withChartSlotDefaults, withGridDefaults, withMarginDefaults, } from './chartDefaults';
|
|
20
20
|
// Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
|
|
21
21
|
// `styled()` default). CHART-6's WO named a `MuiMarkElement` theme-token
|
|
22
22
|
// override in src/theme/tokens.js as the preferred mechanism, with a
|
|
@@ -40,16 +40,23 @@ function FilledMarkElement(_a) {
|
|
|
40
40
|
* the quantity. Omitting one never suppresses a label the caller DID set
|
|
41
41
|
* (`withAxisDefaults` only ever falls back onto an axis without its own
|
|
42
42
|
* `label`).
|
|
43
|
+
*
|
|
44
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
45
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
46
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
47
|
+
* if `minHeight` is also passed and larger.
|
|
43
48
|
*/
|
|
44
49
|
export function LineChart(_a) {
|
|
45
|
-
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slots, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slots", "slotProps"]);
|
|
50
|
+
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, height, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, slots, slotProps } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "height", "aspect", "grid", "hideLegend", "legendPosition", "margin", "slots", "slotProps"]);
|
|
46
51
|
const theme = useTheme();
|
|
47
52
|
const { i18n } = useTranslation();
|
|
48
53
|
const neutralPalette = useNeutralChartPalette();
|
|
54
|
+
useEffect(() => warnOnHeightMismatch('LineChart', { minHeight, height }), [minHeight, height]);
|
|
55
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
49
56
|
const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'point', data: [] }, theme.typography.caption.fontSize);
|
|
50
57
|
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
51
58
|
const defaultedSeries = series.map((item) => (Object.assign({ showMark: false, labelMarkType: 'square' }, item)));
|
|
52
59
|
const sizedYAxis = sizeYAxisForContent(labelledYAxis, defaultedSeries, theme.typography.caption.fontSize);
|
|
53
60
|
const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
|
|
54
|
-
return (_jsx(Box, { "data-testid": "line-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiLineChart, Object.assign({}, chartProps, { series: defaultedSeries, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: withMarginDefaults(rotatedTickSpace.margin), slots: Object.assign({ mark: FilledMarkElement }, slots), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
61
|
+
return (_jsx(Box, { "data-testid": "line-chart-container", sx: { width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }, children: _jsx(MuiLineChart, Object.assign({}, chartProps, { height: chartHeight, series: defaultedSeries, xAxis: rotatedTickSpace.xAxis, yAxis: sizedYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: withMarginDefaults(rotatedTickSpace.margin), slots: Object.assign({ mark: FilledMarkElement }, slots), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
|
|
55
62
|
}
|
|
@@ -10,14 +10,14 @@ var __rest = (this && this.__rest) || function (s, e) {
|
|
|
10
10
|
return t;
|
|
11
11
|
};
|
|
12
12
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
13
|
-
import React, { useMemo } from 'react';
|
|
13
|
+
import React, { useEffect, useMemo } from 'react';
|
|
14
14
|
import { Box } from '@mui/material';
|
|
15
15
|
import { useTheme } from '@mui/material/styles';
|
|
16
16
|
import { useTranslation } from 'react-i18next';
|
|
17
17
|
import { ScatterChart as MuiScatterChart } from '@mui/x-charts/ScatterChart';
|
|
18
18
|
import { useXScale, useYScale } from '@mui/x-charts/hooks';
|
|
19
19
|
import { useNeutralChartPalette } from './palette';
|
|
20
|
-
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, sizeYAxisForContent, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
|
|
20
|
+
import { DEFAULT_LEGEND_POSITION, defaultNumericTickFormatter, resolveChartHeight, sizeYAxisForContent, warnOnHeightMismatch, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
|
|
21
21
|
// Small enough that hram's largest cloud (~300 points, the tested magnitude -- state it, don't
|
|
22
22
|
// imply it scales past that) doesn't fuse into a solid mass at the default (non-bubble) size.
|
|
23
23
|
const DEFAULT_MARKER_RADIUS = 5;
|
|
@@ -118,6 +118,11 @@ export function ScatterReferenceLine({ from, to, color, dashed, strokeWidth = 1.
|
|
|
118
118
|
* Reference geometry (a computed curve, a straight line, either with an optional label) is
|
|
119
119
|
* composed as `children` using `ScatterReferenceCurve`/`ScatterReferenceLine` above.
|
|
120
120
|
*
|
|
121
|
+
* `minHeight` (CHART-8): a floor under an `aspect`-derived responsive height when `aspect` is
|
|
122
|
+
* set and `height` is not; otherwise (no `height`, no `aspect`) it sizes the chart itself. Once
|
|
123
|
+
* `height` is set, `height` sizes the chart and the wrapper never reserves more than that, even
|
|
124
|
+
* if `minHeight` is also passed and larger.
|
|
125
|
+
*
|
|
121
126
|
* Tested magnitude: a few hundred marks (hram's largest cloud is ~300 points). Not verified,
|
|
122
127
|
* and not implied to hold, at thousands+.
|
|
123
128
|
*
|
|
@@ -132,10 +137,12 @@ export function ScatterReferenceLine({ from, to, color, dashed, strokeWidth = 1.
|
|
|
132
137
|
*/
|
|
133
138
|
export function ScatterChart(_a) {
|
|
134
139
|
var _b;
|
|
135
|
-
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, sizeAccessor, getPointStyle, markerSize = DEFAULT_MARKER_RADIUS, slotProps, children } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "aspect", "grid", "hideLegend", "legendPosition", "margin", "sizeAccessor", "getPointStyle", "markerSize", "slotProps", "children"]);
|
|
140
|
+
var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, height, aspect, grid, hideLegend = series.length <= 1, legendPosition = DEFAULT_LEGEND_POSITION, margin, sizeAccessor, getPointStyle, markerSize = DEFAULT_MARKER_RADIUS, slotProps, children } = _a, chartProps = __rest(_a, ["series", "xAxisLabel", "yAxisLabel", "xAxis", "yAxis", "palette", "minHeight", "height", "aspect", "grid", "hideLegend", "legendPosition", "margin", "sizeAccessor", "getPointStyle", "markerSize", "slotProps", "children"]);
|
|
136
141
|
const theme = useTheme();
|
|
137
142
|
const { i18n } = useTranslation();
|
|
138
143
|
const neutralPalette = useNeutralChartPalette();
|
|
144
|
+
useEffect(() => warnOnHeightMismatch('ScatterChart', { minHeight, height }), [minHeight, height]);
|
|
145
|
+
const { wrapperMinHeight, chartHeight } = resolveChartHeight({ minHeight, height, aspect });
|
|
139
146
|
const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
140
147
|
const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear', valueFormatter: defaultNumericTickFormatter(i18n.language) }, theme.typography.caption.fontSize);
|
|
141
148
|
// Only the Y axis is width-sized (sizeYAxisForContent reserves horizontal space for a
|
|
@@ -174,5 +181,5 @@ export function ScatterChart(_a) {
|
|
|
174
181
|
// would resolve every point's style/radius against the wrong (pre-sort) index.
|
|
175
182
|
const orderedSeriesById = useMemo(() => Object.fromEntries(orderedSeries.map((item) => [item.id, item])), [orderedSeries]);
|
|
176
183
|
const markerSlot = useMemo(() => makeMarkerSlot(orderedSeriesById, { sizeAccessor, chartMax, getPointStyle }), [orderedSeriesById, sizeAccessor, chartMax, getPointStyle]);
|
|
177
|
-
return (_jsx(Box, { "data-testid": "scatter-chart-container", sx: { width: '100%', minHeight, aspectRatio: aspect }, children: _jsx(MuiScatterChart, Object.assign({}, chartProps, { series: orderedSeries.map((item) => (Object.assign({ markerSize }, item))), xAxis: labelledXAxis, yAxis: sizedYAxis, colors: resolvedPalette, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: margin, slots: { marker: markerSlot }, slotProps: withChartSlotDefaults(slotProps, legendPosition), children: children })) }));
|
|
184
|
+
return (_jsx(Box, { "data-testid": "scatter-chart-container", sx: { width: '100%', minHeight: wrapperMinHeight, aspectRatio: aspect }, children: _jsx(MuiScatterChart, Object.assign({}, chartProps, { height: chartHeight, series: orderedSeries.map((item) => (Object.assign({ markerSize }, item))), xAxis: labelledXAxis, yAxis: sizedYAxis, colors: resolvedPalette, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: margin, slots: { marker: markerSlot }, slotProps: withChartSlotDefaults(slotProps, legendPosition), children: children })) }));
|
|
178
185
|
}
|
|
@@ -245,6 +245,47 @@ export function defaultNumericTickFormatter(locale) {
|
|
|
245
245
|
? new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value)
|
|
246
246
|
: new Intl.NumberFormat(locale).format(value));
|
|
247
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* CHART-8: resolves the `{minHeight, height, aspect}` trio these presets accept into what the
|
|
250
|
+
* wrapper reserves and what height (if any) sizes the chart itself. Three-way, not two-way --
|
|
251
|
+
* `aspect` changes the answer:
|
|
252
|
+
* - `height` set -> it sizes the chart; the wrapper never reserves more than that (a caller-set
|
|
253
|
+
* `minHeight` larger than `height` is capped here, closing the dead-space gap this WO exists
|
|
254
|
+
* to fix -- equal values, or no `minHeight` at all, pass through unchanged).
|
|
255
|
+
* - no `height`, `aspect` set -> unchanged from before this WO: the wrapper's own `aspectRatio`
|
|
256
|
+
* derives its height from its width, and `minHeight` stays exactly what its name says, a floor
|
|
257
|
+
* stopping it collapsing on a narrow viewport. Do not give the chart a fixed height here --
|
|
258
|
+
* that would remove the responsive behaviour four live call sites rely on.
|
|
259
|
+
* - no `height`, no `aspect` -> `minHeight` sizes the chart itself. There is no other sizing
|
|
260
|
+
* information to go on, and a caller in this shape meant the chart to have that height, not
|
|
261
|
+
* merely a wrapper floor around an unsized chart.
|
|
262
|
+
*/
|
|
263
|
+
export function resolveChartHeight({ minHeight, height, aspect }) {
|
|
264
|
+
if (height != null) {
|
|
265
|
+
const wrapperMinHeight = minHeight != null && minHeight > height ? height : minHeight;
|
|
266
|
+
return { wrapperMinHeight, chartHeight: height };
|
|
267
|
+
}
|
|
268
|
+
if (aspect != null) {
|
|
269
|
+
return { wrapperMinHeight: minHeight, chartHeight: undefined };
|
|
270
|
+
}
|
|
271
|
+
return { wrapperMinHeight: minHeight, chartHeight: minHeight };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* CHART-8: dev-only warning when a caller passes both `minHeight` and `height` and they disagree
|
|
275
|
+
* -- the incoherent pair this WO closes. Not a throw: a shared package throwing on a prop
|
|
276
|
+
* combination in production would break a consumer's page over a layout nit. Silent on the
|
|
277
|
+
* equal-value majority and on the legitimate `minHeight` + `aspect` (no `height`) combination.
|
|
278
|
+
*/
|
|
279
|
+
export function warnOnHeightMismatch(componentName, { minHeight, height }) {
|
|
280
|
+
if (process.env.NODE_ENV === 'production')
|
|
281
|
+
return;
|
|
282
|
+
if (minHeight == null || height == null || minHeight === height)
|
|
283
|
+
return;
|
|
284
|
+
// eslint-disable-next-line no-console
|
|
285
|
+
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.');
|
|
288
|
+
}
|
|
248
289
|
export function withChartSlotDefaults(slotProps, legendPosition) {
|
|
249
290
|
var _a;
|
|
250
291
|
const position = Object.assign(Object.assign(Object.assign({}, DEFAULT_LEGEND_POSITION), legendPosition), (_a = slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend) === null || _a === void 0 ? void 0 : _a.position);
|