@micha.bigler/ui-core-micha 2.31.0 → 2.32.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 CHANGED
@@ -24,6 +24,8 @@ prefixed ID; never reuse a bare flat number across workstreams.
24
24
 
25
25
  | ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
26
26
  |---|---|---|---|---|---|---|
27
+ | CHART-7 | `ChartFrame` gains a `titleVariant` prop | Adds `titleVariant` (default `'h6'`, unchanged from today's hardcoded value) to `ChartFrame`, forwarded to the title `Typography` — lets a dense panel grid (e.g. fitness-monitor's seven body-composition panels beside one hero chart) render smaller titles that recede instead of competing with the one chart that matters. One prop, not a `slotProps.title` system — deliberately minimal per the WO. Precondition of fitness-monitor's `FM-12` (DS-10). `2.32.0` (minor, additive). Full spec: [work-orders/CHART-7.md](work-orders/CHART-7.md). | 2026-08-10 | done | 14020d0 | Envelope authored by Expertenchat, no design question (short-form WO). No preconditions. Orchestrator filled a minimal Implementation map (the whole change is two lines in `ChartFrame.jsx`), ran Codex (stdin invocation). Codex added `titleVariant = 'h6'` to the prop destructure, forwarded it to the title `Typography`, wrote all 3 required tests, and bumped `2.31.1` → `2.32.0` per the WO's own stated version target. **Independent `reviewer` + `ui_reviewer` (both Haiku, proportionate to a small low-risk diff) ran concurrently — both found NO findings**, including explicit confirmation that only the prop-destructure line and the one `Typography` line changed (padding, foot row, subtitle/toolbar/slots byte-for-byte unchanged) and that the default-regression test is non-vacuous. Re-verified: 13/13 scoped vitest tests green, `tsc -p tsconfig.build.json --noEmit` clean. **Two-width rendered side-by-side (DS-1 gate)**: no existing harness specimen used `titleVariant`, so the Orchestrator added one (two `ChartFrame`s side by side, default vs. `titleVariant="subtitle2"`) to `dev/entries.jsx` — confirmed live at 375px and 1280px that the override renders visibly smaller/lighter than the unchanged h6 default. |
28
+ | THEME-2 | Two defects from the THEME-1/CHART-6 landings: `warning.main` contrast, `grid` replacing instead of merging | Fixes `palette.warning.main` (`#C08A2C`, 3.038:1 on white — fails AA) to `#976100` (the existing `text` tone, ~5.2:1), `contrastText` to `#FFFFFF`; `warning.fill`/`.text`/`.fillText`/`.bg` unchanged. Adds a completeness-assertion rule checking every status channel's `.main` against `#FFFFFF` and `background.default` at 4.5:1. Fixes `BarChart`/`LineChart`'s `grid` prop silently replacing (not merging) a caller's partial grid object; the required audit of sibling object-valued defaults found a second live instance (`legendPosition`, same bug class) and fixed it too. `2.31.1` (patch). Full spec: [work-orders/THEME-2.md](work-orders/THEME-2.md). | 2026-08-10 | done | 7d5039f | Envelope authored by Expertenchat, both defects fully diagnosed up front (no design question). Preconditions (THEME-1 `2.30.0`, CHART-6 `2.31.0`) verified. Orchestrator filled the Implementation map with the exact current-state code pointers for both defects, then ran Codex (stdin invocation). Codex's own audit (per the WO's "close the mechanism" instruction) found the `legendPosition` partial-override bug beyond the named `grid` fix and fixed it too, exactly as the WO anticipated ("fix that too and say so"). **Independent `reviewer` (Sonnet) + `ui_reviewer` (Haiku) ran concurrently — both found NO findings**, including the reviewer's own hand-computed contrast check confirming `#976100` clears 4.5:1 against both `#FFFFFF` (~5.22:1) and `background.default` (~5.00:1). One thing the Orchestrator itself fixed (not a reviewer finding): a pre-existing THEME-1 regression test (`tests/createAppTheme.test.js`) asserted `palette[status].main === palette[status].fill` for all four status channels — THEME-2 deliberately breaks that equality for `warning` only (the whole point of the fix), so the assertion was updated to special-case `warning` against its new intended invariant (`main === text`, `contrastText === '#FFFFFF'`) while `success`/`error`/`info` keep the original assertion. Re-verified after the fix: 58/58 scoped + adjacent vitest tests green (`tests/themeCompleteness.test.js`, `tests/chartDefaults.test.js`, `tests/createAppTheme.test.js`, `tests/chartsPalette.test.js`, `tests/BarChart.test.jsx`, `tests/LineChart.test.jsx`, `tests/TimeSeriesChart.test.jsx`), `tsc -p tsconfig.build.json --noEmit` clean. **Two-width rendered side-by-side (DS-1 gate)** run in the dev harness's "Theme / Shared baseline" entry — the WO's own point was that no existing specimen exercised `.main` (the `StatusChip`'s `sx={{bgcolor:'...fill'}}` and the `MuiAlert` `standardWarning` override both bypass it), so a new Button/Chip/`Alert variant="filled"` specimen row was added and confirmed live at 375px and 1280px: the warning tone now renders visibly darker (brown, white text) than the still-unchanged lighter-amber `fill`-based chip beside it. The "Caller overrides" `LineChart` specimen's `grid` prop was also changed from a complete `{horizontal:true,vertical:true}` object (which wouldn't have visually proven the merge) to a genuinely partial `{vertical:true}` one — confirmed live at both widths showing BOTH grid directions post-fix. |
27
29
  | CHART-6 | Chart-chrome defaults in the three wrappers, plus two generic fixes hoisted up | Closes the chart-chrome default surface in `BarChart`/`LineChart`/`TimeSeriesChart`: baseline tick font size (from `theme.typography.caption`), a horizontal grid, `showMark:false` with a filled-marker fix when a caller opts markers back in, automatic bottom-margin growth for rotated tick labels, a new `legendPosition` prop (default bottom-start), and `TimeSeriesChart`'s own toolbar becoming the sole legend (MUI's redundant second legend removed). Hoists `chartLabels.js` (short granularity-aware tick-label formatters) and a generalised `yearTickInterval` from fitness-monitor. `2.31.0`. Full spec: [work-orders/CHART-6.md](work-orders/CHART-6.md). | 2026-08-10 | done | 6dedc62 | Envelope authored by Expertenchat (DS-4). Precondition (THEME-1 landed + published `2.30.0`) verified before start. Orchestrator filled the Implementation map with concrete MUI X-Charts source pointers (installed `8.29.2`, not the WO's stated `8.28.2` — flagged and verified against the actual installed source throughout), then ran Codex (stdin invocation, same as THEME-1). Codex implemented `src/components/charts/{chartDefaults,chartLabels,yearTickInterval}.js`, wired all three wrappers, wrote the 10 required tests, and used a wrapper-level `slots.mark` composition for the filled-marker fix (a deliberate, now-documented deviation from the WO's named `MuiMarkElement` theme-token mechanism — the wrapper-level fix applies to all 14 consumers immediately on this version bump, not only to apps that separately adopt `createAppTheme`). **Independent `reviewer` (Sonnet) + `ui_reviewer` (Haiku) ran concurrently.** `reviewer` found **R1 (P2)** — the marker-fill deviation from the WO's specified mechanism was undocumented; fixed with an explanatory code comment (functionally correct either way, this was a disclosure gap, not a bug). `ui_reviewer` found **U1 (P3)** — `SeriesToggleDot`'s 12px dot used hardcoded pixels instead of `theme.spacing()`; fixed. Both reviewers otherwise found the six highest-risk invariants (default-vs-caller-override merge order, filled markers, rotated-tick margin math, the double-legend fix, `yearTickInterval`'s generalisation, `skipAnimation` passthrough) correct and non-vacuously tested. **A third, more serious bug was found by the Orchestrator's own DS-1 two-width rendered check (not by either reviewer, since both are static-diff reviews)**: `withAxisDefaults` in `chartDefaults.js` only applied `defaults.scaleType` when the caller passed NO `xAxis`/`yAxis` array at all — a caller-supplied axis with `data` but no explicit `scaleType` (exactly what the new dev-harness "Chart defaults" `LineChart` specimen used) silently lost the wrapper's `'point'`/`'band'` scale default entirely, leaving MUI's scale undefined and every rendered line/point position `NaN` (`<path> attribute d: Expected number, "MNaN,...`, confirmed live via `read_console_messages` and only reproducible through a real MUI scale computation — a mocked prop-assertion test structurally cannot catch it, the same class of bug CHART-1/3/4/5 have each found before in this kit). Fixed by spreading `defaults` before `axis` in the per-axis merge (caller's own explicit `scaleType`, if any, still wins); a new regression test added (`tests/chartDefaults.test.js`, "keeps the default scaleType on a caller-supplied axis that does not set one"), confirmed against both `BarChart`'s `'band'` and `LineChart`'s `'point'` defaults. Re-verified after all three fixes: 44/44 scoped vitest tests green (10 new + 34 existing adjacent chart tests, no regressions), `tsc -p tsconfig.build.json --noEmit` clean. **Two-width rendered side-by-side (DS-1 gate)** run in the dev harness's "Theme / Shared baseline" entry (which now also carries the two new `LineChart` chart-chrome specimens per the WO's "at least one chart that set the props and one that did not" requirement) at 375px and 1280px — confirmed live: correct line rendering (post-fix), filled markers on the caller-override chart, horizontal grid, bottom-start vs. caller-chosen legend position, and rotated tick labels with adequate margin at 1280px. |
28
30
  | THEME-1 | Shared MUI theme baseline: `createAppTheme` factory + completeness assertion | Ships `createAppTheme(appConfig)` (palette-first merge of a fixed light-mode baseline with the app's own `palette`/`typography`/`components`; throws without `palette.primary`; throws on a function anywhere in `appConfig.components.*.styleOverrides`), an exported `assertThemeComplete` completeness assertion with an exemption arm and contrast checks (status text-on-own-tint, `controlBorder` on white + page background), a non-breaking chart-ramp fallback in `useNeutralChartPalette` (adopters get the theme's `dataSeries` ramp, non-adopters see byte-identical output), a new "Shared theme baseline" dev-harness entry, and `DESIGN.md` principles 1/8 reworded per the accepted policy change (ucm now ships a shared theme; the data-colour layer has a shared default). Full spec: [work-orders/THEME-1.md](work-orders/THEME-1.md). | 2026-08-10 | done | b329b39, 96992cf | Envelope authored by Expertenchat from the accepted reference sheet (`work-orders/assets/THEME-1-baseline-reference-sheet.html`, DS-2 decisions 1–23). Orchestrator filled the Implementation map, then ran Codex (stdin, not positional arg — the WO text exceeded Windows' command-line length limit via the npm shim, "Argument list too long"; `codex exec ... -` reading the WO from stdin is the working invocation for large WOs on this platform). Codex implemented `src/theme/{tokens,createAppTheme,themeCompleteness,index}.js`, wrote all 13 required tests + extended `tests/chartsPalette.test.js`, updated `src/index.js` exports, `DESIGN.md`, `package.json` (2.29.2→2.30.0, new `@fontsource/dm-sans` dependency — WO-scoped, no separate approval needed), and the dev harness. **Independent `reviewer` (Sonnet) + `ui_reviewer` (Haiku) ran concurrently. `ui_reviewer`: no findings.** `reviewer` found three, all confirmed real and fixed by the Orchestrator: **R1 (P2)** — MUI's `createTheme(options, ...args)` only runs palette augmentation on the FIRST argument; every later arg (how the baseline was layered in) is deep-merged raw, so `success`/`warning`/`error`/`info` — which had `text`/`fill`/`fillText`/`bg` but no `main`/`contrastText` — silently kept MUI's own stock hues for any standard `color="success"` usage. Verified live via a Node repro (`createTheme({...},{palette:BASELINE_PALETTE},{...})` on the pre-fix baseline produced `success.main:'#2e7d32'`, MUI's stock green, not the canonical `#1B8038`). Fixed: explicit `main`/`light`/`dark`/`contrastText` added to all four status channels (`light`/`dark` computed via MUI's own `lighten`/`darken` at the same 0.2/0.3 tonal offsets `augmentColor` uses), plus a new regression assertion in `tests/createAppTheme.test.js`. **R2 (P3)** — `controlBorder.focus` was derived to clear 3:1 only against `background.paper`, while `assertThemeComplete`'s own contrast check tests it against `#FFFFFF` and `background.default` — an app overriding either background could pass the derivation and still fail the assertion. Fixed: derivation now targets both surfaces. **R3 (P3)** — `MuiOutlinedInput`'s `variants` array was nested inside `styleOverrides.root` instead of as a sibling key; MUI never reads `variants` from inside `styleOverrides` (it's matched by `props` independently), so the entry was inert dead code, and it also meant the function-in-`styleOverrides` guard's own suggested escape hatch ("use MUI variants") was structurally unreachable from inside `styleOverrides`. Fixed: moved to the correct sibling placement; updated the one test and the one completeness-surface path that referenced the old (wrong) location. Re-verified after fixes: 15/15 scoped vitest tests green (`tests/createAppTheme.test.js`, `tests/themeCompleteness.test.js`, `tests/chartsPalette.test.js`), `tsc -p tsconfig.build.json --noEmit` clean. Orchestrator's own security/scope pass: no findings (only the WO-approved `@fontsource/dm-sans` dependency addition, no secrets, no scope creep). **Two-width rendered side-by-side (DS-1 gate)** run in the dev harness's new "Theme / Shared baseline" entry at 375px and 1280px — both screenshots confirm radius (3/8), no resting shadow on Paper/Card, correct status fill/text/bg tones, and the six-colour series ramp, consistent with the reference sheet. No app pin bumps in this WO — all 14 consumers pin exact versions and pick this up on their own adoption WO. |
29
31
  | CHART-5 | Dual y-axis + always-visible x-axis ticks on `TimeSeriesChart` | Opt-in second y-axis via a new per-series `axis: 'secondary'` field (backward-compatible, off by default) with auto-detected integer-only tick labels per axis group; deterministic x-axis `tickLabelInterval` so bucket labels never all vanish under MUI's default collision-avoidance (the live symptom: zero x-axis labels with 24 hourly buckets). Reverses CHART-2's deliberate "no second y-axis" choice on fresh operator instruction; `DESIGN.md` #8 updated to record the scoped exception. Full spec: [work-orders/CHART-5.md](work-orders/CHART-5.md). | 2026-08-07 | done | 839e6c5, 84c37ac, 0dd3bac, f4e5586, 4a12a71 | Operator-requested after seeing the shipped chart live: y-axis showed decimal ticks (0.2, 0.4, ...) for an integer user-count series sharing an axis with fractional hours; x-axis showed no time labels at all. **Codex out of credits, known up front** — implemented directly (author-flip, independent review mandatory). Independent `reviewer` + `ui_reviewer` ran concurrently: **`reviewer` found R1 (P1, real bug)** — used `yAxisKey` (removed in MUI X-Charts v7, per the installed v8.29.2's own CHANGELOG and `useBarPlotData.mjs` source, which reads `series[i].yAxisId`) instead of `yAxisId`; the WO explicitly asked to verify this passthrough against real MUI behaviour rather than assume, and the first pass didn't — the dual-axis feature would have silently plotted every series on the primary axis in production despite all mocked tests passing (a mocked-prop test structurally cannot catch a wrong-field-name bug, since it only checks whatever the code itself sets). Fixed (`yAxisKey` → `yAxisId`, verified against `node_modules` source directly, not just the mock) and the pre-existing `tests/BarChart.test.jsx` "dual axis" case corrected too (R2 — it used the same wrong field name and was cited by the WO as precedent, giving false confidence). **`ui_reviewer` found U1 (P1, real bug)** — `tickLabelInterval`'s `index % step === 0` systematically dropped the LAST x-axis bucket unless it landed exactly on a step boundary (e.g. 24 labels/step 3 → indices 0..21, never 23), exactly the failure mode the WO's own SCOPE C explicitly warned to check for; fixed by force-including `index === lastIndex`, with a new `it.each([24,12,7,1])` regression test, confirmed non-vacuous by reverting the fix. **U2 (P2)**: `DESIGN.md` #8's "Never dual-axis" now contradicted the shipped code — updated to record dual-axis as a scoped, opt-in exception (CHART-5) rather than a general rule change. U3 (P3, fixed `MAX_X_TICKS` under-displays low-density ranges e.g. 12 monthly buckets showing only 6) and U4 (P3, no UI indication of which axis a toggle's series plots against) both accepted, not fixed — informational, no code change. Re-verified after fixes: 31/31 scoped vitest tests (`TimeSeriesChart.test.jsx` + `BarChart.test.jsx`) green, `tsc` build clean. Published: ucm 2.29.0 (minor — new optional per-series field + prop, additive, no breaking change). **Third bug found post-publish (2.29.1 patch)**: after jg-ferien adopted it (ACT-7) the operator reported live that the secondary axis was "obviously there but invisible" and x-axis labels were still missing. Investigated by adding a new DX-1 harness entry (`dev/entries.jsx` `TimeSeriesChartEntry`, previously missing — this component had NEVER been visually verified in a real browser through CHART-2/3/4/5) and reading the real rendered DOM directly. X-axis confirmed working (9 evenly-spread labels for 24 buckets) — the live report was very likely a stale PWA service-worker cache on jg-ferien's side, not a code defect. Y-axis confirmed broken: only 1 of 2 `yAxis` entries rendered in the DOM. Root cause traced to the installed MUI X-Charts source (`defaultizeAxis.js`): `defaultPosition = index === 0 ? 'left' : 'none'` — every yAxis array entry past the first silently renders nothing unless given an explicit `position`, a class of bug no mocked-`BarChart` test can catch (the mock never runs MUI's own defaulting logic). Fixed by setting `position: 'left'`/`'right'` explicitly on the primary/secondary entries; independent `reviewer` traced the fix against the same MUI source and confirmed no second gating layer exists. Live-reverified in the dev harness after the fix: both axes render with independent scales (primary `0,1,2`; secondary `0.0,0.5,1.0,1.5,2.0`). 32/32 tests green, build clean. Published: ucm 2.29.1 (patch). **Fourth report, root cause NOT in ucm**: operator reported x-axis labels still empty even after 2.29.1. Extensive live investigation (scoped DOM queries, computed-style checks, a real-browser-DOM comparison against this repo's own dev harness in both dev and production-build mode, a DM-Sans-not-loaded font test) could not reproduce it anywhere in ucm with the exact real bucket data — ruled out: stale cache, MUI axis config, container width, dev-vs-prod build, font-loading timing. Root cause found via web search: a known, unfixed upstream MUI X-Charts bug (mui/mui-x#18768, duplicate of #18399) where insufficient measured space makes its `ellipsize` logic collapse an ENTIRE tick label to `''` instead of shortening it, confirmed live via a `<tspan>` with correct styling but zero text content — triggered by jg-ferien's verbose label format ("08/05, 10:00 AM", 16 chars) combined with its actual DM Sans font metrics (not reproducible with this repo's default Roboto-based harness). Fixed in jg-ferien (ACT-9: short, granularity-aware labels), not in ucm — `TimeSeriesChart` itself has no defect here, it's a host-side label-length choice interacting with an upstream MUI bug. This repo's DX-1 harness entry (`TimeSeriesChartEntry`) updated to model the correct short-label pattern so it doesn't teach the risky verbose format. |
@@ -57,5 +59,3 @@ prefixed ID; never reuse a bare flat number across workstreams.
57
59
  | AUTH-2 | Translate backend auth error codes (stop leaking raw codes) | ucm surfaces raw backend/allauth error **codes**, not text: `apiClient.jsx:107` extracts `err.code`, components do `t(err.code)`/`setErrorKey(err.code)`, but the catalogue has no entries → users see literal `email_password_mismatch` / `password_too_short`. Affects **all** rendering auth screens (login/change-pw/MFA/profile). Fix = add de/fr/en i18n entries **keyed by the enumerated codes** → the existing `t(err.code)` resolves, **zero component/app change**. Does NOT reach the delegating invitation `PasswordSetForm` (that's AUTH-1). Full spec: [work-orders/AUTH-2.md](work-orders/AUTH-2.md). | 2026-08-06 | done | 84387cf, 103a252 | Tier 2 (shared-core). Same Codex→Claude fallback and reviewer pairing as AUTH-1 (one combined diff, both WOs landed together). **Keying decided: raw-code (drop-in)**, per the WO's own stated preference — no operator request for the namespaced `i18nKey` alternative. **Codes enumerated by reading the installed allauth/Django source directly** (not guessed): `email_password_mismatch`/`username_password_mismatch`/`phone_password_mismatch` (`allauth/headless/account/inputs.py` `LoginInput.clean`), `too_many_login_attempts`/`invalid_login`/`account_inactive`/`enter_current_password` (`allauth/account/adapter.py` error_messages + `allauth/headless/account/inputs.py` `ChangePasswordInput.clean_current_password` — confirmed this genuinely means "wrong current password", not merely "field left blank"), `incorrect_code` (`allauth/mfa/**`), `password_too_short`/`password_too_common`/`password_entirely_numeric`/`password_too_similar` (Django `contrib/auth/password_validation.py`, matching dcm's `AUTH_PASSWORD_VALIDATORS` baseline). Added de/fr/en/**sw** (file's established 4-locale convention, honoured over the WO text's literal "de/fr/en"). **`ui_reviewer` + `reviewer`: no AUTH-2-specific findings** (both spot-checked the code spellings independently against the same installed packages; no key collisions with existing `Auth.*` keys; `authErrorCodeRendering.test.jsx` proves a mapped code renders human text via a real i18next instance and a genuinely-uncoded error still falls back to the existing generic `Auth.PASSWORD_CHANGE_FAILED`). Gate: shared with AUTH-1 — full suite 322/322, `tsc` clean. **Published version correction**: see AUTH-1's row — the 2.28.0 tag was skipped by a CI runner backlog and never published; verified present in the npm tarball from **2.28.1** onward (currently 2.29.1). Companion: AUTH-1. |
58
60
  | AUTH-3 | SignUpPage showed the access-code-specific success message for every signup mode | Operator-observed live bug 2026-08-07: open self-registration (no access code) showed "Falls der Zugangscode für dieses Projekt gültig ist, wurde ein Einladungslink an X gesendet" — nonsensical when there is no code at all. Root cause: `SignUpPage.jsx`'s `handleSubmit` hardcoded `Auth.INVITE_REQUEST_SUCCESS` for every mode (open/access-code/email-domain/QR) regardless of the backend's actual response. | 2026-08-07 | done | 599e73d, b4e1fe7 | No WO file — operator-reported bug, fixed directly, same convention as MSG-6c/MSG-6d/MSG-6j. Tier 2 (shared-core). **Codex → Claude fallback**: one fresh probe made today (new day, prior confirmation was 2026-08-06) — still "workspace out of credits", stamped in `project_codex_quota_status` memory — implemented directly, author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit. **Root cause confirmed against the actual backend** (`django-core-micha` `register_request` view + `validate_access_code_or_error`): every signup mode's success response already carries a mode-agnostic, accurate `code: "Auth.INVITE_SENT"`; an invalid access code is never silently accepted, it raises a distinct 400 (`Auth.ACCESS_CODE_INVALID_OR_INACTIVE`) — so the old key's "if valid…" ambiguity never matched backend reality for any mode, not just the ones reported. Fix: `setSuccessKey(res?.code || 'Auth.INVITE_SENT')`, using the backend's own code; no component/app change beyond this. **Both reviewers independently flagged the same P3**: switching away from the old key silently dropped the `{{email}}` confirmation (`Auth.INVITE_SENT` had none). Fixed before commit — extended `Auth.INVITE_SENT`'s text to include `{{email}}` in all 4 locales (safe: it had exactly one consumer, this call site, confirmed by grep). `Auth.INVITE_REQUEST_SUCCESS` is now dead/orphaned (both reviewers flagged as P3, tracked here, not removed — out of this fix's scope). Gate: `tests/SignUpPage.test.jsx` 13/13 (3 new, proving the backend code wins over the old hardcoded key, and the no-code fallback); full suite 342/342, `tsc` clean. |
59
61
  | SHELL-2 | AccountPage: from a horizontal tab strip to a grouped sidebar + mobile drawer | Measured live at 375 px on 2026-08-08 (running cockpit, host header neutralised so the numbers describe ucm alone): `AccountPage` does **not** break the layout -- all six sections render at document width 375. What fails is discoverability and the users table. The tab strip scroller is 343 px visible against 589 px of content with `overflow-x: auto`, 246 px of travel, and **`scrollButtons: 0`** -- Support and any consumer-injected section sit off screen with no arrow, no fade, no hint they exist (they do respond to a horizontal swipe, so this is a discoverability failure, not a reachability one -- unlike cockpit's own header tabs, which are `width: 0px` + `overflow: hidden` and genuinely unreachable). The users table renders 1045 px inside a 341 px `MuiTableContainer`; one third is visible and the `Aktionen` column sits ~700 px to the right. Adopts jg-ferien's `EventInfoPage` pattern (`SectionSwitcher.jsx`, driven from `EventInfoPage.jsx:1126-1159`) in **both** halves -- sticky grouped 280 px sidebar at `md`+, bottom drawer below `md` -- plus an in-page trigger bar, which the reference does not supply because jg's trigger lives in that app's own page header and `AccountPage` has none. | 2026-08-08 | blocked | | **Held back 2026-08-08 — reshaped into DS-11** (see `webapp-management/DESIGN_SYSTEM_PROGRAM.md`): do NOT implement this WO as written; the Envelope will be revised (open: keep the SHELL-2 ID or mint a new SHELL-* ID). Known corrections for the revision: publishing is version-gated, not per-push (this row's "publishes immediately" is imprecise), and the grouped-shell part becomes an inheritable ucm component with `AccountPage` as first consumer. Envelope: [work-orders/SHELL-2.md](work-orders/SHELL-2.md). Prototype: [work-orders/assets/SHELL-2-account-navigation.html](work-orders/assets/SHELL-2-account-navigation.html). Tier 2 (shared core) -- independent `reviewer` (Sonnet, full) **and** `ui_reviewer` concurrently, both mandatory before commit. **Operator decisions, settled 2026-08-08 and not to be renegotiated by implementer or reviewer:** both halves of the pattern, so the horizontal tab strip disappears **on desktop too** (asked explicitly, chosen deliberately, in full knowledge that 14 consumers inherit the desktop change on their next pin bump); fixed groups `Mein Konto` / `Verwaltung` / `Hilfe` / `Weitere`, with `extraTabs` gaining an **optional** `group` field, additive and backward compatible; no standalone "Uebersicht" entry above the groups (jg-ferien has one, `AccountPage` has no overview screen for it to point at, so none is invented); users table becomes a card list below `md` and stays a table at `md`+; the mobile trigger bar is the **sole** carrier of the current section name, so the section heading below it is **removed**, not merely undrawn -- desktop keeps its heading because it has no trigger bar. **Two contracts that must survive and are currently untested:** the `?tab=` URL contract incl. fallback to `profile` on an unknown *or* unpermitted value (`AccountPage.jsx:60`, `:180-186`) -- cockpit's `/user-management`, `/profile` and `/change-password` redirects all hang off it -- and the full `extraTabs` shape (`:146-155`), whose `label` may be a **function** and which supports `visible(context)`. `AccountPage` and `UserListComponent` have **zero tests today** (none of the 20 files under `tests/` touches either), so both test files are new, not extensions. **No staging net:** this repo has no `develop`; a push to `main` touching `src/**` or `package.json` publishes to npm immediately via `publish.yml`, so the independent review is the only gate and is not back-fillable. Version target `2.30.0` (minor -- `extraTabs` gains a public field; additive API is a minor under semver). Explicit non-goals: pagination/filtering for the user list, the sections' internal content, the six auth pages (measured clean at 375 px), and **cockpit's header** -- that toolbar is what inflates the document to 498 px on every route including `/account`, and it is a separate WO in the cockpit repo, sequenced after this one at the operator's choice. Carried-over trap from the reference: jg-ferien uses `env(safe-area-inset-bottom)` while its `index.html` omits `viewport-fit=cover`, so those insets resolve to zero there -- copy the padding, do not assume it does anything. |
60
- | THEME-1 | Shared MUI theme baseline: `createAppTheme` factory + completeness assertion | Every app theme sets a handful of values and leaves the rest at MUI's defaults, so implementations drift toward Material Design 2014 (uppercase buttons, 20px h6, 16px body, elevation shadows). The baseline was distilled from 13 prototype artifacts across 5 repos plus 5 app themes, and settled with the operator over two review rounds against a rendered reference sheet. | 2026-08-09 | planned | | Reference sheet (visual + token spec, live WCAG contrast checks): [work-orders/assets/THEME-1-baseline-reference-sheet.html](work-orders/assets/THEME-1-baseline-reference-sheet.html). Its canonical token table is the value source — the Envelope references it rather than restating the numbers. Envelope: [work-orders/THEME-1.md](work-orders/THEME-1.md) (authored 2026-08-10, Approval Gate #1 pending). Version target 2.30.0; DS-4 follows as 2.31.0. Program context: `webapp-management/DESIGN_SYSTEM_PROGRAM.md` (DS-2). |
61
- | CHART-6 | Chart-chrome defaults in the three wrappers + the two generic fixes hoisted up | The kit's `LineChart`/`BarChart`/`TimeSeriesChart` are ~18 lines of logic each and set no tick font, grid, margin or marker styling, so MUI X-Charts' own visual language shows through untouched. Hand discipline does not close it: hram, the exemplar, sets `tickLabelStyle.fontSize` at only 4 of 9 call sites. Also resolves `TimeSeriesChart`'s double legend and hoists fitness-monitor's label-collapse workaround (two consumers) plus `yearTickInterval`. | 2026-08-10 | planned | | Envelope: [work-orders/CHART-6.md](work-orders/CHART-6.md) (Approval Gate #1 pending). **Hard precondition: THEME-1 must land and publish as 2.30.0 first** — both touch `src/index.js` and `src/components/charts/`, and this repo publishes on every push. Version target 2.31.0. Legend auto-placement is deliberately out of scope (own strand — the in-plot overlay path is unverified in X-Charts 8.28.2). Program context: `webapp-management/DESIGN_SYSTEM_PROGRAM.md` (DS-4). |
package/dev/entries.jsx CHANGED
@@ -77,6 +77,8 @@ function StatusChip({ label, tone }) {
77
77
  );
78
78
  }
79
79
 
80
+ const MUI_STATUS_TONES = ['success', 'warning', 'error', 'info'];
81
+
80
82
  function ThemeBaselineEntry() {
81
83
  return (
82
84
  <Stack spacing={3}>
@@ -120,6 +122,25 @@ function ThemeBaselineEntry() {
120
122
  <StatusChip label="Stale" tone="stale" />
121
123
  </Stack>
122
124
 
125
+ <Stack spacing={1}>
126
+ <Typography variant="subtitle2">MUI status colours using palette main</Typography>
127
+ <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
128
+ {MUI_STATUS_TONES.map((tone) => (
129
+ <Button key={tone} color={tone} variant="contained">{tone}</Button>
130
+ ))}
131
+ </Stack>
132
+ <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
133
+ {MUI_STATUS_TONES.map((tone) => (
134
+ <Chip key={tone} color={tone} label={tone} />
135
+ ))}
136
+ </Stack>
137
+ <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
138
+ {MUI_STATUS_TONES.map((tone) => (
139
+ <Alert key={tone} severity={tone} variant="filled">{tone}</Alert>
140
+ ))}
141
+ </Stack>
142
+ </Stack>
143
+
123
144
  <Stack direction="column" spacing={2} alignItems="flex-start" sx={{ '@container (min-width: 900px)': { flexDirection: 'row' } }}>
124
145
  <TextField label="Project name" defaultValue="Baseline harness" fullWidth />
125
146
  <FormControl fullWidth>
@@ -196,12 +217,25 @@ function ThemeBaselineEntry() {
196
217
  ]}
197
218
  xAxisLabel="Month"
198
219
  yAxisLabel="Cases"
199
- grid={{ horizontal: true, vertical: true }}
220
+ grid={{ vertical: true }}
200
221
  legendPosition={{ vertical: 'top', horizontal: 'end' }}
201
222
  height={320}
202
223
  />
203
224
  </ChartFrame>
204
225
  </Stack>
226
+
227
+ <Stack
228
+ direction="column"
229
+ spacing={2}
230
+ sx={{ '@container (min-width: 900px)': { flexDirection: 'row' } }}
231
+ >
232
+ <ChartFrame title="Default titleVariant" subtitle="h6, unchanged for existing callers" minHeight={160}>
233
+ <Typography variant="body2" color="text.secondary">Panel body placeholder</Typography>
234
+ </ChartFrame>
235
+ <ChartFrame title="Dense panel title" subtitle="titleVariant=&quot;subtitle2&quot;" titleVariant="subtitle2" minHeight={160}>
236
+ <Typography variant="body2" color="text.secondary">Panel body placeholder</Typography>
237
+ </ChartFrame>
238
+ </Stack>
205
239
  </Stack>
206
240
  );
207
241
  }
@@ -15,13 +15,13 @@ import { Box } from '@mui/material';
15
15
  import { useTheme } from '@mui/material/styles';
16
16
  import { BarChart as MuiBarChart } from '@mui/x-charts/BarChart';
17
17
  import { useNeutralChartPalette } from './palette';
18
- import { DEFAULT_LEGEND_POSITION, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, } from './chartDefaults';
18
+ import { DEFAULT_LEGEND_POSITION, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
19
19
  /**
20
20
  * Responsive MUI X-Charts bar preset. xAxisLabel and yAxisLabel (including units) are required.
21
21
  * It supplies labelled axes, tooltips, and an automatic multi-series legend.
22
22
  */
23
23
  export function BarChart(_a) {
24
- var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid = { horizontal: true }, 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"]);
24
+ 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"]);
25
25
  const theme = useTheme();
26
26
  const neutralPalette = useNeutralChartPalette();
27
27
  if (!xAxisLabel || !yAxisLabel) {
@@ -30,5 +30,5 @@ export function BarChart(_a) {
30
30
  const labelledXAxis = withAxisDefaults(xAxis, xAxisLabel, { scaleType: 'band', data: [] }, theme.typography.caption.fontSize);
31
31
  const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear' }, theme.typography.caption.fontSize);
32
32
  const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
33
- 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: labelledYAxis, colors: palette || neutralPalette.categorical, grid: grid, hideLegend: hideLegend, margin: rotatedTickSpace.margin, slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
33
+ 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: labelledYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: rotatedTickSpace.margin, slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
34
34
  }
@@ -6,7 +6,7 @@ import { exportChartPng, exportChartSvg } from './exportChart';
6
6
  /**
7
7
  * Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
8
8
  */
9
- 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', sx, }) {
9
+ 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, }) {
10
10
  const { t } = useTranslation();
11
11
  const titleId = useId();
12
12
  const chartRef = useRef(null);
@@ -40,7 +40,7 @@ export function ChartFrame({ title, subtitle, toolbar, controls, loading = false
40
40
  else if (isEmpty) {
41
41
  content = _jsx(Alert, { severity: "info", children: emptyMessage || t('ChartFrame.EMPTY_DEFAULT') });
42
42
  }
43
- return (_jsxs(Paper, { variant: variant, sx: Object.assign({ p: 2 }, sx), children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }, children: [_jsxs(Box, { children: [_jsx(Typography, { id: titleId, variant: "h6", 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: {
43
+ return (_jsxs(Paper, { variant: variant, sx: Object.assign({ p: 2 }, sx), children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }, 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: {
44
44
  width: '100%',
45
45
  minHeight,
46
46
  aspectRatio: aspect,
@@ -15,7 +15,7 @@ import { Box } from '@mui/material';
15
15
  import { useTheme } from '@mui/material/styles';
16
16
  import { LineChart as MuiLineChart, MarkElement as MuiMarkElement, } from '@mui/x-charts/LineChart';
17
17
  import { useNeutralChartPalette } from './palette';
18
- import { DEFAULT_LEGEND_POSITION, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, } from './chartDefaults';
18
+ import { DEFAULT_LEGEND_POSITION, spaceForRotatedTicks, withAxisDefaults, withChartSlotDefaults, withGridDefaults, } from './chartDefaults';
19
19
  // Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
20
20
  // `styled()` default). CHART-6's WO named a `MuiMarkElement` theme-token
21
21
  // override in src/theme/tokens.js as the preferred mechanism, with a
@@ -35,7 +35,7 @@ function FilledMarkElement(_a) {
35
35
  * It supplies labelled axes, tooltips, and an automatic multi-series legend.
36
36
  */
37
37
  export function LineChart(_a) {
38
- var { series = [], xAxisLabel, yAxisLabel, xAxis, yAxis, palette, minHeight, aspect, grid = { horizontal: true }, 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"]);
38
+ 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"]);
39
39
  const theme = useTheme();
40
40
  const neutralPalette = useNeutralChartPalette();
41
41
  if (!xAxisLabel || !yAxisLabel) {
@@ -45,5 +45,5 @@ export function LineChart(_a) {
45
45
  const labelledYAxis = withAxisDefaults(yAxis, yAxisLabel, { scaleType: 'linear' }, theme.typography.caption.fontSize);
46
46
  const rotatedTickSpace = spaceForRotatedTicks(labelledXAxis, margin, theme.typography.caption.lineHeight);
47
47
  const defaultedSeries = series.map((item) => (Object.assign({ showMark: false, labelMarkType: 'square' }, item)));
48
- 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: labelledYAxis, colors: palette || neutralPalette.categorical, grid: grid, hideLegend: hideLegend, margin: rotatedTickSpace.margin, slots: Object.assign({ mark: FilledMarkElement }, slots), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
48
+ 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: labelledYAxis, colors: palette || neutralPalette.categorical, grid: withGridDefaults(grid), hideLegend: hideLegend, margin: rotatedTickSpace.margin, slots: Object.assign({ mark: FilledMarkElement }, slots), slotProps: withChartSlotDefaults(slotProps, legendPosition) })) }));
49
49
  }
@@ -2,6 +2,9 @@ export const DEFAULT_LEGEND_POSITION = {
2
2
  vertical: 'bottom',
3
3
  horizontal: 'start',
4
4
  };
5
+ export function withGridDefaults(grid) {
6
+ return Object.assign({ horizontal: true }, grid);
7
+ }
5
8
  export function withAxisDefaults(axes, label, defaults, tickFontSize) {
6
9
  const values = (axes === null || axes === void 0 ? void 0 : axes.length) ? axes : [defaults];
7
10
  return values.map((axis) => (Object.assign(Object.assign(Object.assign({}, defaults), axis), { tickLabelStyle: Object.assign({ fontSize: tickFontSize }, axis.tickLabelStyle), label: axis.label || label })));
@@ -76,5 +79,7 @@ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
76
79
  return { xAxis: spacedXAxis, margin: spacedMargin };
77
80
  }
78
81
  export function withChartSlotDefaults(slotProps, legendPosition) {
79
- return Object.assign(Object.assign({}, slotProps), { tooltip: Object.assign({ trigger: 'axis' }, slotProps === null || slotProps === void 0 ? void 0 : slotProps.tooltip), legend: Object.assign({ position: legendPosition }, slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend) });
82
+ var _a;
83
+ 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);
84
+ return Object.assign(Object.assign({}, slotProps), { tooltip: Object.assign({ trigger: 'axis' }, slotProps === null || slotProps === void 0 ? void 0 : slotProps.tooltip), legend: Object.assign(Object.assign({}, slotProps === null || slotProps === void 0 ? void 0 : slotProps.legend), { position }) });
80
85
  }
@@ -219,7 +219,7 @@ export function calculateContrastRatio(foreground, background) {
219
219
  return (light + 0.05) / (dark + 0.05);
220
220
  }
221
221
  function contrastFindings(theme) {
222
- var _a, _b, _c, _d, _e;
222
+ var _a, _b, _c, _d, _e, _f, _g;
223
223
  const findings = [];
224
224
  for (const status of STATUS_KEYS) {
225
225
  const tokens = (_a = theme.palette) === null || _a === void 0 ? void 0 : _a[status];
@@ -248,8 +248,24 @@ function contrastFindings(theme) {
248
248
  white: '#FFFFFF',
249
249
  page: (_c = (_b = theme.palette) === null || _b === void 0 ? void 0 : _b.background) === null || _c === void 0 ? void 0 : _c.default,
250
250
  };
251
+ for (const status of STATUS_KEYS) {
252
+ const main = (_e = (_d = theme.palette) === null || _d === void 0 ? void 0 : _d[status]) === null || _e === void 0 ? void 0 : _e.main;
253
+ if (!main)
254
+ continue;
255
+ for (const [surface, background] of Object.entries(backgrounds)) {
256
+ const ratio = calculateContrastRatio(main, background);
257
+ if (!Number.isFinite(ratio) || ratio < 4.5) {
258
+ findings.push({
259
+ surface: `contrast.${status}.main-on-${surface}`,
260
+ reason: Number.isFinite(ratio)
261
+ ? `Expected at least 4.5:1; received ${ratio.toFixed(2)}:1.`
262
+ : 'Contrast could not be calculated from the declared colours.',
263
+ });
264
+ }
265
+ }
266
+ }
251
267
  for (const state of ['main', 'hover', 'focus', 'error']) {
252
- const colour = (_e = (_d = theme.palette) === null || _d === void 0 ? void 0 : _d.controlBorder) === null || _e === void 0 ? void 0 : _e[state];
268
+ const colour = (_g = (_f = theme.palette) === null || _f === void 0 ? void 0 : _f.controlBorder) === null || _g === void 0 ? void 0 : _g[state];
253
269
  if (!colour)
254
270
  continue;
255
271
  for (const [surface, background] of Object.entries(backgrounds)) {
@@ -62,7 +62,7 @@ export const BASELINE_PALETTE = {
62
62
  error: '#BF3227',
63
63
  },
64
64
  success: Object.assign(Object.assign({}, withMainShades('#1B8038', '#FFFFFF')), { text: '#35794A', fill: '#1B8038', fillText: '#FFFFFF', bg: '#E5F4E9' }),
65
- warning: Object.assign(Object.assign({}, withMainShades('#C08A2C', '#212529')), { text: '#976100', fill: '#C08A2C', fillText: '#212529', bg: '#FBF0DC' }),
65
+ warning: Object.assign(Object.assign({}, withMainShades('#976100', '#FFFFFF')), { text: '#976100', fill: '#C08A2C', fillText: '#212529', bg: '#FBF0DC' }),
66
66
  error: Object.assign(Object.assign({}, withMainShades('#BF3227', '#FFFFFF')), { text: '#BF3227', fill: '#BF3227', fillText: '#FFFFFF', bg: '#FBEAE8' }),
67
67
  // The canonical sheet introduces no new info hue. These values reuse
68
68
  // MUI's own info.dark/main family while adding the shared status shape.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.31.0",
3
+ "version": "2.32.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -8,6 +8,7 @@ import {
8
8
  spaceForRotatedTicks,
9
9
  withAxisDefaults,
10
10
  withChartSlotDefaults,
11
+ withGridDefaults,
11
12
  } from './chartDefaults';
12
13
 
13
14
  /**
@@ -23,7 +24,7 @@ export function BarChart({
23
24
  palette,
24
25
  minHeight,
25
26
  aspect,
26
- grid = { horizontal: true },
27
+ grid,
27
28
  hideLegend = series.length <= 1,
28
29
  legendPosition = DEFAULT_LEGEND_POSITION,
29
30
  margin,
@@ -62,7 +63,7 @@ export function BarChart({
62
63
  xAxis={rotatedTickSpace.xAxis}
63
64
  yAxis={labelledYAxis}
64
65
  colors={palette || neutralPalette.categorical}
65
- grid={grid}
66
+ grid={withGridDefaults(grid)}
66
67
  hideLegend={hideLegend}
67
68
  margin={rotatedTickSpace.margin}
68
69
  slotProps={withChartSlotDefaults(slotProps, legendPosition)}
@@ -32,6 +32,7 @@ export function ChartFrame({
32
32
  ariaLabel,
33
33
  children,
34
34
  variant = 'outlined',
35
+ titleVariant = 'h6',
35
36
  sx,
36
37
  }) {
37
38
  const { t } = useTranslation();
@@ -71,7 +72,7 @@ export function ChartFrame({
71
72
  <Paper variant={variant} sx={{ p: 2, ...sx }}>
72
73
  <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }}>
73
74
  <Box>
74
- <Typography id={titleId} variant="h6">{title}</Typography>
75
+ <Typography id={titleId} variant={titleVariant}>{title}</Typography>
75
76
  {subtitle && <Typography variant="body2" color="text.secondary">{subtitle}</Typography>}
76
77
  </Box>
77
78
  {toolbar && <Box>{toolbar}</Box>}
@@ -11,6 +11,7 @@ import {
11
11
  spaceForRotatedTicks,
12
12
  withAxisDefaults,
13
13
  withChartSlotDefaults,
14
+ withGridDefaults,
14
15
  } from './chartDefaults';
15
16
 
16
17
  // Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
@@ -40,7 +41,7 @@ export function LineChart({
40
41
  palette,
41
42
  minHeight,
42
43
  aspect,
43
- grid = { horizontal: true },
44
+ grid,
44
45
  hideLegend = series.length <= 1,
45
46
  legendPosition = DEFAULT_LEGEND_POSITION,
46
47
  margin,
@@ -85,7 +86,7 @@ export function LineChart({
85
86
  xAxis={rotatedTickSpace.xAxis}
86
87
  yAxis={labelledYAxis}
87
88
  colors={palette || neutralPalette.categorical}
88
- grid={grid}
89
+ grid={withGridDefaults(grid)}
89
90
  hideLegend={hideLegend}
90
91
  margin={rotatedTickSpace.margin}
91
92
  slots={{ mark: FilledMarkElement, ...slots }}
@@ -3,6 +3,10 @@ export const DEFAULT_LEGEND_POSITION = {
3
3
  horizontal: 'start',
4
4
  };
5
5
 
6
+ export function withGridDefaults(grid) {
7
+ return { horizontal: true, ...grid };
8
+ }
9
+
6
10
  export function withAxisDefaults(axes, label, defaults, tickFontSize) {
7
11
  const values = axes?.length ? axes : [defaults];
8
12
  return values.map((axis) => ({
@@ -97,6 +101,11 @@ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
97
101
  }
98
102
 
99
103
  export function withChartSlotDefaults(slotProps, legendPosition) {
104
+ const position = {
105
+ ...DEFAULT_LEGEND_POSITION,
106
+ ...legendPosition,
107
+ ...slotProps?.legend?.position,
108
+ };
100
109
  return {
101
110
  ...slotProps,
102
111
  tooltip: {
@@ -104,8 +113,8 @@ export function withChartSlotDefaults(slotProps, legendPosition) {
104
113
  ...slotProps?.tooltip,
105
114
  },
106
115
  legend: {
107
- position: legendPosition,
108
116
  ...slotProps?.legend,
117
+ position,
109
118
  },
110
119
  };
111
120
  }
@@ -275,6 +275,22 @@ function contrastFindings(theme) {
275
275
  white: '#FFFFFF',
276
276
  page: theme.palette?.background?.default,
277
277
  };
278
+ for (const status of STATUS_KEYS) {
279
+ const main = theme.palette?.[status]?.main;
280
+ if (!main) continue;
281
+ for (const [surface, background] of Object.entries(backgrounds)) {
282
+ const ratio = calculateContrastRatio(main, background);
283
+ if (!Number.isFinite(ratio) || ratio < 4.5) {
284
+ findings.push({
285
+ surface: `contrast.${status}.main-on-${surface}`,
286
+ reason: Number.isFinite(ratio)
287
+ ? `Expected at least 4.5:1; received ${ratio.toFixed(2)}:1.`
288
+ : 'Contrast could not be calculated from the declared colours.',
289
+ });
290
+ }
291
+ }
292
+ }
293
+
278
294
  for (const state of ['main', 'hover', 'focus', 'error']) {
279
295
  const colour = theme.palette?.controlBorder?.[state];
280
296
  if (!colour) continue;
@@ -79,7 +79,7 @@ export const BASELINE_PALETTE = {
79
79
  bg: '#E5F4E9',
80
80
  },
81
81
  warning: {
82
- ...withMainShades('#C08A2C', '#212529'),
82
+ ...withMainShades('#976100', '#FFFFFF'),
83
83
  text: '#976100',
84
84
  fill: '#C08A2C',
85
85
  fillText: '#212529',
@@ -55,6 +55,48 @@ function renderFrame(props = {}, language = 'en') {
55
55
  describe('ChartFrame', () => {
56
56
  afterEach(cleanup);
57
57
 
58
+ it('renders the title as h6 by default', () => {
59
+ renderFrame();
60
+
61
+ const title = screen.getByText('Chart title');
62
+ expect(title.tagName).toBe('H6');
63
+ expect(title.classList.contains('MuiTypography-h6')).toBe(true);
64
+ });
65
+
66
+ it('renders the title at an overridden typography variant', () => {
67
+ renderFrame({ titleVariant: 'subtitle2' });
68
+
69
+ const title = screen.getByText('Chart title');
70
+ expect(title.classList.contains('MuiTypography-subtitle2')).toBe(true);
71
+ expect(title.classList.contains('MuiTypography-h6')).toBe(false);
72
+ });
73
+
74
+ it('leaves the subtitle, toolbar, and footer unchanged for either title variant', () => {
75
+ for (const titleVariant of [undefined, 'subtitle2']) {
76
+ const view = renderFrame({
77
+ titleVariant,
78
+ toolbar: <button type="button">Period selector</button>,
79
+ meta: 'Panel metadata',
80
+ exportOptions: true,
81
+ });
82
+
83
+ const subtitle = screen.getByText('Chart subtitle');
84
+ expect(subtitle.tagName).toBe('P');
85
+ expect(subtitle.classList.contains('MuiTypography-body2')).toBe(true);
86
+ expect(screen.getByRole('button', { name: 'Period selector' })).toBeTruthy();
87
+
88
+ const metaNode = screen.getByText('Panel metadata');
89
+ expect(metaNode.classList.contains('MuiTypography-caption')).toBe(true);
90
+ const foot = metaNode.parentElement;
91
+ expect(foot.contains(screen.getByRole('button', { name: 'Export SVG' }))).toBe(true);
92
+ expect(foot.contains(screen.getByRole('button', { name: 'Export PNG' }))).toBe(true);
93
+ expect(window.getComputedStyle(foot).justifyContent).toBe('space-between');
94
+ expect(window.getComputedStyle(foot).borderTopStyle).toBe('solid');
95
+
96
+ view.unmount();
97
+ }
98
+ });
99
+
58
100
  it('renders its title, subtitle, toolbar, and accessible chart region', () => {
59
101
  renderFrame({ toolbar: <button type="button">Period selector</button> });
60
102
 
@@ -90,7 +90,11 @@ describe('NotificationsProvider', () => {
90
90
  expect.objectContaining({ id: 1, notification_id: 101, seen: false }),
91
91
  expect.objectContaining({ id: 2, notification_id: 102, dismissed: false }),
92
92
  ]));
93
- expect(MockWebSocket.instances).toHaveLength(1);
93
+ // `seeded` (which gates the socket connect) flips in refresh()'s .finally(),
94
+ // one tick after unreadCount is set -- so the socket can still be unopened
95
+ // right when the unread-count waitFor above resolves. Wait for it directly
96
+ // instead of asserting synchronously.
97
+ await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1));
94
98
  });
95
99
 
96
100
  it('prepends a new notification push and increments the unread count', async () => {
@@ -121,6 +121,24 @@ describe('chart chrome defaults', () => {
121
121
  expect(props.slotProps.legend.position).toEqual(legendPosition);
122
122
  });
123
123
 
124
+ it('adds a caller-requested vertical grid to the default horizontal grid', () => {
125
+ renderLine({ grid: { vertical: true } });
126
+
127
+ expect(lineSpy.mock.calls.at(-1)[0].grid).toEqual({
128
+ horizontal: true,
129
+ vertical: true,
130
+ });
131
+ });
132
+
133
+ it('lets a caller switch off both grid directions explicitly', () => {
134
+ renderBar({ grid: { horizontal: false, vertical: false } });
135
+
136
+ expect(barSpy.mock.calls.at(-1)[0].grid).toEqual({
137
+ horizontal: false,
138
+ vertical: false,
139
+ });
140
+ });
141
+
124
142
  // Found live in the dev harness (not by this mocked test, which cannot
125
143
  // reproduce a real MUI scale computation): a caller-supplied xAxis with
126
144
  // `data` but no `scaleType` lost the wrapper's default scaleType entirely,
@@ -170,8 +188,21 @@ describe('chart chrome defaults', () => {
170
188
  });
171
189
 
172
190
  cleanup();
173
- const requested = { vertical: 'middle', horizontal: 'end' };
191
+ const requested = { vertical: 'middle' };
174
192
  renderBar({ series: [{ data: [1] }, { data: [2] }], legendPosition: requested });
175
- expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual(requested);
193
+ expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
194
+ vertical: 'middle',
195
+ horizontal: 'start',
196
+ });
197
+
198
+ cleanup();
199
+ renderBar({
200
+ series: [{ data: [1] }, { data: [2] }],
201
+ slotProps: { legend: { position: { horizontal: 'end' } } },
202
+ });
203
+ expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
204
+ vertical: 'bottom',
205
+ horizontal: 'end',
206
+ });
176
207
  });
177
208
  });
@@ -17,11 +17,18 @@ describe('createAppTheme', () => {
17
17
  // MUI's createTheme(options, ...args) only augments the FIRST argument's
18
18
  // palette; every later arg is deep-merged raw, so a status entry missing
19
19
  // `main`/`contrastText` silently keeps MUI's stock hue for color="success"
20
- // etc. -- main/contrastText must equal the canonical fill/fillText tones.
21
- for (const status of ['success', 'warning', 'error', 'info']) {
20
+ // etc. -- main/contrastText must be explicitly set and clear AA as a
21
+ // foreground (THEME-2: `warning` deliberately breaks the main===fill
22
+ // pattern success/error/info follow -- amber cannot serve both a legible
23
+ // fill AND a legible foreground with one hex, so `warning.main` is the
24
+ // darker `text` tone instead, `warning.fill` stays the lighter amber for
25
+ // deliberate fill use).
26
+ for (const status of ['success', 'error', 'info']) {
22
27
  expect(theme.palette[status].main).toBe(theme.palette[status].fill);
23
28
  expect(theme.palette[status].contrastText).toBe(theme.palette[status].fillText);
24
29
  }
30
+ expect(theme.palette.warning.main).toBe(theme.palette.warning.text);
31
+ expect(theme.palette.warning.contrastText).toBe('#FFFFFF');
25
32
  });
26
33
 
27
34
  it('throws when palette.primary is missing', () => {
@@ -60,4 +60,35 @@ describe('theme completeness', () => {
60
60
  expect(assertThemeComplete(theme).findings.map(({ surface }) => surface))
61
61
  .toContain('contrast.success.text-on-bg');
62
62
  });
63
+
64
+ it('requires every MUI status main to clear AA on white and the page background', () => {
65
+ const theme = createAppTheme({ palette: { primary: { main: '#8AB4F8' } } });
66
+
67
+ for (const status of ['success', 'warning', 'error', 'info']) {
68
+ expect(calculateContrastRatio(theme.palette[status].main, '#FFFFFF')).toBeGreaterThanOrEqual(4.5);
69
+ expect(calculateContrastRatio(
70
+ theme.palette[status].main,
71
+ theme.palette.background.default,
72
+ )).toBeGreaterThanOrEqual(4.5);
73
+ }
74
+
75
+ theme.palette.warning.main = '#C08A2C';
76
+ expect(assertThemeComplete(theme).findings.map(({ surface }) => surface)).toEqual(
77
+ expect.arrayContaining([
78
+ 'contrast.warning.main-on-white',
79
+ 'contrast.warning.main-on-page',
80
+ ]),
81
+ );
82
+ });
83
+
84
+ it('reports main contrast findings without hard-failing for a non-adopting app theme', () => {
85
+ const result = assertThemeComplete(createTheme());
86
+
87
+ expect(result.findings.map(({ surface }) => surface)).toEqual(
88
+ expect.arrayContaining([
89
+ 'contrast.warning.main-on-white',
90
+ 'contrast.warning.main-on-page',
91
+ ]),
92
+ );
93
+ });
63
94
  });
@@ -0,0 +1,175 @@
1
+ # CHART-7 — `ChartFrame` gains a `titleVariant` prop
2
+
3
+ **Target repo:** `ui-core-micha` (branch `main` — this repo has no `develop`)
4
+ **Tier:** 2 (shared core)
5
+ **Review:** independent `reviewer` **and** `ui_reviewer` — spawned concurrently, both mandatory before commit
6
+ **Version target:** `2.32.0` — minor: a new public prop is additive API under semver, consistent with how `SHELL-2` classified `extraTabs` gaining a field
7
+ **Precondition of:** `fitness-monitor`'s `FM-12` (DS-10)
8
+
9
+ Short-form WO: one prop, one default, no design question.
10
+
11
+ ---
12
+
13
+ ## A. Envelope
14
+
15
+ ### Goal
16
+
17
+ `ChartFrame` hard-codes its title as `<Typography variant="h6">` (`src/components/charts/ChartFrame.jsx:74`)
18
+ and exposes no way to change it. Add a `titleVariant` prop, defaulting to `'h6'` so nothing changes for
19
+ any existing caller.
20
+
21
+ ### Why this is a kit gap and not one app's problem
22
+
23
+ A card title and a **panel-grid** title are different typographic jobs. In a dense grid the titles must
24
+ recede so they do not compete with the one chart that matters; at `h6` they do not. Concretely:
25
+ fitness-monitor renders six body-composition panels next to one hero chart, and its accepted prototype
26
+ specifies `13px/600` for those six against `15px/600` for the hero
27
+ (`fitness-monitor/work-orders/assets/FM-8-redesign.html:237` vs `:184`). Today all seven render at the
28
+ same `h6`, and **six oversized titles beside a hero chart was one of the original "reads heavier and
29
+ older" findings** that started this programme.
30
+
31
+ The app cannot reach it: there is no prop, and `ChartFrame`'s title is not exposed through `slotProps`
32
+ either. Any app with a dense panel grid hits the same wall — hram's results tabs are the obvious next
33
+ one — so this belongs in the kit rather than being worked around per app.
34
+
35
+ ### Scope
36
+
37
+ - `ChartFrame` accepts `titleVariant`, forwarded to the title `Typography`. **Default `'h6'`.**
38
+ - Keep it minimal: **one prop, not a `slotProps.title` system.** There is one known need; a general slot
39
+ mechanism for one consumer is the speculative generality this estate's promotion discipline rejects.
40
+ If a second, different need appears, that is when the shape gets revisited.
41
+
42
+ ### Non-goals / do not touch
43
+
44
+ - The subtitle, toolbar, controls, loading/error/empty slots and the export footer — untouched.
45
+ - `ChartFrame`'s `p: 2` padding and its foot row: **byte-for-byte unchanged.** 16 panels depend on them.
46
+ - The baseline's `h6` value (16px/600). This WO changes *which* variant a caller may pick, not what the
47
+ variants are.
48
+ - The chart wrappers. No `LineChart`/`BarChart`/`TimeSeriesChart` change.
49
+ - No behaviour, permission or data-contract change.
50
+
51
+ ### Risks
52
+
53
+ - **`ChartFrame` is the estate's most-used chart primitive** — five of hram's six hand-drawn panels use
54
+ it, plus every wrapper chart. The default must remain `'h6'`; a changed default would silently restyle
55
+ every title in the estate on the next pin bump.
56
+ - **No staging net.** A push to `main` touching `src/**` publishes to npm at once; the independent review
57
+ is the only gate and is not back-fillable.
58
+
59
+ ### Required tests to WRITE
60
+
61
+ Extend the existing `ChartFrame` spec rather than adding a file.
62
+
63
+ 1. With no `titleVariant`, the title renders as `h6` — **prove non-vacuity** by changing the default and
64
+ confirming this fails. This is the regression guard for all 16 existing panels.
65
+ 2. `titleVariant="subtitle2"` renders the title at that variant.
66
+ 3. The subtitle, toolbar and footer render unchanged in both cases.
67
+
68
+ Plus `tsc -p tsconfig.build.json --noEmit` clean.
69
+
70
+ **No full-suite run.** Affected-areas set is the `ChartFrame` spec.
71
+
72
+ ### Verification
73
+
74
+ The rendered two-width side-by-side per DS-1, showing a `ChartFrame` at the default and one with an
75
+ overridden variant side by side — so the "default unchanged" claim is visible and not only asserted.
76
+
77
+ ### Preconditions
78
+
79
+ None. `THEME-2` (`2.31.1`) is the current published version.
80
+
81
+ ### Parity guardrail
82
+
83
+ Purely additive. Every existing caller must render identically — that is what test 1 guards.
84
+
85
+ ---
86
+
87
+ ## B. Implementation map
88
+
89
+ *Filled by the Orchestrator on `git pull` — see AGENTS.md → "Work Order".*
90
+
91
+ ### Execution directive (read this first)
92
+
93
+ > **If you are the implementer reading this work order as your own specification: this section is
94
+ > NOT addressed to you.** It tells the Orchestrator how to invoke you. **You ARE that invocation —
95
+ > do NOT shell out to `codex exec`.**
96
+ >
97
+ > Implement through `codex exec` in the background — invoked directly via Bash (never the
98
+ > `debugger`/`*_coder` Agent wrappers) with BOTH flags `--skip-git-repo-check` and
99
+ > `--dangerously-bypass-approvals-and-sandbox`. **Pass the WO via stdin, not as a positional
100
+ > argument** — `cat work-orders/CHART-7.md | codex exec --skip-git-repo-check
101
+ > --dangerously-bypass-approvals-and-sandbox -` (a positional arg hits Windows' command-line length
102
+ > limit — proven repeatedly in this repo). Fallback to direct Claude implementation only on Codex
103
+ > quota/rate-limit/non-zero exit.
104
+
105
+ ### Context package
106
+
107
+ Verified 2026-08-10, current state: `src/components/charts/ChartFrame.jsx:17-35` (prop destructure)
108
+ and `:74` (the hardcoded title):
109
+ ```jsx
110
+ export function ChartFrame({
111
+ title,
112
+ subtitle,
113
+ toolbar,
114
+ ...
115
+ variant = 'outlined',
116
+ sx,
117
+ }) {
118
+ ...
119
+ <Typography id={titleId} variant="h6">{title}</Typography>
120
+ ```
121
+
122
+ **Named file to change:** add `titleVariant = 'h6'` to the destructured props (alongside `variant =
123
+ 'outlined'`, same style) and change line 74 to `<Typography id={titleId} variant={titleVariant}>
124
+ {title}</Typography>`. That is the entire implementation — no other file needs to change (the WO is
125
+ explicit: no `slotProps` system, no wrapper changes).
126
+
127
+ **Test file:** `tests/ChartFrame.test.jsx` — extend, don't create a new file. Read the file in full
128
+ first (it already has an i18n/theme harness at the top, shown above) and follow its existing render
129
+ helper/assertion style. Add the 3 required tests: (1) default renders `variant="h6"` on the title
130
+ `Typography` — assert via the rendered DOM (e.g. the title element's tag is `<h6>` or its MUI class
131
+ contains `MuiTypography-h6`), proven non-vacuous by temporarily changing the default to something
132
+ else and confirming the test fails, then restoring it; (2) `titleVariant="subtitle2"` renders the
133
+ title at that variant; (3) subtitle/toolbar/footer render unchanged in both the default and
134
+ overridden case (a plain equality/snapshot-style check between the two renders' non-title markup, or
135
+ explicit assertions that subtitle/toolbar/footer content and structure are present and identical —
136
+ your call on the exact mechanism, the WO doesn't prescribe one).
137
+
138
+ ### Do-not-touch / invariants
139
+
140
+ - `ChartFrame`'s `p: 2` padding and its foot row — byte-for-byte unchanged.
141
+ - The baseline's `h6` value itself (16px/600, from THEME-1) — untouched; this WO changes which
142
+ variant a caller may pick, not what the variants are.
143
+ - No `LineChart`/`BarChart`/`TimeSeriesChart` change.
144
+ - Subtitle, toolbar, controls, loading/error/empty slots, export footer — untouched behaviour.
145
+
146
+ ### Target repo working directory (absolute)
147
+
148
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`
149
+
150
+ ### Required tests to WRITE (Codex writes them; the Orchestrator runs them)
151
+
152
+ Exactly the 3 tests above in `tests/ChartFrame.test.jsx`, plus `tsc -p tsconfig.build.json --noEmit`
153
+ clean. No full-suite run — affected-areas set is the `ChartFrame` spec alone.
154
+
155
+ ### Preamble (append verbatim)
156
+
157
+ > The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there
158
+ > is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`, and the
159
+ > app `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/deps/schema/CI
160
+ > unless the spec says so; do not update `MEMORY.md`. Do NOT `git add`/`commit`/`push` — leave every
161
+ > change uncommitted in the working tree for the orchestrator's independent review. WRITE the tests
162
+ > the `Required tests` section calls for AND **RUN the tests you just wrote** to confirm they execute
163
+ > and pass — that is the ONLY test run you do (NOT the app's affected/full suite, NOT any review).
164
+ > The orchestrator re-runs the authoritative set + does the independent review after you finish —
165
+ > those are the gate; your own run does not count as the gate.
166
+ >
167
+ > Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
168
+ > `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
169
+ > completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
170
+ > `RESULT: DONE|BLOCKED <reason>`.
171
+
172
+ ### Mini-handover
173
+
174
+ Repo: `ui-core-micha` (`C:\Users\biglmi\Documents\webapps\ui-core-micha`), branch `main`. WO:
175
+ `work-orders/CHART-7.md`. Follow `orchestrate-codex`.
@@ -0,0 +1,339 @@
1
+ # THEME-2 — Two defects from the THEME-1/CHART-6 landings: `warning.main` contrast, and `grid` replacing instead of merging
2
+
3
+ **Target repo:** `ui-core-micha` (branch `main` — this repo has no `develop`)
4
+ **Tier:** 2 (shared core — `ui-core-micha` is named in AGENTS.md's Tier-2 forcing list)
5
+ **Review:** independent `reviewer` **and** `ui_reviewer` — spawned concurrently, both mandatory before commit
6
+ **Version target:** `2.31.1` (patch — one palette value corrected, one merge bug fixed, one assertion rule added; no API change)
7
+ **Decision record:** `webapp-management/DESIGN_SYSTEM_PROGRAM.md` → "Open follow-ups from the two landings"
8
+
9
+ Short-form WO: both defects are fully diagnosed and measured. There is no design question in either.
10
+
11
+ ---
12
+
13
+ ## A. Envelope
14
+
15
+ ### Goal
16
+
17
+ Fix the two defects found after `THEME-1` (`96992cf`) and `CHART-6` (`6dedc62`) landed, and in each
18
+ case close the **mechanism** rather than only the instance — both are the same failure class as
19
+ something already fixed, which is why they recurred.
20
+
21
+ ### Defect 1 — `warning.main` fails AA as a foreground
22
+
23
+ THEME-1's R1 fix gave every status channel an explicit `main`/`light`/`dark`/`contrastText` (correct
24
+ — MUI was otherwise filling its stock hues for `color="success"` and friends). It put the **fill**
25
+ tone in `main`. Measured against `#FFFFFF`:
26
+
27
+ | Channel | `main` | as foreground | |
28
+ |---|---|---|---|
29
+ | success | `#1B8038` | 5.010 | passes |
30
+ | error | `#BF3227` | 5.674 | passes |
31
+ | **warning** | `#C08A2C` | **3.038** | **fails AA (4.5)** |
32
+
33
+ `main`/`contrastText` pairs are all fine (warning 5.078 with dark ink). The failure is only when
34
+ `main` is used as a **foreground on a light surface** — `<Typography color="warning.main">`,
35
+ `color="warning"` on an icon, and an outlined warning chip, whose border and label both come from
36
+ `main`.
37
+
38
+ **Why warning specifically, and why this is not carelessness:** green and red can serve both roles
39
+ with one value. Amber cannot. A legible amber **fill** needs dark text on it and must therefore stay
40
+ light; a legible amber **text** must be dark. The two requirements pull in opposite directions.
41
+ Warning is the one channel where "one hex for both roles" is impossible in principle — so the
42
+ pattern that works for success and error had to break here.
43
+
44
+ **Fix:** `warning.main` = `#976100` (the text tone already in the same token block, 5.216 on white),
45
+ `contrastText` = `#FFFFFF`. The light amber stays available as `warning.fill` for deliberate fill
46
+ use, unchanged.
47
+
48
+ **Accepted visual consequence, stated so nobody treats it as a regression:** a filled
49
+ `color="warning"` Button/Chip becomes a darker brown with white text instead of a lighter amber with
50
+ dark text. That is the price of one token serving MUI's `main` contract, and it is the operator's
51
+ call already recorded in the programme doc.
52
+
53
+ **Close the mechanism, not just the value:** add an assertion rule that checks **every** status
54
+ channel's `main` against `#FFFFFF` and `background.default` at 4.5:1. Without it the next channel
55
+ added repeats this exactly — the existing contrast checks were designed before R1 introduced `main`,
56
+ which is why they did not catch it.
57
+
58
+ ### Defect 2 — `grid` is replaced, not merged
59
+
60
+ `LineChart` and `BarChart` declare `grid = { horizontal: true }` as a plain default parameter and
61
+ pass it straight through. A caller writing `grid={{ vertical: true }}` — intending to *add* vertical
62
+ lines — silently loses the horizontal ones.
63
+
64
+ This is the same class as the `scaleType` bug the rendered gate caught during CHART-6: **a partial
65
+ caller object erases a default.** `xAxis`, `yAxis`, `margin` and `slotProps` all go through merge
66
+ helpers; `grid` alone does not.
67
+
68
+ **Fix:** merge `grid` with the default the way the sibling props already do, so a caller adds to it
69
+ rather than replacing it.
70
+
71
+ **Close the mechanism:** audit the remaining object-valued props the wrappers default or forward
72
+ (`slots`, `tickLabelStyle` and anything CHART-6 introduced) for the same replace-instead-of-merge
73
+ shape, and state the result — either "all others already merge" or a named list. **Audit and report;
74
+ do not refactor beyond `grid`** unless the audit finds another live instance, in which case fix that
75
+ too and say so.
76
+
77
+ ### Non-goals / do not touch
78
+
79
+ - The other status channels' `main` values — success and error measure fine and stay.
80
+ - `warning.fill` / `warning.text` / `warning.bg` — unchanged.
81
+ - The reference sheet's committed HTML artifact. It is the frozen spec of the accepted baseline;
82
+ `warning.main` is a channel the sheet never depicted (see Verification).
83
+ - Anything in `CHART-6`'s scope beyond the `grid` merge and the audit.
84
+ - No behaviour, permission or data-contract change.
85
+
86
+ ### Risks
87
+
88
+ - **A palette value change is visible.** Any app already using `color="warning"` sees a darker
89
+ filled control. No app has an adoption WO yet, so the blast radius today is ucm's own harness — but
90
+ say it in the release note rather than let someone find it.
91
+ - **Adding an assertion rule can turn a green suite red.** Safe today by the ratchet: the
92
+ completeness check is a hard failure only for an app whose adoption WO has landed, and none has.
93
+ It must stay that way — the new rule must not become hard for non-adopters.
94
+ - **No staging net.** A push to `main` touching `src/**` publishes to npm at once; the independent
95
+ review is the only gate and is not back-fillable.
96
+ - **The `grid` merge changes behaviour for any caller who passes a partial `grid` today.** That is
97
+ the point, but it means the rendered check should look at a chart that passes `grid` explicitly if
98
+ one exists.
99
+
100
+ ### Required tests to WRITE
101
+
102
+ Narrow. Extend the existing files rather than adding new ones.
103
+
104
+ **`tests/themeCompleteness.test.js`**
105
+
106
+ 1. Every status channel's `main` clears 4.5:1 against `#FFFFFF` **and** against
107
+ `background.default`. **Prove non-vacuity** by restoring `warning.main` to `#C08A2C` and
108
+ confirming this test fails — that is the whole point of the rule.
109
+ 2. The new rule reports, and does not hard-fail, for a theme belonging to an app without a landed
110
+ adoption WO (the ratchet stays intact).
111
+
112
+ **`tests/chartDefaults.test.js`**
113
+
114
+ 3. A caller passing `grid={{ vertical: true }}` receives **both** `vertical: true` and the default
115
+ `horizontal: true`. **Prove non-vacuity** by reverting to the plain default parameter and
116
+ confirming the test fails.
117
+ 4. A caller passing `grid={false}` (or the documented way to switch the grid off entirely) still
118
+ gets no grid — merging must not make the default un-removable.
119
+
120
+ Plus `tsc -p tsconfig.build.json --noEmit` clean.
121
+
122
+ **No full-suite run.** Affected-areas set is the two test files above.
123
+
124
+ ### Verification
125
+
126
+ - The rendered two-width side-by-side per DS-1, **and this time it must cover what changed**: the
127
+ harness needs a specimen row rendering `color="success|warning|error|info"` on **Button, Chip and
128
+ Alert** — the components where `main` actually resolves. It has none today, which is precisely why
129
+ the gate could not catch defect 1 the first time. Adding that row is part of this WO.
130
+ - A chart specimen that passes a partial `grid`, to show the merge.
131
+
132
+ ### Preconditions
133
+
134
+ - `THEME-1` (`2.30.0`) and `CHART-6` (`2.31.0`) landed and published.
135
+
136
+ ### Parity guardrail
137
+
138
+ No behaviour, permission or data-contract change. One palette value moves deliberately and visibly;
139
+ everything else must render identically.
140
+
141
+ ---
142
+
143
+ ## B. Implementation map
144
+
145
+ *Filled by the Orchestrator on `git pull` — see AGENTS.md → "Work Order".*
146
+
147
+ ### Execution directive (read this first)
148
+
149
+ > **If you are the implementer reading this work order as your own specification: this section is
150
+ > NOT addressed to you.** It tells the Orchestrator how to invoke you. **You ARE that invocation —
151
+ > do NOT shell out to `codex exec`.**
152
+ >
153
+ > Implement through `codex exec` in the background — invoked directly via Bash (never the
154
+ > `debugger`/`*_coder` Agent wrappers) with BOTH flags `--skip-git-repo-check` and
155
+ > `--dangerously-bypass-approvals-and-sandbox`. **Pass the WO via stdin, not as a positional
156
+ > argument** — `cat work-orders/THEME-2.md | codex exec --skip-git-repo-check
157
+ > --dangerously-bypass-approvals-and-sandbox -` (a positional arg hits Windows' command-line length
158
+ > limit on a WO this size — proven on THEME-1/CHART-6). Fallback to direct Claude implementation
159
+ > only on Codex quota/rate-limit/non-zero exit.
160
+
161
+ ### Context package
162
+
163
+ **Preconditions verified 2026-08-10:** `THEME-1` (`96992cf`) and `CHART-6` (`6dedc62`) are both
164
+ landed on `main` and published (`2.30.0`, `2.31.0`).
165
+
166
+ **Defect 1 — exact current state, verified in `src/theme/tokens.js:81-87`:**
167
+ ```js
168
+ warning: {
169
+ ...withMainShades('#C08A2C', '#212529'),
170
+ text: '#976100',
171
+ fill: '#C08A2C',
172
+ fillText: '#212529',
173
+ bg: '#FBF0DC',
174
+ },
175
+ ```
176
+ `withMainShades(main, contrastText)` (defined near the top of `tokens.js`) sets `main`/`light`/`dark`/
177
+ `contrastText` via MUI's own `lighten`/`darken` at 0.2/0.3 tonal offsets. **Fix:** change the call to
178
+ `withMainShades('#976100', '#FFFFFF')` — reusing the existing `text` value as the new `main`, and
179
+ `'#FFFFFF'` as `contrastText` (verify this actually clears 4.5:1 — the WO states 5.216 on white
180
+ already for `#976100`, and `contrastText` white-on-`#976100` is the same ratio by symmetry). Leave
181
+ `text`/`fill`/`fillText`/`bg` untouched — `fill` stays the lighter amber for deliberate fill use
182
+ (THEME-1's own `MuiAlert` `standardWarning` styleOverride already reads `palette.warning.text`/`.bg`,
183
+ NOT `.main`, so that specimen is unaffected by this change — see the harness note below for why a
184
+ NEW specimen is needed to actually exercise `.main`).
185
+
186
+ **Defect 1's assertion rule — add to `src/theme/themeCompleteness.js`'s `contrastFindings(theme)`
187
+ function (~line 249).** Alongside the existing per-status `text`-vs-`bg` and `fillText`-vs-`fill`
188
+ loops, add a third: for each `status` in `STATUS_KEYS` (`['success','warning','error','info',
189
+ 'stale']`, from `tokens.js`) that HAS a `.main` property (**`stale` does not** — it's a custom
190
+ augmented namespace, never passed through MUI's `createPalette`/`augmentColor`, so
191
+ `theme.palette.stale.main` is `undefined` — skip it, don't push a spurious finding for a channel
192
+ that was never supposed to have one), check `calculateContrastRatio(tokens.main, '#FFFFFF')` and
193
+ `calculateContrastRatio(tokens.main, theme.palette.background.default)`, both `>= 4.5`, pushing a
194
+ `contrast.<status>.main-on-<white|page>` finding (matching the existing naming convention,
195
+ `contrast.controlBorder.<state>-on-<surface>`) on failure. This is a REPORT via the existing
196
+ `findings` array — `assertThemeComplete` never throws or hard-fails on its own (confirm this by
197
+ reading the function; the "ratchet stays a report, not a hard fail for non-adopters" is structural
198
+ here, not a flag to add — a consuming app's OWN test suite decides whether to assert
199
+ `findings.length === 0`, ucm's export just returns data either way).
200
+
201
+ **Defect 2 — exact current state, verified in `src/components/charts/BarChart.jsx:26` and
202
+ `LineChart.jsx:43`:** `grid = { horizontal: true }` as a bare JS default parameter, then
203
+ `grid={grid}` passed straight through (`BarChart.jsx:65`, `LineChart.jsx:88`) — a caller-supplied
204
+ `grid` object REPLACES the default wholesale rather than merging per-key, unlike every sibling prop
205
+ in this file (compare `withAxisDefaults`'s `tickLabelStyle` merge, `withChartSlotDefaults`'s
206
+ `tooltip`/`legend` merge — both spread the default first, caller second, so the caller only
207
+ overrides the keys it actually sets). **Fix:** add a `withGridDefaults(grid)` helper to
208
+ `src/components/charts/chartDefaults.js` following the SAME pattern (`{ horizontal: true, ...grid }`
209
+ — MUI's `grid` prop type is `Pick<ChartsGridProps, 'vertical'|'horizontal'>`, plain optional
210
+ booleans, no special `false`/`true` shorthand at the type level, so spreading a caller's object
211
+ after the default is sufficient: a caller wanting no grid at all passes
212
+ `grid={{ horizontal: false, vertical: false }}` explicitly, which the spread correctly honours since
213
+ it's a later key overriding an earlier one — do not add special-casing for a literal `grid={false}`
214
+ unless you find MUI's actual runtime prop handling treats a boolean differently from the declared
215
+ type, which the WO's own phrasing ("or the documented way to switch the grid off entirely") already
216
+ anticipates might not be a literal `false`). Wire it into both `BarChart.jsx` and `LineChart.jsx`
217
+ the same way the other `chartDefaults.js` helpers are used.
218
+
219
+ **Defect 2's audit — read `src/components/charts/chartDefaults.js` in full and check each
220
+ object-valued prop the wrappers default or forward against the same replace-vs-merge question:**
221
+ - `withAxisDefaults`'s `tickLabelStyle: { fontSize: tickFontSize, ...axis.tickLabelStyle }` —
222
+ ALREADY merges correctly (default first, caller's own `tickLabelStyle` fields win per-key).
223
+ - `withChartSlotDefaults`'s `{ ...slotProps, tooltip: {...}, legend: {...} }` — the outer
224
+ `...slotProps` spread preserves any OTHER slot key a caller sets (e.g. `slotProps.axisHighlight`),
225
+ and the `tooltip`/`legend` sub-objects are themselves merged default-first. ALREADY correct.
226
+ - `LineChart.jsx`'s `slots={{ mark: FilledMarkElement, ...slots }}` — default first, caller's own
227
+ `slots` spread after, so a caller's own `slots.mark` wins and other slot keys survive. ALREADY
228
+ correct.
229
+ - `spaceForRotatedTicks`'s `margin` handling is NOT a "default object caller can partially override"
230
+ shape at all (it only computes `margin.bottom` when rotation is detected AND the caller hasn't set
231
+ one) — not the same bug class, nothing to fix there, but confirm this reading is right by re-reading
232
+ the function rather than trusting this summary.
233
+ **Verify each of these four independently rather than trusting this list — this map is a starting
234
+ point for the audit, not a substitute for it.** If the audit finds anything actually broken beyond
235
+ `grid`, fix it and say so explicitly in the PR/commit, per the WO's "fix that too and say so." If the
236
+ audit confirms all four are already correct, state that explicitly too — "audited and confirmed
237
+ correct" is a valid, expected outcome per the WO ("either... or a named list").
238
+
239
+ **Harness — why a new specimen row is required, not optional (verified 2026-08-10):** the current
240
+ `ThemeBaselineEntry` (`dev/entries.jsx`) has TWO status-colour specimens today, and NEITHER exercises
241
+ `palette.<status>.main`:
242
+ 1. `StatusChip` (`dev/entries.jsx:71-78`) sets `sx={{ bgcolor: '${tone}.fill', color:
243
+ '${tone}.fillText' }}` directly — bypasses `.main` entirely by design.
244
+ 2. `<Alert severity="warning">` etc. (`dev/entries.jsx:136-140`) — THEME-1's `MuiAlert`
245
+ `styleOverrides.standardWarning` (`src/theme/createAppTheme.js`'s `createPaletteAwareComponents`)
246
+ already overrides the STANDARD variant's `color`/`backgroundColor` to `palette.warning.text`/
247
+ `.bg` directly, NOT `.main` — so this existing Alert row was already immune to defect 1 and would
248
+ not have caught it even now.
249
+ Add: a row of `<Button color="success|warning|error|info" variant="contained">` (MUI's contained
250
+ Button reads `palette[color].main` for its background and `.contrastText` for its label — this is
251
+ the most direct `.main` exposure in the kit), a row of native `<Chip color="warning">` (not
252
+ `StatusChip` — MUI's own `color` prop on `Chip`, filled variant, also reads `.main`/
253
+ `.contrastText` directly), and an `<Alert severity="warning" variant="filled">` (MUI's `filled`
254
+ Alert variant is NOT covered by THEME-1's `standardWarning`-only override, so it still resolves
255
+ `.main` — confirm this by checking `createPaletteAwareComponents`'s `alertStyles` only sets
256
+ `standard${Status}` keys, never `filled${Status}` or `outlined${Status}`). These three together
257
+ are what the WO's Verification section means by "Button, Chip and Alert — the components where
258
+ `main` actually resolves."
259
+
260
+ **Chart specimen for the `grid` merge:** the existing "Caller overrides" `LineChart` specimen
261
+ (`dev/entries.jsx`, added in CHART-6) already sets `grid={{ horizontal: true, vertical: true }}` — a
262
+ COMPLETE object, which happens to already include `horizontal: true`, so it would not visibly
263
+ demonstrate the bug even pre-fix. Change it to a genuinely PARTIAL grid, e.g. `grid={{ vertical:
264
+ true }}` alone — pre-fix this would have shown ONLY vertical lines (losing the horizontal default);
265
+ post-fix it must show both. This is the specimen the WO's Verification section calls for ("A chart
266
+ specimen that passes a partial `grid`, to show the merge").
267
+
268
+ ### Named files to change
269
+
270
+ - `src/theme/tokens.js` — the `warning` entry's `withMainShades(...)` call (line 82).
271
+ - `src/theme/themeCompleteness.js` — `contrastFindings` (~line 249), add the `main`-vs-white/page
272
+ loop, skipping any `STATUS_KEYS` entry without a `.main`.
273
+ - `src/components/charts/chartDefaults.js` — new `withGridDefaults` export.
274
+ - `src/components/charts/BarChart.jsx:26,65`, `src/components/charts/LineChart.jsx:43,88` — wire in
275
+ `withGridDefaults`.
276
+ - `dev/entries.jsx` — new Button/Chip/Alert `.main`-exercising specimen row; fix the existing
277
+ "Caller overrides" LineChart's `grid` prop to a genuinely partial object.
278
+ - `package.json` — version `2.31.0` → `2.31.1` (patch).
279
+ - `tests/themeCompleteness.test.js` — extend with the 2 required tests (main contrast + non-vacuity
280
+ proof by temporarily reverting `warning.main`; ratchet/report-not-hard-fail confirmation).
281
+ - `tests/chartDefaults.test.js` — extend with the 2 required tests (partial-grid merge + non-vacuity
282
+ proof by temporarily reverting to the bare default parameter; grid-off-via-explicit-false-object
283
+ still works).
284
+
285
+ ### Do-not-touch / invariants
286
+
287
+ - `success`/`error`/`info`/`stale` palette values — untouched, they measure fine.
288
+ - `warning.fill`/`.text`/`.bg` — untouched.
289
+ - The committed reference-sheet HTML artifact (`work-orders/assets/THEME-1-baseline-reference-sheet.html`)
290
+ — frozen spec, never depicted `warning.main` as a foreground, not touched by this WO.
291
+ - Anything in CHART-6's scope beyond the `grid` merge and the audit — no new chart-chrome feature
292
+ work here.
293
+ - No behaviour, permission, or data-contract change.
294
+
295
+ ### Pitfalls (verified against landed code 2026-08-10)
296
+
297
+ - `withMainShades` is defined once near the top of `tokens.js` and used for
298
+ `success`/`warning`/`error`/`info` — changing ONLY the `warning` call site, not the helper itself
299
+ (the helper's `lighten`/`darken` derivation logic is correct and unrelated to this bug).
300
+ - `STATUS_KEYS` includes `'stale'`, which has no MUI-augmented `.main` — a naive loop over all
301
+ `STATUS_KEYS` reading `tokens.main` will hit `undefined` for `stale`; guard it (`if (!tokens?.main)
302
+ continue`, matching the existing loop's `if (!tokens) continue` pattern one function up).
303
+ - Don't confuse this WO's `contrastFindings` addition with `THEME_COMPLETENESS_SURFACES`'s existing
304
+ `STATUS_SURFACES` (`text`/`fill`/`fillText`/`bg` presence-vs-default check) — the new rule is a
305
+ CONTRAST check (numeric ratio), same family as the existing `contrast.<status>.text-on-bg` /
306
+ `contrast.controlBorder.*` findings, not a "surface is deliberately defined" presence check.
307
+
308
+ ### Target repo working directory (absolute)
309
+
310
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha`
311
+
312
+ ### Required tests to WRITE (Codex writes them; the Orchestrator runs them)
313
+
314
+ Exactly the 4 tests enumerated in Envelope § "Required tests to WRITE" above, extending
315
+ `tests/themeCompleteness.test.js` and `tests/chartDefaults.test.js` (no new files), plus
316
+ `tsc -p tsconfig.build.json --noEmit` clean. Do not add more, do not run the full suite. Affected-areas
317
+ set is exactly those two files.
318
+
319
+ ### Preamble (append verbatim)
320
+
321
+ > The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there
322
+ > is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`, and the
323
+ > app `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/deps/schema/CI
324
+ > unless the spec says so; do not update `MEMORY.md`. Do NOT `git add`/`commit`/`push` — leave every
325
+ > change uncommitted in the working tree for the orchestrator's independent review. WRITE the tests
326
+ > the `Required tests` section calls for AND **RUN the tests you just wrote** to confirm they execute
327
+ > and pass — that is the ONLY test run you do (NOT the app's affected/full suite, NOT any review).
328
+ > The orchestrator re-runs the authoritative set + does the independent review after you finish —
329
+ > those are the gate; your own run does not count as the gate.
330
+ >
331
+ > Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
332
+ > `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
333
+ > completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
334
+ > `RESULT: DONE|BLOCKED <reason>`.
335
+
336
+ ### Mini-handover
337
+
338
+ Repo: `ui-core-micha` (`C:\Users\biglmi\Documents\webapps\ui-core-micha`), branch `main`. WO:
339
+ `work-orders/THEME-2.md`. Follow `orchestrate-codex`.