@micha.bigler/ui-core-micha 3.11.1 → 3.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.12.0 - UCM-THEME-18
4
+
5
+ The shared type scale now uses h1–h5 sizes of 36/32/28/24/20px, h4 steps to 23px below `sm`, and subtitle1 is 13px; every adopted app changes on its next bump, with hram, jg-ferien, kerzenziehen, spesix, and fitness-monitor moving screen titles from h5 to h4, while survey_app should remove its 800/700 weights and h4 `2.125rem`/400 override and decide its h3 page titles (24px to 28px), closing the held `SVA-THEME-1` typography revert. An app h4 `fontSize` override retains the baseline’s 23px mobile step (survey_app currently sets `2.125rem`); heading weights are fixed at 600, so apps should not override them per level. All other adopted apps need only bump; webshop-guenter, Kira, Cinevia, and Gustav are unaffected.
6
+
7
+ ## 3.11.2 — UCM-THEME-16
8
+
9
+ The "Signup Access / valid until" box in `QrSignupManager` now takes `background.subtle` instead of the fixed light `grey.50`,
10
+ so its text stays readable in dark mode (2.00:1 before). In light the ground moves from `#FAFAFA` to `#F4F5F6` (1.05:1 step).
11
+ The white ground behind the QR code itself is unchanged on purpose.
12
+
3
13
  ## 3.11.1 — UCM-I18N-5
4
14
 
5
15
  `UserListComponent`'s pagination range text ("1–4 of 4") now goes through `labelDisplayedRows`,
package/WORK_ORDERS.md CHANGED
@@ -28,7 +28,9 @@ prefixed ID; never reuse a bare flat number across workstreams.
28
28
 
29
29
  | ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
30
30
  |---|---|---|---|---|---|---|
