@micha.bigler/ui-core-micha 3.10.0 → 3.11.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 +6 -0
- package/DESIGN.md +11 -0
- package/WORK_ORDERS.md +4 -2
- package/dev/main.jsx +3 -3
- package/dist/theme/createAppTheme.js +123 -20
- package/dist/theme/themeCompleteness.js +13 -7
- package/dist/theme/tokens.js +42 -0
- package/docs/DARK-TOKENS.md +140 -0
- package/package.json +1 -1
- package/src/theme/createAppTheme.js +139 -12
- package/src/theme/themeCompleteness.js +10 -4
- package/src/theme/tokens.js +72 -0
- package/tests/createAppThemeDark.test.js +127 -0
- package/tests/createAppThemeLightGuard.test.js +40 -0
- package/tests/fixtures/light-theme-baseline.json +2228 -0
- package/work-orders/UCM-I18N-5.md +80 -0
- package/work-orders/UCM-THEME-14.md +178 -8
- package/work-orders/assets/UCM-THEME-13-dark-prototype.html +390 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.11.0 — UCM-THEME-14
|
|
4
|
+
|
|
5
|
+
Added dark-mode composition to `createAppTheme` from the central dark token baseline, including own-surface
|
|
6
|
+
contrast derivation, safe primary/status colours, adaptive data series, dark overlays, scrim, and autofill.
|
|
7
|
+
The light theme output remains unchanged.
|
|
8
|
+
|
|
3
9
|
## 3.10.0 — UCM-TEST-1
|
|
4
10
|
|
|
5
11
|
Added the exported `checkKitIntegration` consumer check for app i18n, providers, kit page rendering, and auth error translation wiring.
|
package/DESIGN.md
CHANGED
|
@@ -6,6 +6,17 @@ These principles are the spec a **prototype** encodes and the implementation mat
|
|
|
6
6
|
|
|
7
7
|
## Identity
|
|
8
8
|
|
|
9
|
+
### Dark mode
|
|
10
|
+
|
|
11
|
+
Opt in with `createAppTheme({ palette: { mode: 'dark', primary } })`. The factory keeps a primary that
|
|
12
|
+
already clears 4.5:1 on the dark page and lightens one that does not; its button text is whichever of
|
|
13
|
+
`#FFFFFF` and the page colour has the stronger contrast. Baseline status colours and control borders are
|
|
14
|
+
lightened only when a baseline-owned value fails on an app's dark page; app-supplied values remain owned by
|
|
15
|
+
the app. Dialogs, menus, popovers, and drawers use the dark overlay surface and shadow, while resting
|
|
16
|
+
surfaces remain outlined and shadowless. Data-series colours are lightened until they clear 3:1 on the
|
|
17
|
+
theme's own paper, so an app-owned dark paper adapts without changing its supplied series. Apps own their
|
|
18
|
+
dark primary and may own reviewed dark surfaces and ink; the baseline owns the remaining dark tokens.
|
|
19
|
+
|
|
9
20
|
The baseline's page-derived colours are `success.main`, `warning.main`, `error.main`, `info.main`,
|
|
10
21
|
`controlBorder.main`, `controlBorder.hover`, and `controlBorder.error`, alongside the existing
|
|
11
22
|
`controlBorder.focus`: a fixed colour cannot promise WCAG contrast on every page background. The baseline therefore
|
package/WORK_ORDERS.md
CHANGED
|
@@ -28,6 +28,7 @@ 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 | planned | | 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. |
|
|
31
32
|
| 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. |
|
|
32
33
|
| 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. |
|
|
33
34
|
| 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). |
|
|
@@ -37,8 +38,9 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
37
38
|
| 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. |
|
|
38
39
|
| 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. |
|
|
39
40
|
| 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. |
|
|
40
|
-
| UCM-THEME-
|
|
41
|
-
| UCM-THEME-
|
|
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 |
|
|
42
|
+
| 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
|
+
| 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). |
|
|
42
44
|
| 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). |
|
|
43
45
|
| AUTH-8 | `extractErrorInfo`s `GENERIC`-Code ueberschreibt jeden `defaultCode` | Bei jedem DRF-Fehler mit `{"detail": "..."}`-Form (u.a. Django `Throttled`/`Http404`/`PermissionDenied` — genau die Form, die den urspruenglichen 429 aus `kira` ausloeste) liefert `extractErrorInfo` (`src/utils/auth-errors.js`) `code: 'GENERIC'`; `normaliseApiError`s `info.code \|\| defaultCode` waehlt dann IMMER `'GENERIC'`, nie den vom Aufrufer uebergebenen `defaultCode` (z. B. `'Auth.LOGIN_FAILED'`). `'GENERIC'` ist an keiner Stelle als i18n-Key registriert — die UI zeigt vermutlich den rohen String statt einer Uebersetzung. **Betrifft JEDE bestehende `normaliseApiError(...)`-Call-Site in diesem Repo**, nicht nur `loginWithPassword`. Spec [`work-orders/AUTH-8.md`](work-orders/AUTH-8.md) | 2026-09-05 | done | d3af6ac | Gefunden von `ui_reviewer` waehrend `AUTH-7`s Review, empirisch bestaetigt (`node --input-type=module -e "..."` gegen die echte Funktion). Operator-Entscheidung 2026-09-05: zuerst angehen. **Implementiert (Codex gpt-5.6-luna):** `code: 'GENERIC'` -> `code: null` im `data.detail`-Zweig, die anderen zwei Zweige unveraendert; neue `tests/authErrors.test.js` (4 Tests). review: codex/gpt-5.6-luna (`reviewer` 4 Lenses + `ui_reviewer` + `sec_reviewer`, ein Hintergrund-Batch) 1 raised · 1 accepted · worst accepted: defect — `envelope`/`regression`/`duplication`/`tests`/`sec_reviewer`: 0 raised (regression bestaetigte zusaetzlich per Grep: keine bestehende Test-Assertion haengt am alten `'GENERIC'`-Wert). `ui_reviewer` fand einen ECHTEN, empirisch bestaetigten Zusatzbefund: 7 von 39 im Code verwendeten `defaultCode`-Schluesseln (`Auth.RECOVERY_LOGIN_FAILED`, `Auth.USER_LIST_FAILED`, `Auth.USER_DELETE_FAILED`, `Auth.USER_ROLE_UPDATE_FAILED`, `Auth.USER_SUPPORT_UPDATE_FAILED`, `Auth.PASSKEY_CANCELLED`, `Auth.MFA_CHALLENGE_FAILED`) waren in KEINER der 4 Sprachen registriert — waeren also auch nach dem Code-Precedence-Fix weiterhin roh statt uebersetzt angezeigt worden. Auf Operator-Wunsch ("zuerst das angehen... stoert mich schon lange, auch dass die Meldungen nicht sauber uebersetzt sind") direkt mitgefixt: alle 7 Keys in `src/i18n/authTranslations.ts` ergaenzt (de/fr/en/sw), Stil an bestehende Nachbar-Keys angelehnt. Erneuter Scan aller 39 Aufrufstellen gegen die Uebersetzungsdatei: 0 fehlend. Tests: `npx vitest run tests/authErrors.test.js tests/authErrorCodeRendering.test.jsx tests/authApiLogin.test.js tests/authApiRegistration.test.js tests/authTranslations.test.js tests/i18nAggregate.test.js` 29/29 gruen. **MINOR-Bump 3.5.1 -> 3.6.0** (nicht PATCH) — aendert beobachtbares Verhalten fuer jeden `{detail}`-foermigen Fehler, keine rein additive Aenderung; Changelog warnt Konsumenten explizit, ihre Fehleranzeige nach dem Pin-Bump gegenzupruefen. |
|
|
44
46
|
| MSG-19 | `DirectMessageLauncher` recipient picker: searchable Autocomplete replaces the flat List | Operator request: the new-DM recipient picker had no search, the same friction the fleet's own "Einfache Bedienung schlägt Vollständigkeit" rule already names ("a picker without search stops working long before the list feels long"). The `List`/`ListItemButton` picker is replaced with an MUI `Autocomplete` bound to the same `selectedId`/`selectedCandidate` state; the component's public prop contract (`candidates`, `scope`, `onOpen`) and its `start()`/scope-resolution logic are unchanged — only the picker's own rendering and selection mechanism changed. | 2026-09-05 | done | 7d99d82 | No WO file — operator-requested, fixed directly. Tier 3 (shared-core, `ui-core-micha`). Only current consumer of this component: jg-ferien (confirmed via a fleet-wide grep for `DirectMessageLauncher`), and jg-ferien's own tests fully mock it, so this change has zero blast radius there until jg-ferien separately bumps its `@micha.bigler/ui-core-micha` pin. Implemented directly in Claude (no Codex dispatch — pure component-level UI change). **review: claude/sonnet · 0 raised** (verified null-safety of `isOptionEqualToValue`/`getOptionLabel` against MUI's own `useAutocomplete` source, confirmed the rewritten test file preserves every one of the original 7 cases plus one new filtering case, confirmed i18n completeness) + **ui_reviewer: claude/sonnet · 4 raised · 4 accepted · worst accepted: defect** — (U1: the new "typed query matches nothing" state had no `noOptionsText` and would have shown MUI's hardcoded, untranslated English default — added `MessagingDirect.NO_MATCHES` in all 4 languages plus a test; U2: the Autocomplete was missing `fullWidth`, breaking from this repo's own one other Autocomplete-in-Dialog convention (`SupportRecoveryRequestsTab.jsx`) and regressing from the old List's full-width block layout — added; U3: reused `MessagingDirect.CANDIDATES`'s old plural-noun wording ("Recipients") as a visible single-select field label read awkwardly — reworded to an instruction-style label ("Select recipient") matching the sibling component's own convention, in all 4 languages; U4 (inferred, not rendered/verified): `autoFocus` on the TextField combined with the dialog's `fullScreen` mobile breakpoint could open the virtual keyboard unprompted — removed `autoFocus` entirely, since MUI's own `Dialog` already focuses the first focusable descendant on open by design, making it redundant). Two additional self-found nits fixed post-review: a defensive null-guard added to `candidateLabel` (matching the sibling `getUserOptionLabel` convention, even though confirmed unreachable under MUI's current calling contract) and a one-line update to `docs/messaging-deviations.md` item 18 (stale reference to "displays... candidate list" not mentioning the new search). Tests: `messagingDirectMessageLauncher.test.jsx` rewritten (9/9 green, up from 7 — the rewrite required discovering and documenting a real jsdom/RTL gotcha: MUI's `Autocomplete` only reacts to a typed value once the input is genuinely `.focus()`-ed, not merely `mouseDown`-ed) + `i18nAggregate.test.js` (4/4) both green; `tsc -p tsconfig.build.json` clean. Version bumped 3.4.0 → 3.5.0 (minor — additive UI capability, no prop-contract change) via the repo's `publish.yml` (push-to-`main` + version bump). jg-ferien bumps its pin separately, in its own repo, once this lands on npm. |
|
package/dev/main.jsx
CHANGED
|
@@ -38,10 +38,10 @@ function Harness() {
|
|
|
38
38
|
const entry = entries.find((item) => item.id === entryId) || entries[0];
|
|
39
39
|
const usesBaselineTheme = ['theme-baseline', 'mobile-bottom-nav'].includes(entry.id);
|
|
40
40
|
const usesBrowserViewport = entry.id === 'mobile-bottom-nav';
|
|
41
|
-
const effectiveMode =
|
|
41
|
+
const effectiveMode = mode;
|
|
42
42
|
const theme = useMemo(
|
|
43
43
|
() => usesBaselineTheme
|
|
44
|
-
? createAppTheme({ palette: { primary: { main: '#3D5A99' } } })
|
|
44
|
+
? createAppTheme({ palette: { mode, primary: { main: '#3D5A99' } } })
|
|
45
45
|
: createTheme({ palette: { mode } }),
|
|
46
46
|
[mode, usesBaselineTheme],
|
|
47
47
|
);
|
|
@@ -55,7 +55,7 @@ function Harness() {
|
|
|
55
55
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ mb: 3 }} alignItems={{ md: 'center' }}>
|
|
56
56
|
<Typography variant="h5" sx={{ mr: 'auto' }}>ui-core-micha harness</Typography>
|
|
57
57
|
<FormControl size="small" sx={{ minWidth: 220 }}><InputLabel id="entry-label">Entry</InputLabel><Select labelId="entry-label" label="Entry" value={entryId} onChange={(event) => setEntryId(event.target.value)}>{entries.map((item) => <MenuItem key={item.id} value={item.id}>{item.label}</MenuItem>)}</Select></FormControl>
|
|
58
|
-
<ButtonGroup size="small"><Button variant={effectiveMode === 'light' ? 'contained' : 'outlined'} onClick={() => setMode('light')}>Light</Button><Button
|
|
58
|
+
<ButtonGroup size="small"><Button variant={effectiveMode === 'light' ? 'contained' : 'outlined'} onClick={() => setMode('light')}>Light</Button><Button variant={effectiveMode === 'dark' ? 'contained' : 'outlined'} onClick={() => setMode('dark')}>Dark</Button></ButtonGroup>
|
|
59
59
|
{usesBrowserViewport ? (
|
|
60
60
|
<Typography variant="caption" color="text.secondary">Use the browser viewport for this specimen</Typography>
|
|
61
61
|
) : (
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './fonts';
|
|
2
|
-
import { createTheme, darken, getContrastRatio } from '@mui/material/styles';
|
|
3
|
-
import { BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS, BASELINE_PALETTE, BASELINE_STATIC, MOTION, withMainShades, } from './tokens';
|
|
2
|
+
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';
|
|
4
4
|
import { calculateContrastRatio } from './themeCompleteness';
|
|
5
5
|
function findFunction(value, path) {
|
|
6
6
|
if (typeof value === 'function')
|
|
@@ -28,6 +28,21 @@ function validateStyleOverrides(components = {}) {
|
|
|
28
28
|
function clearsContrast(colour, surfaces) {
|
|
29
29
|
return surfaces.every((surface) => getContrastRatio(colour, surface) >= 3);
|
|
30
30
|
}
|
|
31
|
+
function normalizeColour(colour) {
|
|
32
|
+
const match = colour.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
33
|
+
if (!match)
|
|
34
|
+
return colour;
|
|
35
|
+
return `#${match.slice(1).map((channel) => Number(channel).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
|
36
|
+
}
|
|
37
|
+
function lightenColour(colour, coefficient) {
|
|
38
|
+
const normalized = normalizeColour(colour);
|
|
39
|
+
const match = normalized.match(/^#([\da-f]{6})$/i);
|
|
40
|
+
if (!match)
|
|
41
|
+
return normalizeColour(lighten(colour, coefficient));
|
|
42
|
+
const channels = [0, 2, 4].map((offset) => parseInt(match[1].slice(offset, offset + 2), 16));
|
|
43
|
+
return `#${channels.map((channel) => Math.round(channel + (255 - channel) * coefficient)
|
|
44
|
+
.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
|
45
|
+
}
|
|
31
46
|
// Darkened until it clears 3:1 against BOTH '#FFFFFF' and background.default
|
|
32
47
|
// -- the exact two surfaces assertThemeComplete's contrastFindings checks --
|
|
33
48
|
// so deriving against background.paper alone (which need not be white for an
|
|
@@ -41,6 +56,17 @@ function deriveFocusColour(primary, surfaces) {
|
|
|
41
56
|
}
|
|
42
57
|
return focus;
|
|
43
58
|
}
|
|
59
|
+
function deriveLightenedColour(original, surfaces) {
|
|
60
|
+
if (clearsContrast(original, surfaces))
|
|
61
|
+
return original;
|
|
62
|
+
let colour = original;
|
|
63
|
+
for (let coefficient = 0.05; !clearsContrast(colour, surfaces); coefficient += 0.05) {
|
|
64
|
+
colour = lightenColour(original, Math.min(coefficient, 0.9));
|
|
65
|
+
if (coefficient >= 0.9)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
return colour;
|
|
69
|
+
}
|
|
44
70
|
function clearsPageContrast(colour, surfaces, threshold) {
|
|
45
71
|
return surfaces.every((surface) => {
|
|
46
72
|
const ratio = calculateContrastRatio(colour, surface);
|
|
@@ -58,6 +84,34 @@ function deriveDarkenedPageColour(original, surfaces, threshold) {
|
|
|
58
84
|
}
|
|
59
85
|
return colour;
|
|
60
86
|
}
|
|
87
|
+
function deriveLightenedPageColour(original, surfaces, threshold) {
|
|
88
|
+
if (clearsPageContrast(original, surfaces, threshold))
|
|
89
|
+
return original;
|
|
90
|
+
let colour = original;
|
|
91
|
+
for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
|
|
92
|
+
colour = lightenColour(original, Math.min(coefficient, 0.9));
|
|
93
|
+
if (coefficient >= 0.9)
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
return colour;
|
|
97
|
+
}
|
|
98
|
+
// ink.primary may be supplied as hex or rgb(); decomposeColor normalises both.
|
|
99
|
+
function inkChannels(ink) {
|
|
100
|
+
return decomposeColor(ink).values.slice(0, 3).map((value) => Math.round(value));
|
|
101
|
+
}
|
|
102
|
+
// 0.02 steps: the frozen sheet value (0.38) is the first even step that clears
|
|
103
|
+
// 3:1; a 0.01 search would land on 0.37 and drift from the frozen baseline.
|
|
104
|
+
function deriveDarkAlphaColour(ink, surfaces) {
|
|
105
|
+
const [red, green, blue] = inkChannels(ink);
|
|
106
|
+
let alpha = 0;
|
|
107
|
+
while (alpha <= 1) {
|
|
108
|
+
const colour = `rgba(${red},${green},${blue},${alpha.toFixed(2)})`;
|
|
109
|
+
if (clearsPageContrast(colour, surfaces, 3))
|
|
110
|
+
return colour;
|
|
111
|
+
alpha = Number((alpha + 0.02).toFixed(2));
|
|
112
|
+
}
|
|
113
|
+
return `rgba(${red},${green},${blue},1)`;
|
|
114
|
+
}
|
|
61
115
|
const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
|
|
62
116
|
function deriveAlphaPageColour(original, surfaces, threshold) {
|
|
63
117
|
if (clearsPageContrast(original, surfaces, threshold))
|
|
@@ -76,7 +130,7 @@ function deriveAlphaPageColour(original, surfaces, threshold) {
|
|
|
76
130
|
}
|
|
77
131
|
return colour;
|
|
78
132
|
}
|
|
79
|
-
function createPaletteAwareComponents(palette) {
|
|
133
|
+
function createPaletteAwareComponents(palette, isDark) {
|
|
80
134
|
const stateTransition = `border-color ${MOTION.duration.fast}ms ${MOTION.easing.state}, color ${MOTION.duration.fast}ms ${MOTION.easing.state}, background-color ${MOTION.duration.fast}ms ${MOTION.easing.state}`;
|
|
81
135
|
const autofill = {
|
|
82
136
|
'&:-webkit-autofill': {
|
|
@@ -98,7 +152,7 @@ function createPaletteAwareComponents(palette) {
|
|
|
98
152
|
'& .MuiAlert-icon': { color: palette[status].text },
|
|
99
153
|
},
|
|
100
154
|
]));
|
|
101
|
-
|
|
155
|
+
const components = {
|
|
102
156
|
MuiButton: {
|
|
103
157
|
styleOverrides: {
|
|
104
158
|
root: { transition: stateTransition },
|
|
@@ -180,6 +234,20 @@ function createPaletteAwareComponents(palette) {
|
|
|
180
234
|
styleOverrides: alertStyles,
|
|
181
235
|
},
|
|
182
236
|
};
|
|
237
|
+
if (isDark) {
|
|
238
|
+
for (const component of ['MuiDialog', 'MuiMenu', 'MuiPopover', 'MuiDrawer']) {
|
|
239
|
+
components[component] = {
|
|
240
|
+
styleOverrides: {
|
|
241
|
+
paper: {
|
|
242
|
+
backgroundColor: DARK_OVERLAY_SURFACE,
|
|
243
|
+
boxShadow: DARK_OVERLAY_SHADOW,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
components.MuiBackdrop = { styleOverrides: { root: { backgroundColor: DARK_SCRIM } } };
|
|
249
|
+
}
|
|
250
|
+
return components;
|
|
183
251
|
}
|
|
184
252
|
/**
|
|
185
253
|
* Builds the shared application theme while leaving app identity in primary
|
|
@@ -187,20 +255,29 @@ function createPaletteAwareComponents(palette) {
|
|
|
187
255
|
* objects are created, so runtime app/site overrides cannot leave stale values.
|
|
188
256
|
*/
|
|
189
257
|
export function createAppTheme(appConfig = {}) {
|
|
190
|
-
var _a, _b, _c, _d, _e, _f;
|
|
258
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p;
|
|
191
259
|
if (!((_a = appConfig === null || appConfig === void 0 ? void 0 : appConfig.palette) === null || _a === void 0 ? void 0 : _a.primary)) {
|
|
192
260
|
throw new TypeError('createAppTheme: appConfig.palette.primary is required.');
|
|
193
261
|
}
|
|
194
262
|
validateStyleOverrides(appConfig.components);
|
|
195
|
-
const
|
|
263
|
+
const isDark = appConfig.palette.mode === 'dark';
|
|
264
|
+
const baselinePalette = isDark ? BASELINE_PALETTE_DARK : BASELINE_PALETTE;
|
|
265
|
+
const paletteTheme = createTheme({ palette: appConfig.palette }, { palette: baselinePalette }, { palette: appConfig.palette });
|
|
196
266
|
const finalPalette = paletteTheme.palette;
|
|
197
|
-
const contrastSurfaces =
|
|
267
|
+
const contrastSurfaces = isDark
|
|
268
|
+
? [finalPalette.background.default, finalPalette.background.paper]
|
|
269
|
+
: ['#FFFFFF', finalPalette.background.default];
|
|
198
270
|
const derivedStatusEntries = {};
|
|
199
271
|
for (const status of ['success', 'warning', 'error', 'info']) {
|
|
200
272
|
if (((_c = (_b = appConfig.palette) === null || _b === void 0 ? void 0 : _b[status]) === null || _c === void 0 ? void 0 : _c.main) !== undefined)
|
|
201
273
|
continue;
|
|
202
274
|
const current = finalPalette[status];
|
|
203
|
-
const
|
|
275
|
+
const statusSurfaces = isDark
|
|
276
|
+
? [finalPalette.background.default, current.bg]
|
|
277
|
+
: contrastSurfaces;
|
|
278
|
+
const main = isDark
|
|
279
|
+
? deriveLightenedPageColour(current.main, statusSurfaces, 4.5)
|
|
280
|
+
: deriveDarkenedPageColour(current.main, statusSurfaces, 4.5);
|
|
204
281
|
if (main !== current.main) {
|
|
205
282
|
derivedStatusEntries[status] = Object.assign(Object.assign({}, current), withMainShades(main, current.contrastText));
|
|
206
283
|
}
|
|
@@ -210,25 +287,51 @@ export function createAppTheme(appConfig = {}) {
|
|
|
210
287
|
if (((_e = (_d = appConfig.palette) === null || _d === void 0 ? void 0 : _d.controlBorder) === null || _e === void 0 ? void 0 : _e[state]) !== undefined)
|
|
211
288
|
continue;
|
|
212
289
|
const current = finalPalette.controlBorder[state];
|
|
213
|
-
const derived =
|
|
214
|
-
?
|
|
215
|
-
|
|
290
|
+
const derived = isDark
|
|
291
|
+
? state === 'error'
|
|
292
|
+
? deriveLightenedPageColour(current, [finalPalette.background.default, finalPalette.error.bg], 3)
|
|
293
|
+
: deriveDarkAlphaColour(finalPalette.ink.primary, contrastSurfaces)
|
|
294
|
+
: state === 'error'
|
|
295
|
+
? deriveDarkenedPageColour(current, contrastSurfaces, 3)
|
|
296
|
+
: deriveAlphaPageColour(current, contrastSurfaces, 3);
|
|
216
297
|
if (derived !== current)
|
|
217
298
|
derivedControlBorderEntries[state] = derived;
|
|
218
299
|
}
|
|
219
|
-
|
|
300
|
+
if (isDark && ((_g = (_f = appConfig.palette) === null || _f === void 0 ? void 0 : _f.controlBorder) === null || _g === void 0 ? void 0 : _g.hover) === undefined) {
|
|
301
|
+
const main = derivedControlBorderEntries.main || finalPalette.controlBorder.main;
|
|
302
|
+
const alpha = Number(((_h = main.match(/,([\d.]+)\)$/)) === null || _h === void 0 ? void 0 : _h[1]) || 0);
|
|
303
|
+
const hoverAlpha = Math.min(1, Number((alpha + 0.15).toFixed(2)));
|
|
304
|
+
const [red, green, blue] = inkChannels(finalPalette.ink.primary);
|
|
305
|
+
derivedControlBorderEntries.hover = `rgba(${red},${green},${blue},${hoverAlpha.toFixed(2)})`;
|
|
306
|
+
}
|
|
307
|
+
const primaryMain = isDark
|
|
308
|
+
? deriveLightenedPageColour(finalPalette.primary.main, [finalPalette.background.default], 4.5)
|
|
309
|
+
: finalPalette.primary.main;
|
|
310
|
+
const primaryContrastText = isDark
|
|
311
|
+
? (getContrastRatio('#FFFFFF', primaryMain) >= getContrastRatio(finalPalette.background.default, primaryMain)
|
|
312
|
+
? '#FFFFFF'
|
|
313
|
+
: finalPalette.background.default)
|
|
314
|
+
: finalPalette.primary.contrastText;
|
|
315
|
+
const derivedSeries = isDark && ((_k = (_j = appConfig.palette) === null || _j === void 0 ? void 0 : _j.dataSeries) === null || _k === void 0 ? void 0 : _k.categorical) === undefined
|
|
316
|
+
? finalPalette.dataSeries.categorical.map((colour) => (deriveLightenedPageColour(colour, [finalPalette.background.paper], 3)))
|
|
317
|
+
: finalPalette.dataSeries.categorical;
|
|
318
|
+
const computedPalette = Object.assign(Object.assign({}, derivedStatusEntries), { controlBorder: Object.assign(Object.assign(Object.assign({}, finalPalette.controlBorder), derivedControlBorderEntries), { focus: isDark
|
|
319
|
+
? ((_o = (_m = (_l = appConfig.palette) === null || _l === void 0 ? void 0 : _l.controlBorder) === null || _m === void 0 ? void 0 : _m.focus) !== null && _o !== void 0 ? _o : deriveLightenedColour(primaryMain, contrastSurfaces))
|
|
320
|
+
: deriveFocusColour(finalPalette.primary.main, contrastSurfaces) }), primary: Object.assign(Object.assign(Object.assign({}, finalPalette.primary), (isDark && primaryMain !== finalPalette.primary.main
|
|
321
|
+
? withMainShades(primaryMain, primaryContrastText)
|
|
322
|
+
: { main: primaryMain })), { contrastText: primaryContrastText }), dataSeries: { categorical: derivedSeries } });
|
|
220
323
|
const resolvedPalette = Object.assign(Object.assign({}, finalPalette), computedPalette);
|
|
221
|
-
const fontFamily = ((
|
|
222
|
-
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] }), palette: resolvedPalette, themeCompleteness: {
|
|
324
|
+
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: {
|
|
223
326
|
baseline: true,
|
|
224
|
-
exemptions:
|
|
327
|
+
exemptions: (isDark
|
|
328
|
+
? BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK
|
|
329
|
+
: BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS).map((exemption) => (Object.assign({}, exemption))),
|
|
225
330
|
} }), {
|
|
226
|
-
components: createPaletteAwareComponents(resolvedPalette),
|
|
331
|
+
components: createPaletteAwareComponents(resolvedPalette, isDark),
|
|
227
332
|
}, appConfig, {
|
|
228
|
-
palette: {
|
|
229
|
-
dataSeries: {
|
|
333
|
+
palette: Object.assign(Object.assign({}, (isDark ? computedPalette : {})), { dataSeries: {
|
|
230
334
|
categorical: [...resolvedPalette.dataSeries.categorical],
|
|
231
|
-
},
|
|
232
|
-
},
|
|
335
|
+
} }),
|
|
233
336
|
});
|
|
234
337
|
}
|
|
@@ -237,7 +237,7 @@ export function calculateContrastRatio(foreground, background) {
|
|
|
237
237
|
return (light + 0.05) / (dark + 0.05);
|
|
238
238
|
}
|
|
239
239
|
function contrastFindings(theme) {
|
|
240
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
240
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
|
241
241
|
const findings = [];
|
|
242
242
|
for (const status of STATUS_KEYS) {
|
|
243
243
|
const tokens = (_a = theme.palette) === null || _a === void 0 ? void 0 : _a[status];
|
|
@@ -262,12 +262,18 @@ function contrastFindings(theme) {
|
|
|
262
262
|
});
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
265
|
+
const dark = ((_b = theme.palette) === null || _b === void 0 ? void 0 : _b.mode) === 'dark';
|
|
266
|
+
const backgrounds = dark
|
|
267
|
+
? {
|
|
268
|
+
paper: (_d = (_c = theme.palette) === null || _c === void 0 ? void 0 : _c.background) === null || _d === void 0 ? void 0 : _d.paper,
|
|
269
|
+
page: (_f = (_e = theme.palette) === null || _e === void 0 ? void 0 : _e.background) === null || _f === void 0 ? void 0 : _f.default,
|
|
270
|
+
}
|
|
271
|
+
: {
|
|
272
|
+
white: '#FFFFFF',
|
|
273
|
+
page: (_h = (_g = theme.palette) === null || _g === void 0 ? void 0 : _g.background) === null || _h === void 0 ? void 0 : _h.default,
|
|
274
|
+
};
|
|
269
275
|
for (const status of STATUS_KEYS) {
|
|
270
|
-
const main = (
|
|
276
|
+
const main = (_k = (_j = theme.palette) === null || _j === void 0 ? void 0 : _j[status]) === null || _k === void 0 ? void 0 : _k.main;
|
|
271
277
|
if (!main)
|
|
272
278
|
continue;
|
|
273
279
|
for (const [surface, background] of Object.entries(backgrounds)) {
|
|
@@ -283,7 +289,7 @@ function contrastFindings(theme) {
|
|
|
283
289
|
}
|
|
284
290
|
}
|
|
285
291
|
for (const state of ['main', 'hover', 'focus', 'error']) {
|
|
286
|
-
const colour = (
|
|
292
|
+
const colour = (_m = (_l = theme.palette) === null || _l === void 0 ? void 0 : _l.controlBorder) === null || _m === void 0 ? void 0 : _m[state];
|
|
287
293
|
if (!colour)
|
|
288
294
|
continue;
|
|
289
295
|
for (const [surface, background] of Object.entries(backgrounds)) {
|
package/dist/theme/tokens.js
CHANGED
|
@@ -78,6 +78,47 @@ export const BASELINE_PALETTE = {
|
|
|
78
78
|
categorical: SERIES_COLOURS,
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
|
+
export const DARK_OVERLAY_SURFACE = '#232A31';
|
|
82
|
+
export const DARK_OVERLAY_SHADOW = '0 8px 28px rgba(0,0,0,.45)';
|
|
83
|
+
export const DARK_SCRIM = 'rgba(0,0,0,.55)';
|
|
84
|
+
export const BASELINE_PALETTE_DARK = {
|
|
85
|
+
mode: 'dark',
|
|
86
|
+
ink: {
|
|
87
|
+
primary: '#E6EBEF',
|
|
88
|
+
secondary: '#AAB5BF',
|
|
89
|
+
muted: '#87919A',
|
|
90
|
+
},
|
|
91
|
+
text: {
|
|
92
|
+
primary: '#E6EBEF',
|
|
93
|
+
secondary: '#AAB5BF',
|
|
94
|
+
disabled: '#87919A',
|
|
95
|
+
},
|
|
96
|
+
background: {
|
|
97
|
+
default: '#14181B',
|
|
98
|
+
paper: '#1B2126',
|
|
99
|
+
subtle: '#20262C',
|
|
100
|
+
},
|
|
101
|
+
divider: '#2B333A',
|
|
102
|
+
controlBorder: {
|
|
103
|
+
// These are replaced by the dark alpha derivation in createAppTheme.
|
|
104
|
+
main: 'rgba(230,235,239,0)',
|
|
105
|
+
hover: 'rgba(230,235,239,0)',
|
|
106
|
+
error: '#E58B80',
|
|
107
|
+
},
|
|
108
|
+
success: Object.assign(Object.assign({}, withMainShades('#7BC496', '#14181B')), { text: '#7BC496', fill: '#7BC496', fillText: '#14181B', bg: '#1C2A22' }),
|
|
109
|
+
warning: Object.assign(Object.assign({}, withMainShades('#D8BD7E', '#14181B')), { text: '#D8BD7E', fill: '#D8BD7E', fillText: '#14181B', bg: '#2A2418' }),
|
|
110
|
+
error: Object.assign(Object.assign({}, withMainShades('#E58B80', '#14181B')), { text: '#E58B80', fill: '#E58B80', fillText: '#14181B', bg: '#3A2320' }),
|
|
111
|
+
info: Object.assign(Object.assign({}, withMainShades('#8FB8E0', '#14181B')), { text: '#8FB8E0', fill: '#8FB8E0', fillText: '#14181B', bg: '#1A2633' }),
|
|
112
|
+
stale: {
|
|
113
|
+
text: '#AAB5BF',
|
|
114
|
+
fill: '#AAB5BF',
|
|
115
|
+
fillText: '#14181B',
|
|
116
|
+
bg: '#20262C',
|
|
117
|
+
},
|
|
118
|
+
dataSeries: {
|
|
119
|
+
categorical: SERIES_COLOURS,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
81
122
|
export const OVERLAY_SHADOW = '0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)';
|
|
82
123
|
export const MOTION = {
|
|
83
124
|
duration: {
|
|
@@ -348,3 +389,4 @@ export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS = [
|
|
|
348
389
|
reason: `The canonical ${variant} weight deliberately equals MUI's numeric default.`,
|
|
349
390
|
})),
|
|
350
391
|
];
|
|
392
|
+
export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK = BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.filter(({ surface }) => surface !== 'palette.background.paper');
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Dark-mode baseline tokens
|
|
2
|
+
|
|
3
|
+
Reference sheet for `UCM-THEME-13`. **This file is the value source** for the dark baseline: `UCM-THEME-14`
|
|
4
|
+
(dark mode in `createAppTheme`) takes every dark value from here by token name, and if code and this
|
|
5
|
+
sheet ever disagree, this sheet is the one to fix first.
|
|
6
|
+
|
|
7
|
+
Instrument: [`work-orders/assets/UCM-THEME-13-dark-prototype.html`](../work-orders/assets/UCM-THEME-13-dark-prototype.html)
|
|
8
|
+
(the screen the values were judged on, at 375 px and 1280 px, with contrasts computed in the page).
|
|
9
|
+
|
|
10
|
+
## Frozen
|
|
11
|
+
|
|
12
|
+
**Frozen 2026-10-02 by the operator** ("Passt so, bitte einfrieren"), after two value rounds.
|
|
13
|
+
|
|
14
|
+
## Structural decisions (operator, 2026-10-01)
|
|
15
|
+
|
|
16
|
+
1. **Complete baseline.** Every mode-dependent token class of the light baseline has a dark value, so an app
|
|
17
|
+
that turns dark on is complete and `assertThemeComplete` can judge dark as it judges light.
|
|
18
|
+
2. **Outlined first, as in light.** Resting surfaces carry no shadow. Structure comes from borders and a
|
|
19
|
+
small surface step. Only overlays (dialog, menu, popover, drawer) stand out, through a lighter surface;
|
|
20
|
+
their shadow is an edge, not the means of separation.
|
|
21
|
+
3. **Primary is derived unless the app sets it.** The factory lightens the app's light primary until it
|
|
22
|
+
clears 4.5:1 on the dark page. An app that supplies its own dark primary (webshop-guenter: `#D49040`)
|
|
23
|
+
keeps it.
|
|
24
|
+
4. **Fixed dark status set, derivation as a safety net.** The status colours below are decided values. On
|
|
25
|
+
an app page that is not the baseline page, the factory lightens a status colour that would fail there,
|
|
26
|
+
the dark counterpart of the darkening `UCM-THEME-15` does on light pages.
|
|
27
|
+
|
|
28
|
+
## Token table
|
|
29
|
+
|
|
30
|
+
Every value below is either **distilled** (found in the estate, source named), **derived** (computed by a
|
|
31
|
+
stated rule from a decided value) or **invented** (no precedent anywhere; labelled, unvalidated).
|
|
32
|
+
|
|
33
|
+
### Surfaces and ink
|
|
34
|
+
|
|
35
|
+
| Token | Light (today) | Dark | Origin |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| `background.default` (page) | `#FAFAFA` | `#14181B` | distilled: cockpit OBS-10, cockpit KB-23 (`#14181b`), ucm SHELL-2 (`#14171c`) |
|
|
38
|
+
| `background.paper` (card) | `#FFFFFF` | `#1B2126` | distilled: cockpit OBS-10, KB-23 |
|
|
39
|
+
| `background.subtle` | `#F4F5F6` | `#20262C` | distilled: cockpit OBS-10, KB-23 (`surface2`) |
|
|
40
|
+
| overlay surface (dialog, menu, popover, drawer) | = paper, plus shadow | `#232A31` | derived: one step above paper on the same hue, ratio 1.12:1 to paper; new in dark |
|
|
41
|
+
| `divider` | `rgba(33,37,41,.10)` | `#2B333A` | distilled: cockpit OBS-10, KB-23 (`border`) |
|
|
42
|
+
| `ink.primary` / `text.primary` | `#212529` | `#E6EBEF` | distilled: cockpit KB-23 (`ink`); OBS-10 `#e7ebee` |
|
|
43
|
+
| `ink.secondary` / `text.secondary` | `#5B6670` | `#AAB5BF` | distilled: cockpit KB-23 (`ink2`) |
|
|
44
|
+
| `ink.muted` / `text.disabled` | `#6A7178` | `#87919A` | distilled: cockpit OBS-10 (`text3`) |
|
|
45
|
+
|
|
46
|
+
### Controls
|
|
47
|
+
|
|
48
|
+
| Token | Light (today) | Dark | Origin |
|
|
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 |
|
|
51
|
+
| `controlBorder.hover` | `rgba(33,37,41,.65)` | `rgba(230,235,239,.53)` | derived: the light step (+0.15) carried over |
|
|
52
|
+
| `controlBorder.error` | `#BF3227` | `#E58B80` | = `error.main` (dark) |
|
|
53
|
+
| `controlBorder.focus` | derived from primary | derived from primary | rule: lighten the primary until it clears 3:1 on page and paper |
|
|
54
|
+
| autofill field | paper inset, `ink.primary` text and caret | paper inset, `ink.primary` text and caret | derived: the palette-aware block in `createAppTheme.js:97-108` already resolves from the palette and wins over the static light block in `tokens.js`; dark follows once the dark palette is in place |
|
|
55
|
+
|
|
56
|
+
### Status
|
|
57
|
+
|
|
58
|
+
`main` = `text` in dark. `light`/`dark`/`contrastText` follow from `main` through `withMainShades`, with
|
|
59
|
+
`contrastText` = `#14181B`. A solid fill takes the status colour with the page colour as its text (dark
|
|
60
|
+
text on a light fill, the same rule as the primary button).
|
|
61
|
+
|
|
62
|
+
| Status | `text` = `main` | `bg` | `fill` | `fillText` | Origin |
|
|
63
|
+
|---|---|---|---|---|---|
|
|
64
|
+
| success | `#7BC496` | `#1C2A22` | `#7BC496` | `#14181B` | distilled: cockpit KB-23 (`ok`, `ok-bg`) |
|
|
65
|
+
| warning | `#D8BD7E` | `#2A2418` | `#D8BD7E` | `#14181B` | distilled: cockpit KB-23 (`warn`, `warn-bg`) |
|
|
66
|
+
| error | `#E58B80` | `#3A2320` | `#E58B80` | `#14181B` | distilled: cockpit KB-23 (`err`), OBS-10 (`danger-bg`) |
|
|
67
|
+
| info | `#8FB8E0` | `#1A2633` | `#8FB8E0` | `#14181B` | **invented**: no dark info hue anywhere in the estate |
|
|
68
|
+
| stale | `#AAB5BF` | `#20262C` | `#AAB5BF` | `#14181B` | **invented**, like the light `stale` it mirrors: `ink.secondary` on `subtle` |
|
|
69
|
+
|
|
70
|
+
### Data series and shadows
|
|
71
|
+
|
|
72
|
+
| Token | Light (today) | Dark | Origin |
|
|
73
|
+
|---|---|---|---|
|
|
74
|
+
| `dataSeries.categorical` | `#3D5A99 #3E80B8 #2E8F8A #7A5FA8 #9C4F86 #8A7355` | `#506BA3 #3E80B8 #2E8F8A #7A5FA8 #A1588C #8A7355` | derived: each light colour lightened in 0.05 steps until it clears 3:1 on paper; only slots 1 and 5 move |
|
|
75
|
+
| `shadow.rest` | `none` | `none` | structural decision 2 |
|
|
76
|
+
| `shadow.overlay` | `0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)` | `0 8px 28px rgba(0,0,0,.45)` | invented: on near-black the light shadow is invisible; the surface step carries the separation |
|
|
77
|
+
| scrim (behind a modal) | MUI default | `rgba(0,0,0,.55)` | invented |
|
|
78
|
+
|
|
79
|
+
### App identity in dark (rules, not values)
|
|
80
|
+
|
|
81
|
+
| What | Rule | Worked examples (computed) |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| primary, app sets none | lighten the light primary in 0.05 steps until it clears 4.5:1 on `#14181B` | `#468AB2` stays `#468AB2` (4.71:1); hram `#2F4F96` -> `#6D84B6` (4.78:1); survey_app `#432CA1` -> `#8576C2` (4.56:1) |
|
|
84
|
+
| primary button text | whichever of `#FFFFFF` and `#14181B` contrasts more with the derived primary | `#14181B` in all three examples |
|
|
85
|
+
| primary, app sets its own | the app's value | webshop-guenter `#D49040` with `#0E0B08` text (7.35:1) |
|
|
86
|
+
| status on an app page that is not `#14181B` | lighten the status colour until it clears 4.5:1 on its `bg` and on the page | webshop-guenter `#0E0B08`: no status colour needs to move |
|
|
87
|
+
|
|
88
|
+
## Contrast table (computed 2026-10-02 with the instrument's own functions)
|
|
89
|
+
|
|
90
|
+
| Pair | Foreground | Background | Ratio | Threshold |
|
|
91
|
+
|---|---|---|---|---|
|
|
92
|
+
| ink.primary on page / paper / overlay | `#E6EBEF` | `#14181B` / `#1B2126` / `#232A31` | 14.87 / 13.54 / 12.09 | 4.5 |
|
|
93
|
+
| ink.secondary on page / paper / overlay | `#AAB5BF` | same | 8.56 / 7.79 / 6.96 | 4.5 |
|
|
94
|
+
| ink.muted on page / paper | `#87919A` | `#14181B` / `#1B2126` | 5.57 / 5.07 | 3 |
|
|
95
|
+
| success text on bg; main on page / paper; fillText on fill | `#7BC496` | | 7.25; 8.65 / 7.88; 8.65 | 4.5 |
|
|
96
|
+
| warning, same pairs | `#D8BD7E` | | 8.43; 9.78 / 8.90; 9.78 | 4.5 |
|
|
97
|
+
| error, same pairs | `#E58B80` | | 5.78; 7.09 / 6.45; 7.09 | 4.5 |
|
|
98
|
+
| info, same pairs | `#8FB8E0` | | 7.38; 8.59 / 7.82; 8.59 | 4.5 |
|
|
99
|
+
| stale text on bg; fillText on fill | `#AAB5BF` | `#20262C` | 7.32; 8.56 | 4.5 |
|
|
100
|
+
| controlBorder.main on page / paper | `rgba(230,235,239,.38)` | | 3.18 / 3.14 | 3 |
|
|
101
|
+
| controlBorder.hover on page / paper | `rgba(230,235,239,.53)` | | 4.98 / 4.79 | 3 |
|
|
102
|
+
| controlBorder.error on paper | `#E58B80` | `#1B2126` | 6.45 | 3 |
|
|
103
|
+
| dataSeries 1-6 on paper | as above | `#1B2126` | 3.07 / 3.86 / 4.19 / 3.12 / 3.32 / 3.61 | 3 |
|
|
104
|
+
| surface steps: paper/page, overlay/paper, divider/paper, subtle/paper | | | 1.10 / 1.12 / 1.27 / 1.06 | none (structure, not text) |
|
|
105
|
+
|
|
106
|
+
Every pair passes. Two pairs sit close to their threshold: `controlBorder.main` (3.14:1 on paper) and
|
|
107
|
+
`dataSeries` slot 1 (3.07:1). A paper colour that moves lighter breaks them first.
|
|
108
|
+
|
|
109
|
+
## Rejected, and why
|
|
110
|
+
|
|
111
|
+
- **Neutral base B** (`#121212` / `#1C1C1C`, no cast, near MUI) and **C** (`#16181B` / `#1D2024`, the light
|
|
112
|
+
baseline's own cool ink carried into dark). Round 1: at this lightness none of the three could be told
|
|
113
|
+
apart, so the cast was decided on principle: A is the one the estate already uses in three places.
|
|
114
|
+
- **Surface step 2** (paper `#1F262C`, overlay `#29313A`) and **step 3** (paper `#232B32`, overlay
|
|
115
|
+
`#2F3942`). Round 2: step 1, the estate's own, reads as card and dialog already; the larger steps lower
|
|
116
|
+
text and border contrast on cards for no gain the operator could see.
|
|
117
|
+
|
|
118
|
+
## Identity versus baseline
|
|
119
|
+
|
|
120
|
+
The baseline owns everything above. An app owns, in dark as in light, only its primary (optional in dark,
|
|
121
|
+
derived otherwise) and its font. An app with a decided brand (webshop-guenter) may own its dark surfaces and
|
|
122
|
+
ink too; that is the app's identity bucket, not a baseline change.
|
|
123
|
+
|
|
124
|
+
## Unverified
|
|
125
|
+
|
|
126
|
+
Declared, not demonstrated, because a static instrument cannot show them: hover and pressed states, focus
|
|
127
|
+
rings in a running app, native date and select pickers, scrollbars, transitions, and how browsers paint
|
|
128
|
+
autofill in dark. `UCM-THEME-14`'s rendered check is where they are first seen.
|
|
129
|
+
|
|
130
|
+
## Known code that assumes light (input for `UCM-THEME-14`)
|
|
131
|
+
|
|
132
|
+
- `src/theme/createAppTheme.js:221` and `src/theme/themeCompleteness.js:298`: contrast surfaces include a
|
|
133
|
+
fixed `#FFFFFF`; the derivations only darken.
|
|
134
|
+
- `src/theme/tokens.js` `autofill`: fixed `#FFFFFF` inset and `#212529` text. **Shadowed** by the
|
|
135
|
+
palette-aware block in `src/theme/createAppTheme.js:97-108` (corrected 2026-10-02: the first version of
|
|
136
|
+
this sheet listed it as live). Harmless in both modes, but a duplicate that can mislead.
|
|
137
|
+
- `BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS`: the `background.paper` reason ("deliberately MUI white") is
|
|
138
|
+
false in dark.
|
|
139
|
+
- `src/components/QrSignupManager.jsx:361`: `#ffffff` behind a QR code. **Stays white on purpose**: a QR code
|
|
140
|
+
needs a light quiet zone to scan.
|