31
- | UCM-I18N-5 | [User-list pagination range text is hard-coded English](work-orders/UCM-I18N-5.md) | `UserListComponent`'s `TablePagination` sets `labelRowsPerPage` but no `labelDisplayedRows`, so German shows "1–4 of 4". | 2026-10-01 | in-progress | | Found in jg-ferien's rendered check 2026-10-01. Follows `UCM-I18N-4`. Tier 3. Done = released AND jg-ferien pin bumped. Envelope written 2026-10-01; operator: hand to the jg Paket-4 session. Implementation map filled 2026-10-02 (`a732025`): the translation bundle carries FOUR locales (de/fr/en/sw), not three — confirmed by reading `authTranslations.ts` directly; proposed two keys (exact-count + "more than" form) with a best-effort, not native-verified, Swahili wording. Dispatching codex/gpt-5.6-luna. |
31
+ | UCM-THEME-17 | Decide a less compressed baseline type scale | Design round (design-language process): inventory, instrument with 2-3 candidate scales next to today's baseline and MUI on real screens (hram, a survey site, jg-ferien, kerzenziehen), value rounds, frozen sheet. Measured 2026-10-04: page/card/section titles differ by 1-3 px, hierarchy rests on weight alone. Full order [`work-orders/UCM-THEME-17.md`](work-orders/UCM-THEME-17.md) | 2026-10-04 | done | `68dfc6a`, `998e36c`, `cb9dd89`, `c452224` | Tier 2 (document only) · operator decision 2026-10-04: baseline finding, not per-app overrides · implementation order follows the freeze · survey_app SVA-THEME-1 typography revert held until then · **frozen 2026-10-04** after two value rounds: candidate B, mobile h4 23, h1-h3 36/32/28; value source [`docs/TYPE-SCALE.md`](docs/TYPE-SCALE.md) · review: none, document order, the operator freeze is the gate · implementation UCM-THEME-18 |
32
+ | UCM-THEME-18 | Ship the frozen baseline type scale | `tokens.js` typography per `docs/TYPE-SCALE.md`: h1-h3 36/32/28, h4 24 (23 below `sm`), h5 20, subtitle1 13; light fixture typography entries; minor release 3.12.0. Full order [`work-orders/UCM-THEME-18.md`](work-orders/UCM-THEME-18.md) | 2026-10-04 | 5be83d5 | | Tier 3 (shared-core) · reviewer + ui_reviewer · downstream per-app orders (h5 page titles -> h4, per-level weight overrides) written after the release - 2026-10-04: Codex implemented (gpt-6-luna; probe of stale 2026-10-03 record succeeded, available) - orchestrator fixed review findings - review: codex/gpt-6-luna - lenses: envelope,regression,duplication,tests + ui_review - 3 raised - 2 accepted - worst accepted: defect (h4 mobile key now follows an app-overridden sm, test added; completeness test strengthened; rejected: breakpointDownQuery vs theme.breakpoints.down, no theme exists at token time) - light fixture diff limited to typography entries - no rendered side-by-side (no prototype; values asserted from the resolved theme in light and dark) - release 3.12.0 |
33
+ | UCM-I18N-5 | [User-list pagination range text is hard-coded English](work-orders/UCM-I18N-5.md) | `UserListComponent`'s `TablePagination` sets `labelRowsPerPage` but no `labelDisplayedRows`, so German shows "1–4 of 4". | 2026-10-01 | done | 6b9d557 | Found in jg-ferien's rendered check 2026-10-01. Follows `UCM-I18N-4`. Tier 3. Done = released AND jg-ferien pin bumped. Envelope written 2026-10-01; operator: hand to the jg Paket-4 session. Implementation map filled 2026-10-02 (`a732025`): the translation bundle carries FOUR locales (de/fr/en/sw), not three — confirmed by reading `authTranslations.ts` directly; proposed two keys (exact-count + "more than" form) with a best-effort, not native-verified, Swahili wording. Implemented by codex/gpt-5.6-luna exactly per the map: `labelDisplayedRows` resolves `UserList.DISPLAYED_ROWS`/`UserList.DISPLAYED_ROWS_MORE_THAN` through `t()`, both keys added in all four locales. review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui_reviewer (one concurrent batch, diff inline) · 4 raised · 3 accepted (deduplicated) · worst accepted: defect. Accepted: the rendered English test would still pass against a reverted implementation, since the EN wording was deliberately made identical to MUI's own hardcoded default — added a second test swapping in a distinctive marker string for just that key, proving the render genuinely resolves through i18next, not MUI's fallback; `authTranslations.test.js`'s new key-parity check only asserted non-empty strings, not that each locale actually carries the `{{from}}`/`{{to}}`/`{{count}}` placeholders the others do — strengthened with an explicit per-locale placeholder check; self-identified (corroborated by the `tests` lens's own closing remark) that the `count === -1` ("more than") branch can never be reached through a render of `UserListComponent` itself (its own `count` prop is always a real array length) — extracted the inline callback into an exported, directly unit-tested `formatDisplayedRowsLabel(t)` rather than leaving it permanently untested dead code. Rejected: `regression`'s claim that hard-coding `labelDisplayedRows` "overrides any consuming app's MUI theme customisation" — speculative, no actual consumer override exists (same explicit-prop design as `labelRowsPerPage` since `UCM-I18N-4`), and the reviewer's own repo search found zero consumer snapshots/assertions relying on the old English text. `envelope`'s "release + pin bump missing from the diff" was correct but not a code defect — addressed by the remaining steps below, not a fix to the reviewed diff. `duplication`: 0 findings. `ui_reviewer`: 0 blocking findings; confirmed placeholder consistency across all 8 strings; Swahili wording flagged as advisory-only ("structurally plausible to a non-native reviewer, native-speaker verification advisable" — not re-asked of a native speaker here, recorded as open). Tests: 37/37 green (`userScreenTranslations.test.jsx`, `authTranslations.test.js`, `UserListComponent.test.jsx`). Released as `3.11.1` (patch — continues `UCM-I18N-4`'s i18n-gap category, not a new capability area), commit `6b9d557`; npm Trusted Publishing confirmed via the Actions job log (not a registry re-query — provenance publishes can lag), tarball `@micha.bigler/ui-core-micha@3.11.1`. jg-ferien's pin bumped 3.7.0 -> 3.11.1 same day, closed as jg's own `JG-DEPS-3` (`2c1d6e3`) — see that repo's `WORK_ORDERS.md` for the empirical safety checks (status-colour palette diff, `UserListComponent`-unused confirmation, full-suite run) this multi-version jump required. |
32
34
  | UCM-TEST-1 | A kit integration check every app runs in its own suite | Exported `checkKitIntegration({ i18n, wrapper, pages })` -> `{ findings }` (shape of `assertThemeComplete`): kit keys present in the app's i18n, one `i18next` instance, kit pages render in the app's providers without raw keys, the `invalid`-email case shows the field text. Replaces per-app hand-written wiring tests and manual auth walks. Full order [`work-orders/UCM-TEST-1.md`](work-orders/UCM-TEST-1.md) | 2026-10-01 | done | 2cde120 | Tier 3 (shared-core, auth surface) · operator decision 2026-10-01: central check, apps adopt with their next bump · companion: `workflow-templates` WFT-CI-31. Codex implemented (available today, no fallback). Orchestrator independently found + fixed before review: a false-positive in the raw-code render heuristic (a legitimate short lowercase kit-shipped value, e.g. German `"oder"` for `Auth.LOGIN_OR`, would otherwise be flagged as a leaked backend code — added a `KIT_VALUES` exemption + a pinning test), and independently proved Required Test 5 (resolver-bypassed -> error-text finding) via a transient mutation of `resolveErrorText.js`, restored clean. Independent `reviewer` (all 4 lenses) + `ui_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui · 9 raised · 6 accepted · worst accepted: blocker**. Accepted and fixed: catalogue check didn't force `fallbackLng: false` per lookup, so a consumer's own `fallbackLng` config (survey_app's is `"de"`, not `"false"`) could silently resolve a missing-language key through the fallback and mask it (tests lens) — fixed, plus a dedicated test using a real fallback-configured instance; Required Test 6 only checked adapter reference-identity, not that a real post-check request actually reaches the real adapter (envelope lens) — strengthened with a sentinel-adapter test; React 19's `act()` needs `IS_REACT_ACT_ENVIRONMENT`, undocumented for consumers (ui lens) — added to the README example; README's `pages` description implied it fully replaces the default set, when `LoginPage` always additionally renders for the error-text check regardless (ui lens, nit) — clarified. Accepted, documented rather than redesigned: mutating the shared `apiClient.defaults.adapter` for the check's duration is process-wide and could intercept a concurrent real request from other code in the same test file (regression lens, blocker) — this is the Envelope's own decided stub design (`src/utils/auth-errors.js`-style module-singleton stubbing, same pattern already used throughout this suite), not fixable without abandoning that decision; added a README caveat instead of restructuring. Rejected: `RAW_CODE` only matches lowercase-snake-case, missing uppercase/hyphenated shapes (envelope + tests lenses, same point from both) — every real backend error code in this codebase (`src/utils/auth-errors.js`, allauth headless responses) is lowercase snake_case; widening the regex matches no real shape here and raises false-positive risk for marginal benefit. `KIT_VALUES` could let a leaked code evade detection if it coincidentally equals a real kit translation value (tests lens) — true but reintroducing the "oder" false positive to close it is a worse trade; no real backend code collides with the kit's UI copy in practice. The correctly-wired/legitimate-word/resolver/adapter-restore tests would all pass against a hardcoded `{findings:[]}` stub in isolation (tests lens, nit) — true of any happy-path assertion; each is paired with a failure-case test proving the same code path actually detects the defect (Required Tests 2-5), which is where the real proof lives. `duplication`: no capability-level overlap with `assertThemeComplete` or any shared-core/app module. **Verification** (WO's own instruction): ran `checkKitIntegration` once against survey_app's real wiring — not by touching that repo, but from inside this repo's own resolved environment, replicating `survey_app/frontend/i18n/index.js`'s real merge + config exactly (`fallbackLng: "de"`, survey_app's actual `translations.json`) plus this repo's real `AuthProvider` — **result: `findings: []`**, including against the real fallback-configured instance the tests-lens finding above was about. Orchestrator's own scoped run: `tests/checkKitIntegration.test.jsx` + `tests/resolveErrorText.test.js` (12 tests) + 7 directly-dependent files (33 tests) = 45/45 green via `npx vitest run <files> --pool=threads --maxWorkers=1`. No rendered gate — library-only change, no running app to preview. **Released as `3.10.0`** (minor — new exported capability, per the WO's own instruction). Per-app adoption rows are added by the Expertenchat when each app's next bump is scoped — not part of this WO. |
33
35
  | UCM-AUTH-11 | [`UserListComponent` offers an admin the delete action on their own row](work-orders/UCM-AUTH-11.md) | Without a `canDeleteUser` prop, `canDelete` falls back to `canEdit`, which is true for a superuser or role `admin` on EVERY row, their own included (`src/components/UserListComponent.jsx` `defaultCanEdit`/`canDelete`, ~l.128-156); the backend (dcm) still allows an admin's generic self-delete. Default should never offer deleting one's own row. | 2026-09-30 | released | 83bac37 | From django-core-micha `DCM-AUTH-4` (dropped there): operator decision 2026-09-30, UI-only, backend unchanged. Affected consumers measured: kerzenziehen and reimbursements render ucm's `AccountPage` without props (jg-ferien passes its own `userListCanDeleteUser` and already excludes itself). Both apps pin ucm **2.41.3**, so reaching them needs a major bump (3.0.0 removed chart layout props) — tracked as its own follow-up, not this WO. Gate #1 approved 2026-09-30 (operator handover). Codex implemented (available today, no fallback): new `isOwnRow(targetUser)` helper (`String(id) === String(id)`, `false` whenever either id is unresolved — covers the loading-state risk) gates the delete button's RENDER — `{showDeleteAction && !isOwnRow(row) && (...)}` — not its `disabled` state, so a consumer's `canDeleteUser` returning `true` for the own row cannot bring the button back; `canDelete`/`canEdit`/`defaultCanEdit` untouched. Independent `reviewer` (all 4 lenses) + `ui_reviewer` + `sec_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui,sec · 3 raised · 1 accepted · worst accepted: nit**. Accepted (nit, tests lens): the committed fixtures only ever used numeric ids for both `currentUser` and the own row, so a strict-equality implementation would have passed every committed test despite violating the Envelope's own `3`/`"3"` identity requirement — added a fifth case with a string-vs-number id mismatch. Rejected: envelope lens's claim that "Required Test 5 (mutation proof) is missing" — the WO's own Part B (mirroring `UCM-THEME-15`/`UCM-PRIM-2`'s established pattern in this repo) asks for a TRANSIENT demonstration during implementation, not a permanent fifth test case; the implementer's own `PROGRESS` narration confirms it removed the gate, watched tests 1 and 3 fail (2 delete buttons found instead of 1), then restored — exactly as asked. Envelope lens's claim that "package.json remains 3.9.0 so the release is incomplete" — true at the moment the reviewer read the diff (version bump + register are the Orchestrator's own finalize step, explicitly forbidden to the implementer), not a defect in the change itself; done now. `regression`/`duplication`/`ui`/`sec` lenses: no findings — `sec_reviewer` specifically confirmed the render-gate cannot be reopened via `canDeleteUser`/`canEditUser`/`extraRowActions`, and that the backend's own continued refusal of an admin's self-delete (`DCM-AUTH-4`) means this UI-only fix is not masking a still-open server-side gap. Orchestrator's own scoped run: `tests/UserListComponent.test.jsx` 7/7 green + `tests/userScreenTranslations.test.jsx` (directly-dependent, also renders this component) 4/4 green, via `npx vitest run <files> --pool=threads --maxWorkers=1` (default fork pool repeatedly failed to start workers in this sandbox — an environment flake, not a code issue). No rendered gate — library component, no running app to preview; unit-tested only. **Released as `3.9.1`** (patch — behaviour fix of an existing component, no API change, per the WO's own instruction). `npm view @micha.bigler/ui-core-micha version` will read `3.9.1` once the registry's version-list index catches up to the just-completed publish (provenance-signed publishes report success immediately but can take a few minutes to appear in `npm view`/the registry API — confirmed via the GitHub Actions job log on the prior `UCM-AUTH-10` release, not re-verified here to avoid the same registry-lag false alarm). **Not done in this session** (per the WO's own Part C): registering the kerzenziehen/reimbursements major-bump follow-up rows in their own `WORK_ORDERS.md` registers — flagged for whoever next works in those repos. |
34
36
  | UCM-AUTH-10 | Error texts never show a raw backend code | One resolver for every kit error display: backend `i18nKey` > field key `Auth.field.<field>.<code>` > code key (AUTH-2) > backend message > caller default; `apiClient` sends the UI language as `Accept-Language`; `invalid`/`required` field keys for every submitted field; ~40 call sites migrated; reconstructs the missing 3.7.0 changelog entry. Full order [`work-orders/UCM-AUTH-10.md`](work-orders/UCM-AUTH-10.md) | 2026-09-30 | done | 9019d3d | Tier 3 (shared-core, auth screens, header on every request) · operator decision 2026-09-30: code-based UI, backend message as last specific fallback, UI language sent to the backend · found in `survey_app` SVA-DEP-1 acceptance (pre-existing, not a bump regression). Codex implemented (available today, no fallback): new `src/utils/resolveErrorText.js` (plain function, not a hook — called inside `catch` blocks) implementing steps 0-4 via `i18n.exists`/`i18n.t`; `extractErrorInfo`/`normaliseApiError` now carry `field` (`param` allauth / `field` dcm, normalised to one name), `i18nKey`, and `backendMessage` (never falls back to the code — the literal bug) as separate fields; `apiClient` gained a request interceptor sending `Accept-Language: i18next.language` only when `i18next.isInitialized` (never guesses); ~40 call sites across 22 files migrated to the uniform pattern (state now always holds resolved display text, never a bare key; render sites dropped their `t()` wrapper); 48 new `Auth.field.<field>.{invalid,required}` keys (24 fields, enumerated from `authApi.jsx`, all 4 locales + `de_informal` where wording differs); `CHANGELOG.md`'s missing `3.7.0` entry reconstructed. Independent `reviewer` (all 4 lenses) + `ui_reviewer` + `sec_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui,sec · 12 raised · 4 accepted · worst accepted: defect**. Accepted: (defect, envelope lens — a real guard blind spot) `MFAComponent.jsx`'s `setErrorKey('Auth.MFA_TOTP_ALREADY_ACTIVE')` was a plain literal-key setter the mechanical sweep missed — the call-site-guard test only matches the `.code` pattern, not "any literal setter needs `t()` now that render dropped its wrapper"; would have displayed the raw key on screen, exactly the bug this WO exists to fix. Fixed (wrapped in `t()`); no dedicated test exists for this component, fix verified by direct code inspection only. (nit, ui_reviewer) the new field-key catalogue omitted `RegistrationMethodsManager.jsx`'s 5 boolean policy-toggle fields (`allow_admin_invite`, `allow_self_signup_{access_code,open,email_domain,qr}`); added, all 4 locales. (nit, tests lens) `resolveErrorText.test.js`'s "dcm field shape" case was byte-identical to the allauth-shape case, testing nothing new (the param/field normalisation it claimed to cover already happens one layer down, in `extractErrorInfo`, and is genuinely tested there); removed the redundant duplicate. Rejected: 2 lenses (envelope, ui_reviewer) claimed `SupportRecoveryRequestsTab.jsx` calls `resolveErrorText(i18n, …)` without destructuring `i18n` — verified directly against the file twice, `const { t, i18n } = useTranslation()` is present at line 42 and every call site is a closure over it; a stale/misread finding from both lenses, not a real defect. 3 lenses (envelope, regression, tests) flagged step 3 (`backendMessage` rendered verbatim) as an exposure risk — this is the Envelope's own **decided** design (`Operator decision: … the backend message as the last specific fallback`), not an implementation defect; `sec_reviewer` — the lens specifically briefed with dcm's S4 audit-log rationale and tasked with exactly this question — found no HTML-injection path and confirmed React's text-node escaping covers every render site, which is what the Envelope's own risk-review request was actually asking. `sec_reviewer` additionally flagged `BulkInviteCsvTab.jsx:88`'s `err.message` and `PasswordInvitePage.jsx:117`'s `err.raw.messages` as chain bypasses — the former is a purely local CSV-file-read error with no backend response involved at all (confirmed no `apiClient` call in that `try` block), the latter is `UCM-AUTH-9`'s already-shipped, already-reviewed password-rejection-reason display, unchanged by this WO and out of scope per its own non-goal ("no change to which errors are shown, where, or what triggers one"). `envelope`+`tests` lenses' claim that `auth-errors.js`'s native `.message` still falls back to the code — deliberate, per this WO's own Part B ("you may leave `err.message` with its existing fallback-to-code behaviour untouched") and covered by the implementer's own test; the field the resolver actually reads (`backendMessage`) correctly never falls back, which is what the Envelope's separation requirement is about. Orchestrator's own scoped run: 14 files, 102 tests green (`resolveErrorText`/`loginPageErrorResolution`/`errorCallSiteGuard`/`apiClientLanguage`/`authErrors`/`AccountPage`/`PasswordInvitePage`/`SignUpPage`/`UserListComponent`/`QrSignupManager.registrationContext`/`i18nAggregate`/`authTranslations`/`themeCompleteness`/`i18nGermanVariant`), via `npx vitest run <files> --pool=threads --maxWorkers=1` (the default fork pool repeatedly failed to start workers in this sandbox — an environment flake, not a code issue; the threads pool is stable). **Verification (the operator's exact screenshot case, rendered end to end):** `en` → "Enter a valid email address."; `de` → "Bitte geben Sie eine gültige E-Mail-Adresse ein." — both resolve via step 1 (`Auth.field.email.invalid`), never the raw `invalid` code, confirmed by `tests/loginPageErrorResolution.test.jsx` (`en`) and by the locale-agnostic resolver design (same key, `de` value) for `de`. No rendered browser gate — verified via the real `LoginPage` component rendered against a real i18next instance in the test harness. **Released as `3.9.0`** (minor, per the Envelope's own instruction — `resolveErrorText` is internal-only, not part of the public `src/index.js` surface). `npm view @micha.bigler/ui-core-micha version` should read `3.9.0` once `publish.yml` runs on this push. No consumer bump in this session (non-goal). |
@@ -38,7 +40,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
38
40
  | UCM-MSG-20 | [Message composer collapses on narrow screens](work-orders/UCM-MSG-20.md) | At 375 px with four actions the `Composer` textarea is 16 px wide and Send is clipped; plus linkified URLs (http/https only) and an explained or hidden read marker in `MessageBubble`. | 2026-09-29 | done | fcd4ae6 | Raised from jg-ferien's local browser test 2026-09-29; operator 2026-09-29: generic fixes go to ucm. From jg `JG-UX-14`. dcm register is canonical for `MSG-*`; this is the ucm-side row. Tier 3 (inside shared-core). Gate #1 approved 2026-09-29 (operator handover covering UCM-PRIM-2/SHELL-7/MSG-20/AUTH-9/I18N-4). Codex implemented (available today, no fallback): `Composer`'s action row split into an icon row + a message-field/send row (`sx={{flex:1,minWidth:120}}`/`sx={{flexShrink:0}}`); new `linkifyText.jsx` (strict `https?://` inclusion pattern, trims trailing sentence punctuation); `ReadTicks` wrapped in a `Tooltip` and hidden entirely when `recipient_count === 0`. Independent `reviewer` (all 4 lenses) + `ui_reviewer` + `sec_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui,sec · 6 raised · 3 accepted · worst accepted: defect** — the composer test only asserted CSS values, not that the actions actually moved to a separate DOM row (a same-row implementation with the same sx values would have passed); the read-marker/linkify test suite lacked a structural row-separation proof; and `linkifyText`'s raw `<a>` diverged from the kit's own `Link` primitive already used elsewhere (`CookieConsentStep.jsx`). Fixed by the Orchestrator: added a structural assertion (attach button's `Stack` ancestor does not contain the message field or send button); swapped `<a>` for MUI `Link` (which then broke `.closest('.MuiTypography-root')` in the punctuation test — Link itself carries that class — fixed to `.closest('p')`); **also found and fixed, not from a reviewer but from the Orchestrator's own test run:** the Composer's two new inline `sx` objects tripped `reportKitSxBypasses` (a pre-existing repo-wide guard neither the implementer nor any reviewer lens checked) — extracted to exported `COMPOSER_MESSAGE_FIELD_SX`/`COMPOSER_SEND_BUTTON_SX` with new `kitSxRegistry.js` entries. Rejected: "actions row split at every width, not just narrow" (Envelope's own wording allows "wrap to their own row" unconditionally, and a width-gated variant would need media queries for no stated benefit); the `Link`-vs-`<a>` and `minWidth: 120`-magic-number nits beyond what's already fixed above (the latter matches this repo's own `StatTile.jsx:10` `minWidth = 120` convention, not a violation); sec_reviewer found no issues (clean). Orchestrator's own scoped run: 14/14 new/extended (`messagingComposer`/`messagingLinkify`/`messagingReadTicksTooltip`) + `themeCompleteness` 38/38 (sx-registry re-check) + 16/16 directly-dependent regression (`messagingCompactBubble`/`messagingChunk6`/`messagingComposerChunk5`), all via `npx vitest run <files> --maxWorkers=1`. No rendered gate — library component, no running app to preview; unit-tested only. **Released as `3.7.0`** together with `PRIM-2`/`SHELL-7`/`AUTH-9`/`I18N-4` in one combined release — see `I18N-4`'s row for the release note; jg-ferien's pin bump is that app's own separate step. |
39
41
  | UCM-SHELL-7 | [User-menu avatar initials fail contrast](work-orders/UCM-SHELL-7.md) | `UserMenu` avatar renders MUI default white-on-grey initials at 1.83:1; take colours from theme tokens with a passing fallback. | 2026-09-29 | done | 04a29fe | Raised from jg-ferien's local browser test 2026-09-29; operator 2026-09-29: generic fixes go to ucm. From jg `JG-DL-12`. Tier 3 (inside shared-core). Gate #1 approved 2026-09-29 (operator handover covering UCM-PRIM-2/SHELL-7/MSG-20/AUTH-9/I18N-4). Codex implemented (available today, no fallback): avatar background = `primary.main`, text dynamically picked between white/black by measured contrast (`calculateContrastRatio`, already exported). Independent `ui_reviewer` only (Codex gpt-5.6-luna) — Envelope scopes this to ui_reviewer alone, no new logic beyond a colour choice: **review: codex/gpt-5.6-luna · lenses: ui_reviewer · 1 raised · 1 accepted · worst accepted: defect** — the implementer's fallback used `ink.primary` (#212529, luminance ~0.018) instead of true black, which is NOT mathematically guaranteed to reach 4.5:1 together with `background.paper`: for a `primary.main` in the ~0.183–0.257 luminance band neither branch cleared 4.5:1, and neither required test exercised that fallback branch to catch it. Fixed by the Orchestrator: switched both branches to `theme.palette.common.white`/`.black` (true white/black, always present on any MUI theme, not a source literal) — contrast-against-true-white × contrast-against-true-black is a luminance-independent constant (21), so `max(...) >= sqrt(21) ~= 4.58` holds for every possible `primary.main`, closing the gap rather than narrowing it. Added a third test with a synthetic mid-grey `primary.main` (`#808080`, ~3.95:1 on white) to actually exercise the black-fallback branch. Orchestrator's own scoped run: `tests/UserMenu.test.jsx` 10/10 green (`npx vitest run tests/UserMenu.test.jsx --maxWorkers=1`). No rendered gate — library component, no running app to preview; unit-tested only. **Released as `3.7.0`** together with `PRIM-2`/`MSG-20`/`AUTH-9`/`I18N-4` in one combined release — see `I18N-4`'s row for the release note; jg-ferien's pin bump is that app's own separate step. |
40
42
  | UCM-PRIM-2 | [Shared EmptyState primitive](work-orders/UCM-PRIM-2.md) | New `EmptyState` export (icon, one line, optional secondary line and action) from theme tokens; jg-ferien shows empty sections in seven different ways today. | 2026-09-29 | done | 059e9f0 | Raised from jg-ferien's local browser test 2026-09-29; operator 2026-09-29: generic fixes go to ucm. Adopted in jg `JG-UX-9`. Tier 3 (new public export, additive). Gate #1 approved 2026-09-29 (operator handover covering UCM-PRIM-2/SHELL-7/MSG-20/AUTH-9/I18N-4). Codex implemented (available today, no fallback). Independent `reviewer` (Codex gpt-5.6-luna, all 4 lenses) + `ui_reviewer` (Codex gpt-5.6-luna), concurrent background batch on the full diff: **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui · 5 raised · 3 accepted · worst accepted: defect** — missing README doc entry (Envelope required one, added), action button missing `type="button"` (form-submit risk, fixed), two test assertions passed for a would-be hardcoded stub (title/description always rendered the same fixed string across all 3 tests; strengthened with a second distinct-value render + negative assertion). Rejected: `sx` passthrough flagged as an "unrequested escape hatch" (established kit convention, e.g. `Composer`'s emoji button, not a new pattern); colour-guard/action-callback "could pass under a stub" claims (the callback test uses a distinct mock reference, which a hardcoded stub cannot satisfy; the colour guard test does exactly what Required Test #2 asked). Orchestrator's own scoped run: `tests/EmptyState.test.jsx` 4/4 green, `tests/themeCompleteness.test.js` (directly-dependent, directory-enumerated palette scan) 38/38 green. No rendered gate — library component, no running app to preview; unit-tested only. **Released as `3.7.0`** together with `SHELL-7`/`MSG-20`/`AUTH-9`/`I18N-4` in one combined release — see `I18N-4`'s row for the release note. |
41
- | UCM-THEME-16 | Dark-safe tokens for QrSignupManager and ReadTicks | `QrSignupManager.jsx` uses `grey.50` and `ReadTicks.jsx` uses `grey.500`, both fixed light-ish MUI values that do not follow the dark baseline. Swapping to `background.subtle`/`text.disabled` changes the light value (`#FAFAFA`->`#F4F5F6`, `#9E9E9E`->`#6A7178`), so it needs an operator decision on accepting that light change. Raised by UCM-THEME-14 review | 2026-10-02 | planned | | Tier 2 - decision needed: accept the small light change, or add a token |
43
+ | UCM-THEME-16 | Dark-safe info box in `QrSignupManager` | `QrSignupManager.jsx:376` info box `grey.50` -> `background.subtle` (dark text was 2.00:1 on the fixed light ground). Operator decision 2026-10-02: the small light change `#FAFAFA` -> `#F4F5F6` is accepted; the QR code's own white ground (`:361`) and `ReadTicks.jsx` `grey.500` (6.07:1 in dark) stay unchanged on purpose. Full order [`work-orders/UCM-THEME-16.md`](work-orders/UCM-THEME-16.md) | 2026-10-02 | done | 7834231 | Tier 3 (change inside shared-core; corrected from Tier 2) · reviewer + ui_reviewer · patch release · raised by the UCM-THEME-14 review - 2026-10-02: Orchestrator implemented (one line, authorship with the Claude orchestrator; Codex not needed) - review: codex/gpt-5.6-luna - lenses: envelope,regression,duplication,tests + ui_review - 2 raised - 1 accepted - worst accepted: nit (hex in test assertion removed; rejected: apps without background.subtle, MFAComponent already depends on it) - release 3.11.2 - test fails on revert to grey.50 (mutation-checked) - no rendered side-by-side (no prototype; style assertion in both modes) |
42
44
  | UCM-THEME-14 | Dark mode in `createAppTheme`, from the central token file | `palette.mode: 'dark'` selects a dark baseline palette in `tokens.js`; focus derivation and completeness contrast checks judge a theme against its own surfaces instead of a fixed white; light output unchanged (regression guard). Full order [`work-orders/UCM-THEME-14.md`](work-orders/UCM-THEME-14.md) | 2026-09-29 | done | ddecdba | Tier 3 (inside shared-core) - Gate #1 carried by the committed Envelope + mini-handover - Codex implemented (gpt-5.6-luna, available today, no fallback); orchestrator fixed review findings (reverted two grey.* token swaps that changed light output, rgb() ink support, primary light/dark shades, test hardening, 0.02 alpha step to reproduce the frozen 0.38/0.53) - review: codex/gpt-5.6-luna - lenses: envelope,regression,duplication,tests + ui_review - 13 raised - 6 accepted - worst accepted: defect - light guard fixture proven against HEAD src - rendered check: dev harness Theme/Shared baseline, dark at 375 px and 1280 px (live browser screenshots; light not re-rendered, covered by the fixture guard) - full kit suite run once incidentally: 722 pass, 2 flaky under parallel load (messagingComposerChunk5, packageTreeShaking) pass in isolation - release 3.11.0 publishes on push - downstream: webshop-guenter pin bump planned
43
45
  | UCM-THEME-13 | Freeze a dark-mode baseline token set | Reference sheet `docs/DARK-TOKENS.md` with a decided value per mode-dependent token class plus a contrast table; `design-language` process, operator-led, no code. Full order [`work-orders/UCM-THEME-13.md`](work-orders/UCM-THEME-13.md) | 2026-09-29 | done | 7330f91 | Tier 2 (document only) · operator decision 2026-09-29: dark mode gets its own kit order · only consumer today: `webshop-guenter` · **Frozen 2026-10-02 by the operator** after two value rounds (neutral base A = estate convergence `#14181B`; surface step 1 = estate's own). Sheet `docs/DARK-TOKENS.md`, instrument `work-orders/assets/UCM-THEME-13-dark-prototype.html`. Invented and labelled: dark `info`, dark `stale`, overlay shadow, scrim. Document order: no implementer, no review (Envelope). |
44
46
  | AUTH-7 | `loginWithPassword`s 409-Retry gegen Zweitfehlschlag haerten | allauth-headless antwortet mit 409 ("already authenticated"), wenn die Session bereits gueltig ist; `loginWithPassword` ruft dann `fetchCurrentUser()` erneut auf. Schlaegt dieser Retry ebenfalls fehl, wurde der Fehler bisher roh durchgereicht statt normalisiert. Spec [`work-orders/AUTH-7.md`](work-orders/AUTH-7.md) | 2026-09-05 | done | ae7aa89 | **Tier 3** (Shared-Core). Ausgeloest durch `kira`: ein erschoepfter globaler Throttle-Bucket (siehe `kira/KIRA-TRACK-3`) liess den 409-Retry selbst mit 429 fehlschlagen, roh statt normalisiert durchgereicht. Rein additiver Bugfix, kein Prop-/Verhaltensvertrag geaendert. **Implementiert (Codex gpt-5.6-luna):** genau wie geplant, `src/auth/authApi.jsx` + neue `tests/authApiLogin.test.js`. review: codex/gpt-5.6-luna (`reviewer` 4 Lenses + `ui_reviewer` + `sec_reviewer`, ein Hintergrund-Batch) 3 raised · 1 accepted · 2 rejected · worst accepted: defect (aber ausserhalb des Scopes dieser WO, siehe unten) — `envelope`/`regression`/`duplication`/`sec_reviewer`: 0 raised. `tests`-Lens: 2 raised, BEIDE empirisch widerlegt (Fix testweise zurueckgerollt, betroffener Test schlug korrekt fehl — sind keine echten Luecken) → rejected. `ui_reviewer`: 1 raised, akzeptiert als real (empirisch bestaetigt: `normaliseApiError('...', 'Auth.LOGIN_FAILED')` liefert bei einem DRF-`{"detail": "..."}`-Fehler `code: 'GENERIC'` statt des übergebenen Default-Codes, weil `extractErrorInfo` in `src/utils/auth-errors.js` `'GENERIC'` bereits als gesetzten Wert zurückgibt, der `info.code || defaultCode` gewinnt) — **PRE-EXISTING in einer anderen Datei, nicht durch diesen Diff verursacht, an JEDER anderen Call-Site derselben Funktion bereits vorhanden** → out-of-scope fuer AUTH-7, nicht hier gefixt, nachverfolgt als `AUTH-8`. Tests: `npx vitest run tests/authApiLogin.test.js tests/AuthContext.test.jsx tests/authApiRegistration.test.js` 7/7 gruen. **PATCH-Bump 3.5.0 -> 3.5.1** (`package.json` + `CHANGELOG.md`), kein Consumer-Pin-Bump noetig (rein additiv). |
@@ -289,7 +289,7 @@ export function QrSignupManager({ enabled = false, expiryDays = DEFAULT_EXPIRY_D
289
289
  mt: 2,
290
290
  borderRadius: 3,
291
291
  p: 2,
292
- bgcolor: 'grey.50',
292
+ bgcolor: 'background.subtle',
293
293
  border: '1px solid',
294
294
  borderColor: 'divider',
295
295
  }, children: [_jsx(Typography, { variant: "subtitle2", gutterBottom: true, children: t('Auth.SIGNUP_QR_ACCESS_TITLE', 'Signup Access') }), _jsxs(Typography, { variant: "body2", color: "text.secondary", children: [t('Auth.SIGNUP_QR_VALID_UNTIL', 'Valid until'), ": ", formattedExpiry || result.expires_at] })] }), _jsxs(Stack, { direction: { xs: 'column', sm: 'row' }, spacing: 1, sx: { mt: 2 }, children: [_jsx(Button, { variant: "outlined", onClick: () => generate(), disabled: busy, children: t('Auth.SIGNUP_QR_NEW_BUTTON', 'New QR-Code') }), _jsx(Button, { variant: "outlined", onClick: handleCopyLink, disabled: !(result === null || result === void 0 ? void 0 : result.signup_url) || busy, children: t('Auth.SIGNUP_QR_COPY_BUTTON', 'Copy Link') }), _jsx(Button, { variant: "outlined", onClick: handleSavePdf, disabled: !(result === null || result === void 0 ? void 0 : result.signup_url) || busy, children: t('Auth.SIGNUP_QR_PDF_BUTTON', 'Save as PDF') })] })] }))] }));
@@ -1,6 +1,17 @@
1
+ var __rest = (this && this.__rest) || function (s, e) {
2
+ var t = {};
3
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
4
+ t[p] = s[p];
5
+ if (s != null && typeof Object.getOwnPropertySymbols === "function")
6
+ for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
7
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
8
+ t[p[i]] = s[p[i]];
9
+ }
10
+ return t;
11
+ };
1
12
  import './fonts';
2
13
  import { createTheme, darken, decomposeColor, getContrastRatio, lighten } from '@mui/material/styles';
3
- import { BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS, BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK, BASELINE_PALETTE, BASELINE_PALETTE_DARK, BASELINE_STATIC, DARK_OVERLAY_SHADOW, DARK_OVERLAY_SURFACE, DARK_SCRIM, MOTION, withMainShades, } from './tokens';
14
+ import { BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS, BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK, BASELINE_PALETTE, BASELINE_PALETTE_DARK, BASELINE_STATIC, BREAKPOINT_SM, DARK_OVERLAY_SHADOW, DARK_OVERLAY_SURFACE, DARK_SCRIM, MOTION, breakpointDownQuery, withMainShades, } from './tokens';
4
15
  import { calculateContrastRatio } from './themeCompleteness';
5
16
  function findFunction(value, path) {
6
17
  if (typeof value === 'function')
@@ -255,7 +266,7 @@ function createPaletteAwareComponents(palette, isDark) {
255
266
  * objects are created, so runtime app/site overrides cannot leave stale values.
256
267
  */
257
268
  export function createAppTheme(appConfig = {}) {
258
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
269
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s;
259
270
  if (!((_a = appConfig === null || appConfig === void 0 ? void 0 : appConfig.palette) === null || _a === void 0 ? void 0 : _a.primary)) {
260
271
  throw new TypeError('createAppTheme: appConfig.palette.primary is required.');
261
272
  }
@@ -322,7 +333,11 @@ export function createAppTheme(appConfig = {}) {
322
333
  : { main: primaryMain })), { contrastText: primaryContrastText }), dataSeries: { categorical: derivedSeries } });
323
334
  const resolvedPalette = Object.assign(Object.assign({}, finalPalette), computedPalette);
324
335
  const fontFamily = ((_p = appConfig.typography) === null || _p === void 0 ? void 0 : _p.fontFamily) || BASELINE_STATIC.typography.fontFamily;
325
- return createTheme(Object.assign(Object.assign({}, BASELINE_STATIC), { typography: Object.assign(Object.assign({}, BASELINE_STATIC.typography), { fontFamily }), fontLoading: Object.assign(Object.assign({}, BASELINE_STATIC.fontLoading), { weights: [...BASELINE_STATIC.fontLoading.weights] }), shadow: isDark ? Object.assign(Object.assign({}, BASELINE_STATIC.shadow), { overlay: DARK_OVERLAY_SHADOW }) : BASELINE_STATIC.shadow, palette: resolvedPalette, themeCompleteness: {
336
+ // The h4 step below `sm` follows the app's own `sm` if it overrides the breakpoint,
337
+ // so the step always ends where theme.breakpoints.down('sm') ends.
338
+ const _t = BASELINE_STATIC.typography.h4, _u = breakpointDownQuery(BREAKPOINT_SM), h4Mobile = _t[_u], h4Base = __rest(_t, [typeof _u === "symbol" ? _u : _u + ""]);
339
+ const smValue = (_s = (_r = (_q = appConfig.breakpoints) === null || _q === void 0 ? void 0 : _q.values) === null || _r === void 0 ? void 0 : _r.sm) !== null && _s !== void 0 ? _s : BREAKPOINT_SM;
340
+ return createTheme(Object.assign(Object.assign({}, BASELINE_STATIC), { typography: Object.assign(Object.assign({}, BASELINE_STATIC.typography), { fontFamily, h4: Object.assign(Object.assign({}, h4Base), { [breakpointDownQuery(smValue)]: h4Mobile }) }), fontLoading: Object.assign(Object.assign({}, BASELINE_STATIC.fontLoading), { weights: [...BASELINE_STATIC.fontLoading.weights] }), shadow: isDark ? Object.assign(Object.assign({}, BASELINE_STATIC.shadow), { overlay: DARK_OVERLAY_SHADOW }) : BASELINE_STATIC.shadow, palette: resolvedPalette, themeCompleteness: {
326
341
  baseline: true,
327
342
  exemptions: (isDark
328
343
  ? BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK
@@ -40,6 +40,8 @@ export const TYPOGRAPHY_VARIANTS = [
40
40
  const typeVariant = (fontSize, fontWeight, lineHeight, extra = {}) => (Object.assign({ fontSize,
41
41
  fontWeight,
42
42
  lineHeight, letterSpacing: 0 }, extra));
43
+ export const BREAKPOINT_SM = 600;
44
+ export const breakpointDownQuery = (value) => `@media (max-width:${value - 0.05}px)`;
43
45
  export const BASELINE_PALETTE = {
44
46
  ink: {
45
47
  primary: '#212529',
@@ -156,13 +158,15 @@ const autofill = {
156
158
  export const BASELINE_STATIC = {
157
159
  typography: {
158
160
  fontFamily: "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
159
- h1: typeVariant('32px', 600, 1.2),
160
- h2: typeVariant('28px', 600, 1.22),
161
- h3: typeVariant('24px', 600, 1.25),
162
- h4: typeVariant('20px', 600, 1.3),
163
- h5: typeVariant('18px', 600, 1.3),
161
+ h1: typeVariant('36px', 600, 1.2),
162
+ h2: typeVariant('32px', 600, 1.22),
163
+ h3: typeVariant('28px', 600, 1.25),
164
+ h4: typeVariant('24px', 600, 1.3, {
165
+ [breakpointDownQuery(BREAKPOINT_SM)]: { fontSize: '23px' },
166
+ }),
167
+ h5: typeVariant('20px', 600, 1.3),
164
168
  h6: typeVariant('16px', 600, 1.35),
165
- subtitle1: typeVariant('15px', 600, 1.4),
169
+ subtitle1: typeVariant('13px', 600, 1.4),
166
170
  subtitle2: typeVariant('13px', 500, 1.4),
167
171
  body1: typeVariant('14px', 400, 1.55),
168
172
  body2: typeVariant('13px', 400, 1.55),
@@ -182,7 +186,7 @@ export const BASELINE_STATIC = {
182
186
  },
183
187
  spacing: 8,
184
188
  breakpoints: {
185
- values: { xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 },
189
+ values: { xs: 0, sm: BREAKPOINT_SM, md: 900, lg: 1200, xl: 1536 },
186
190
  },
187
191
  density: {
188
192
  controlHeight: 40,
@@ -47,7 +47,7 @@ stated rule from a decided value) or **invented** (no precedent anywhere; labell
47
47
 
48
48
  | Token | Light (today) | Dark | Origin |
49
49
  |---|---|---|---|
50
- | `controlBorder.main` | `rgba(33,37,41,.50)` | `rgba(230,235,239,.38)` | derived: `ink.primary` at the smallest alpha that clears 3:1 on page and paper |
50
+ | `controlBorder.main` | `rgba(33,37,41,.50)` | `rgba(230,235,239,.38)` | derived: `ink.primary` at the smallest alpha **in 0.02 steps from 0.30** that clears 3:1 on page and paper (wording made precise 2026-10-02; a finer step would give 0.37, the frozen value is 0.38) |
51
51
  | `controlBorder.hover` | `rgba(33,37,41,.65)` | `rgba(230,235,239,.53)` | derived: the light step (+0.15) carried over |
52
52
  | `controlBorder.error` | `#BF3227` | `#E58B80` | = `error.main` (dark) |
53
53
  | `controlBorder.focus` | derived from primary | derived from primary | rule: lighten the primary until it clears 3:1 on page and paper |
@@ -0,0 +1,99 @@
1
+ # Baseline type scale
2
+
3
+ Reference sheet for `UCM-THEME-17`. **This file is the value source** for the baseline typography:
4
+ `UCM-THEME-18` takes every value from here by variant name, and if code and this sheet ever disagree, this
5
+ sheet is the one to fix first.
6
+
7
+ Instrument: [`work-orders/assets/UCM-THEME-17-type-scale.html`](../work-orders/assets/UCM-THEME-17-type-scale.html)
8
+ (the candidates beside today's baseline and the MUI default on four estate screens at 375 px and 1280 px; sizes,
9
+ ratios and frame heights are measured in the page).
10
+
11
+ ## Frozen
12
+
13
+ **Frozen 2026-10-04 by the operator** ("Ja, einfrieren"), after two value rounds.
14
+
15
+ ## Why the scale changed
16
+
17
+ The previous baseline stepped its headings by 1 to 3 px (h4 20, h5 18, h6 16, subtitle1 15), so hierarchy rested
18
+ on weight alone, and where apps raised every level to 700 or 800 the levels merged (survey_app sites, operator
19
+ screenshots 2026-10-04). The approved prototypes in the estate had used the larger title step all along: screen
20
+ title about 1.7x body at 1280 px, stepped down on mobile, section labels below body size.
21
+
22
+ ## Structural decisions (operator, 2026-10-04)
23
+
24
+ 1. **One scale** for every app and page type; no separate reading register. Body stays at the tool density
25
+ (`jg-ferien` `JG-DL-7` had rejected a larger body).
26
+ 2. **The screen title (h4) steps down on narrow screens.** No other variant does.
27
+ 3. **Size carries the hierarchy, weight is fixed**: every heading 600. App overrides are for font family, not
28
+ for per-level weight.
29
+ 4. **The section label (subtitle1) sits below body size**, set apart by weight.
30
+
31
+ Role mapping, unchanged from the `THEME-1` sheet: h4 screen title, h5 panel or dialog, h6 card title,
32
+ subtitle1 section label.
33
+
34
+ ## Token table
35
+
36
+ | Variant | Before | Frozen | Weight | Line height | Origin |
37
+ |---|---|---|---|---|---|
38
+ | h1 | 32 | **36** | 600 | 1.2 | derived: 4 px steps above h3 |
39
+ | h2 | 28 | **32** | 600 | 1.22 | derived: 4 px steps above h3 |
40
+ | h3 | 24 | **28** | 600 | 1.25 | derived: 4 px above h4 |
41
+ | h4, screen title | 20 | **24** | 600 | 1.3 | distilled: prototype median (KZ-UI-1, UX-10, JG-MEM-1) |
42
+ | h4 below `sm` (600 px) | 20 | **23** | 600 | 1.3 | decided round 2: the smallest size that keeps h4 : h5 at 1.12 or more on mobile. The breakpoint is the baseline's own `sm`, derived, not judged |
43
+ | h5, panel | 18 | **20** | 600 | 1.3 | distilled: prototypes 18 to 20 |
44
+ | h6, card title | 16 | 16 | 600 | 1.35 | unchanged |
45
+ | subtitle1, section label | 15/600 | **13** | 600 | 1.4 | distilled: prototypes 12 to 13 at 600 to 700 |
46
+ | subtitle2 | 13/500 | 13 | 500 | 1.4 | unchanged |
47
+ | body1 | 14 | 14 | 400 | 1.55 | unchanged (decision 1) |
48
+ | body2 | 13 | 13 | 400 | 1.55 | unchanged |
49
+ | button | 14/500 | 14 | 500 | 1.4 | unchanged |
50
+ | caption | 12 | 12 | 400 | 1.4 | unchanged |
51
+ | overline | 11/600 | 11 | 600 | 1.4 | unchanged |
52
+
53
+ Sizes in px. `letterSpacing` 0 throughout, overline 0.4px uppercase as before.
54
+
55
+ ## Measurements (computed in the instrument, 2026-10-04)
56
+
57
+ | Ratio | Before | Frozen | MUI |
58
+ |---|---|---|---|
59
+ | h4 : h5 at 1280 | 1.11 | 1.20 | 1.42 |
60
+ | h4 : h5 at 375 | 1.11 | 1.15 | 1.42 |
61
+ | h5 : h6 | 1.13 | 1.25 | 1.20 |
62
+ | h6 : body1 | 1.14 | 1.14 | 1.25 |
63
+ | h4 : body1 at 1280 / 375 | 1.43 / 1.43 | 1.71 / 1.64 | 2.13 / 2.13 |
64
+ | subtitle1 : body1 | 1.07 | 0.93 | 1.00 |
65
+
66
+ The instrument works with a threshold of 1.12 between two heading levels at the same weight. That number is this
67
+ sheet's working assumption, not a standard.
68
+
69
+ Frame height against the previous baseline: hram Research -1.1 % / -1.0 % (375 / 1280), jg-ferien dashboard
70
+ +0.5 % / +0.5 %, kerzenziehen admin -1.4 % / -1.3 %, survey site +0.7 % / +0.8 %. The smaller section label offsets
71
+ the larger titles; the vertical-space risk named in the work order does not occur. MUI by comparison: +4 to +18 %.
72
+
73
+ ## Rejected, and why
74
+
75
+ - **Candidate A** (h4 22, h5 18, h6 16, subtitle1 13): leaves h5 : h6 at 1.13, the panel and card stay as
76
+ close as before.
77
+ - **Candidate C** (h4 28, h5 22, h6 17, subtitle1 12): the counter-test; closest to MUI, largest height cost
78
+ (up to +2.3 %).
79
+ - **Mobile title at desktop minus 2** (round 1, B at 22): h4 : h5 on mobile falls to 1.10, as close as before.
80
+ - **h1 to h3 kept at 32 / 28 / 24**: h3 would equal the new h4 and drop out as a level.
81
+ - **The MUI scale**: body 16 is the size `jg-ferien` already rejected for tool screens.
82
+
83
+ ## Known consequences outside this repo
84
+
85
+ These are inputs for the per-app follow-up after the release, not part of the baseline:
86
+
87
+ - **Screens that use another variant as their screen title** do not get the new title value or the mobile step.
88
+ Measured 2026-10-04 (first heading variant in each `*Page.jsx`, a heuristic): hram 5 pages h5, jg-ferien 3 pages
89
+ h5 (`DashboardPage.jsx:187`), kerzenziehen admin h5 (rendered measurement), spesix 1 page h5, fitness-monitor
90
+ 1 page h5; survey_app uses h3 on 7 pages, which grows from 24 to 28.
91
+ - **Per-level weight overrides** contradict decision 3: survey_app (h1 to h6 800/700, site builder 800),
92
+ hram (500), spesix (weights). survey_app's held typography revert (`SVA-THEME-1`) is decided by this sheet.
93
+ - webshop-guenter keeps its handoff scale (decided brand, identity bucket). Kira, Cinevia and Gustav use no MUI
94
+ heading variants and are unaffected.
95
+
96
+ ## Unverified
97
+
98
+ Declared, not shown by the static instrument: long titles wrapping in French and German at 375 px, dialogs,
99
+ the h1 to h3 sizes on real public pages, and the step at exactly 600 px.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "3.11.1",
3
+ "version": "3.12.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [
@@ -373,7 +373,7 @@ export function QrSignupManager({
373
373
  mt: 2,
374
374
  borderRadius: 3,
375
375
  p: 2,
376
- bgcolor: 'grey.50',
376
+ bgcolor: 'background.subtle',
377
377
  border: '1px solid',
378
378
  borderColor: 'divider',
379
379
  }}
@@ -8,10 +8,12 @@ import {
8
8
  BASELINE_PALETTE,
9
9
  BASELINE_PALETTE_DARK,
10
10
  BASELINE_STATIC,
11
+ BREAKPOINT_SM,
11
12
  DARK_OVERLAY_SHADOW,
12
13
  DARK_OVERLAY_SURFACE,
13
14
  DARK_SCRIM,
14
15
  MOTION,
16
+ breakpointDownQuery,
15
17
  withMainShades,
16
18
  } from './tokens';
17
19
  import { calculateContrastRatio } from './themeCompleteness';
@@ -377,6 +379,10 @@ export function createAppTheme(appConfig = {}) {
377
379
  };
378
380
  const resolvedPalette = { ...finalPalette, ...computedPalette };
379
381
  const fontFamily = appConfig.typography?.fontFamily || BASELINE_STATIC.typography.fontFamily;
382
+ // The h4 step below `sm` follows the app's own `sm` if it overrides the breakpoint,
383
+ // so the step always ends where theme.breakpoints.down('sm') ends.
384
+ const { [breakpointDownQuery(BREAKPOINT_SM)]: h4Mobile, ...h4Base } = BASELINE_STATIC.typography.h4;
385
+ const smValue = appConfig.breakpoints?.values?.sm ?? BREAKPOINT_SM;
380
386
 
381
387
  return createTheme(
382
388
  {
@@ -384,6 +390,7 @@ export function createAppTheme(appConfig = {}) {
384
390
  typography: {
385
391
  ...BASELINE_STATIC.typography,
386
392
  fontFamily,
393
+ h4: { ...h4Base, [breakpointDownQuery(smValue)]: h4Mobile },
387
394
  },
388
395
  fontLoading: {
389
396
  ...BASELINE_STATIC.fontLoading,
@@ -50,6 +50,9 @@ const typeVariant = (fontSize, fontWeight, lineHeight, extra = {}) => ({
50
50
  ...extra,
51
51
  });
52
52
 
53
+ export const BREAKPOINT_SM = 600;
54
+ export const breakpointDownQuery = (value) => `@media (max-width:${value - 0.05}px)`;
55
+
53
56
  export const BASELINE_PALETTE = {
54
57
  ink: {
55
58
  primary: '#212529',
@@ -222,13 +225,15 @@ const autofill = {
222
225
  export const BASELINE_STATIC = {
223
226
  typography: {
224
227
  fontFamily: "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
225
- h1: typeVariant('32px', 600, 1.2),
226
- h2: typeVariant('28px', 600, 1.22),
227
- h3: typeVariant('24px', 600, 1.25),
228
- h4: typeVariant('20px', 600, 1.3),
229
- h5: typeVariant('18px', 600, 1.3),
228
+ h1: typeVariant('36px', 600, 1.2),
229
+ h2: typeVariant('32px', 600, 1.22),
230
+ h3: typeVariant('28px', 600, 1.25),
231
+ h4: typeVariant('24px', 600, 1.3, {
232
+ [breakpointDownQuery(BREAKPOINT_SM)]: { fontSize: '23px' },
233
+ }),
234
+ h5: typeVariant('20px', 600, 1.3),
230
235
  h6: typeVariant('16px', 600, 1.35),
231
- subtitle1: typeVariant('15px', 600, 1.4),
236
+ subtitle1: typeVariant('13px', 600, 1.4),
232
237
  subtitle2: typeVariant('13px', 500, 1.4),
233
238
  body1: typeVariant('14px', 400, 1.55),
234
239
  body2: typeVariant('13px', 400, 1.55),
@@ -248,7 +253,7 @@ export const BASELINE_STATIC = {
248
253
  },
249
254
  spacing: 8,
250
255
  breakpoints: {
251
- values: { xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 },
256
+ values: { xs: 0, sm: BREAKPOINT_SM, md: 900, lg: 1200, xl: 1536 },
252
257
  },
253
258
  density: {
254
259
  controlHeight: 40,
@@ -0,0 +1,49 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import React from 'react';
4
+ import { ThemeProvider } from '@mui/material/styles';
5
+ import { cleanup, render, screen } from '@testing-library/react';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ vi.mock('../src/auth/authApi', () => ({
9
+ createSignupQr: vi.fn().mockResolvedValue({
10
+ signup_url: 'https://example.test/signup?rt=token',
11
+ expires_at: '2026-09-01T12:00:00Z',
12
+ }),
13
+ }));
14
+
15
+ vi.mock('qrcode.react', () => ({
16
+ QRCodeSVG: () => null,
17
+ }));
18
+
19
+ const stableT = (_key, fallback) => fallback;
20
+ const stableI18n = { exists: () => false, t: stableT };
21
+ vi.mock('react-i18next', () => ({
22
+ useTranslation: () => ({ t: stableT, i18n: stableI18n }),
23
+ }));
24
+
25
+ import { QrSignupManager } from '../src/components/QrSignupManager';
26
+ import { createAppTheme } from '../src/theme';
27
+
28
+ // jsdom serialises the resolved colour as rgb(), so compare against the theme's own value in that form.
29
+ function toRgb(hex) {
30
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
31
+ return `rgb(${r}, ${g}, ${b})`;
32
+ }
33
+
34
+ describe('QrSignupManager info box', () => {
35
+ afterEach(cleanup);
36
+
37
+ it.each(['light', 'dark'])('paints background.subtle of the %s theme', async (mode) => {
38
+ const theme = createAppTheme({ palette: { mode, primary: { main: '#468AB2' } } });
39
+ render(
40
+ <ThemeProvider theme={theme}>
41
+ <QrSignupManager enabled expiryDays={90} />
42
+ </ThemeProvider>,
43
+ );
44
+
45
+ const title = await screen.findByText('Signup Access');
46
+ const box = title.parentElement;
47
+ expect(getComputedStyle(box).backgroundColor).toBe(toRgb(theme.palette.background.subtle));
48
+ });
49
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { assertThemeComplete, createAppTheme } from '../src/theme';
4
+ import { breakpointDownQuery } from '../src/theme/tokens';
5
+
6
+ const themes = [
7
+ ['light', createAppTheme({ palette: { primary: { main: '#0F62FE' } } })],
8
+ ['dark', createAppTheme({ palette: { mode: 'dark', primary: { main: '#468AB2' } } })],
9
+ ];
10
+
11
+ const decidedSizes = {
12
+ h1: '36px',
13
+ h2: '32px',
14
+ h3: '28px',
15
+ h4: '24px',
16
+ h5: '20px',
17
+ subtitle1: '13px',
18
+ };
19
+
20
+ describe('createAppTheme frozen typography scale', () => {
21
+ it.each(themes)('uses the decided type sizes in %s mode', (_mode, theme) => {
22
+ for (const [variant, fontSize] of Object.entries(decidedSizes)) {
23
+ expect(theme.typography[variant].fontSize).toBe(fontSize);
24
+ }
25
+ });
26
+
27
+ it.each(themes)('steps h4 down below the theme sm breakpoint in %s mode', (_mode, theme) => {
28
+ const query = breakpointDownQuery(theme.breakpoints.values.sm);
29
+
30
+ expect(Object.keys(theme.typography.h4)).toContain(query);
31
+ expect(theme.typography.h4[query]).toEqual({ fontSize: '23px' });
32
+ });
33
+
34
+ it('reports no typography finding for the baseline theme', () => {
35
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
36
+
37
+ expect(Object.keys(theme.typography.h4)).toContain(breakpointDownQuery(theme.breakpoints.values.sm));
38
+ expect(assertThemeComplete(theme).findings.filter(({ surface }) =>
39
+ surface.startsWith('typography.'),
40
+ )).toEqual([]);
41
+ });
42
+
43
+ it('moves the h4 step with an app-overridden sm breakpoint', () => {
44
+ const theme = createAppTheme({
45
+ palette: { primary: { main: '#0F62FE' } },
46
+ breakpoints: { values: { sm: 480 } },
47
+ });
48
+ const keys = Object.keys(theme.typography.h4).filter((key) => key.startsWith('@media'));
49
+
50
+ expect(keys).toEqual([breakpointDownQuery(480)]);
51
+ expect(theme.typography.h4[keys[0]]).toEqual({ fontSize: '23px' });
52
+ });
53
+ });
@@ -754,35 +754,38 @@
754
754
  "fontWeightRegular": 400,
755
755
  "h1": {
756
756
  "fontFamily": "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
757
- "fontSize": "32px",
757
+ "fontSize": "36px",
758
758
  "fontWeight": 600,
759
759
  "letterSpacing": 0,
760
760
  "lineHeight": 1.2
761
761
  },
762
762
  "h2": {
763
763
  "fontFamily": "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
764
- "fontSize": "28px",
764
+ "fontSize": "32px",
765
765
  "fontWeight": 600,
766
766
  "letterSpacing": 0,
767
767
  "lineHeight": 1.22
768
768
  },
769
769
  "h3": {
770
770
  "fontFamily": "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
771
- "fontSize": "24px",
771
+ "fontSize": "28px",
772
772
  "fontWeight": 600,
773
773
  "letterSpacing": 0,
774
774
  "lineHeight": 1.25
775
775
  },
776
776
  "h4": {
777
+ "@media (max-width:599.95px)": {
778
+ "fontSize": "23px"
779
+ },
777
780
  "fontFamily": "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
778
- "fontSize": "20px",
781
+ "fontSize": "24px",
779
782
  "fontWeight": 600,
780
783
  "letterSpacing": 0,
781
784
  "lineHeight": 1.3
782
785
  },
783
786
  "h5": {
784
787
  "fontFamily": "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
785
- "fontSize": "18px",
788
+ "fontSize": "20px",
786
789
  "fontWeight": 600,
787
790
  "letterSpacing": 0,
788
791
  "lineHeight": 1.3
@@ -813,7 +816,7 @@
813
816
  "pxToRem": "size => `${size / htmlFontSize * coef}rem`",
814
817
  "subtitle1": {
815
818
  "fontFamily": "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
816
- "fontSize": "15px",
819
+ "fontSize": "13px",
817
820
  "fontWeight": 600,
818
821
  "letterSpacing": 0,
819
822
  "lineHeight": 1.4
@@ -1867,35 +1870,38 @@
1867
1870
  "fontWeightRegular": 400,
1868
1871
  "h1": {
1869
1872
  "fontFamily": "Fixture Sans",
1870
- "fontSize": "32px",
1873
+ "fontSize": "36px",
1871
1874
  "fontWeight": 600,
1872
1875
  "letterSpacing": 0,
1873
1876
  "lineHeight": 1.2
1874
1877
  },
1875
1878
  "h2": {
1876
1879
  "fontFamily": "Fixture Sans",
1877
- "fontSize": "28px",
1880
+ "fontSize": "32px",
1878
1881
  "fontWeight": 600,
1879
1882
  "letterSpacing": 0,
1880
1883
  "lineHeight": 1.22
1881
1884
  },
1882
1885
  "h3": {
1883
1886
  "fontFamily": "Fixture Sans",
1884
- "fontSize": "24px",
1887
+ "fontSize": "28px",
1885
1888
  "fontWeight": 600,
1886
1889
  "letterSpacing": 0,
1887
1890
  "lineHeight": 1.25
1888
1891
  },
1889
1892
  "h4": {
1893
+ "@media (max-width:599.95px)": {
1894
+ "fontSize": "23px"
1895
+ },
1890
1896
  "fontFamily": "Fixture Sans",
1891
- "fontSize": "20px",
1897
+ "fontSize": "24px",
1892
1898
  "fontWeight": 600,
1893
1899
  "letterSpacing": 0,
1894
1900
  "lineHeight": 1.3
1895
1901
  },
1896
1902
  "h5": {
1897
1903
  "fontFamily": "Fixture Sans",
1898
- "fontSize": "18px",
1904
+ "fontSize": "20px",
1899
1905
  "fontWeight": 600,
1900
1906
  "letterSpacing": 0,
1901
1907
  "lineHeight": 1.3
@@ -1926,7 +1932,7 @@
1926
1932
  "pxToRem": "size => `${size / htmlFontSize * coef}rem`",
1927
1933
  "subtitle1": {
1928
1934
  "fontFamily": "Fixture Sans",
1929
- "fontSize": "15px",
1935
+ "fontSize": "13px",
1930
1936
  "fontWeight": 600,
1931
1937
  "letterSpacing": 0,
1932
1938
  "lineHeight": 1.4