@micha.bigler/ui-core-micha 3.7.0 → 3.8.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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.8.0 — UCM-THEME-15
4
+
5
+ `createAppTheme` can now derive baseline status `main` colours and control-border colours against the theme's own
6
+ page background when their WCAG contrast would otherwise fail. Values that already pass remain byte-identical, and
7
+ app-supplied values are unchanged. When a status `main` is derived, its `light` and `dark` shades move with it too,
8
+ so components using `error.dark` for hover may change on tinted pages.
9
+
3
10
  Only notable, user-facing changes. Not every version — see `WORK_ORDERS.md` for the full history.
4
11
 
5
12
  ## 3.6.0 — AUTH-8
package/DESIGN.md CHANGED
@@ -6,6 +6,14 @@ These principles are the spec a **prototype** encodes and the implementation mat
6
6
 
7
7
  ## Identity
8
8
 
9
+ The baseline's page-derived colours are `success.main`, `warning.main`, `error.main`, `info.main`,
10
+ `controlBorder.main`, `controlBorder.hover`, and `controlBorder.error`, alongside the existing
11
+ `controlBorder.focus`: a fixed colour cannot promise WCAG contrast on every page background. The baseline therefore
12
+ tries for a passing colour rather than a fixed hex that must appear unchanged on every site — derivation is capped
13
+ (the same bounded darken/alpha steps `controlBorder.focus` already used) and can still leave a contrast finding on a
14
+ page extreme enough that no derived shade clears the threshold; values that already pass remain byte-identical,
15
+ while baseline values that fail are derived against the theme's own page.
16
+
9
17
  1. **Honor the shared baseline and the app's identity; don't invent a parallel system.** `ui-core-micha`'s `createAppTheme` deliberately owns the complete visual baseline. Each app supplies its identity through `palette.primary` and `fontFamily`, plus reviewed overrides where its real design requires them (hram: primary blue `#468AB2`). **Match first, then elevate.** Never invent a parallel language — a made-up teal / pills / breadcrumb vocabulary was the v1 mistake.
10
18
  2. **One accent, deliberately placed.** Interactive = the app's one accent colour. **Data colours are a separate layer with a shared default that apps may override.** Status (green / amber / red) is semantic and is NEVER repurposed as a series colour. Restraint over decoration.
11
19
  3. **Commit to the app's theme reality — with intent.** If the app is single-theme (hram is light-only), commit to that deliberately rather than bolting on dark mode as an afterthought — a decision, not an omission. The estate does NOT mandate light-only; it is per-app — but whichever it is, it is on purpose.
package/WORK_ORDERS.md CHANGED
@@ -27,6 +27,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
27
27
 
28
28
  | ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
29
29
  |---|---|---|---|---|---|---|
30
+ | UCM-THEME-15 | Derive status and control-border colours against the theme's own page | Baseline-supplied status `main` and `controlBorder` colours are derived against `#FFFFFF` + `background.default` (like `controlBorder.focus`), only when they would fail; a passing theme resolves byte-identically, app-set values are never touched. Full order [`work-orders/UCM-THEME-15.md`](work-orders/UCM-THEME-15.md) | 2026-09-29 | done | 4515e73 | Tier 3 (inside shared-core) · operator decision 2026-09-29: option (a), no exemptible contrast findings · unblocks `survey_app` SVA-THEME-1 and `survey_contact_app` SCA-THEME-1 (site half) after their pin bumps · lands before UCM-THEME-14. Gate #1 carried by the committed Envelope + mini-handover. Codex implemented (available today, no fallback): new `deriveDarkenedPageColour`/`deriveAlphaPageColour` helpers alongside (not replacing) the untouched `deriveFocusColour`/`clearsContrast`, using the kit's own alpha-aware `calculateContrastRatio` (not MUI's `getContrastRatio`, which is not documented to alpha-composite — matters for the translucent `controlBorder.main`/`.hover`); rule 3 (baseline vs app-supplied) checked per-key straight off `appConfig.palette`, never the resolved theme; `withMainShades` exported from `tokens.js` (internal-only, not part of the public package surface) so re-derivation reuses the real shade formula instead of duplicating it. Independent `reviewer` (all 4 lenses) + `ui_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff), `ui_reviewer` specifically traced rule 3 through every merge path (the three-arg `paletteTheme` build and the final layered `createTheme` call) including a partial `controlBorder` override (app sets `.hover` only) — confirmed no defect, per-key checks hold: **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui · 6 raised · 3 accepted · worst accepted: defect**. Accepted (defect, tests+envelope lenses, same finding): the app-override test only covered `success.main`/`controlBorder.main`, leaving `warning`/`error`/`info` and `controlBorder.hover`/`.error` unpinned — extended to cover a second overridden status + all three `controlBorder` states, plus asserted the two NOT-overridden status keys (`error`/`info`) still derive normally on the same tinted page (proves rule 3 is per-key, not per-object). (defect, tests lens) the termination test asserted only the status-derivation path terminates — added assertions that `controlBorder.main/hover/error` are also defined (not thrown) on the unreachable page. (nit, ui_reviewer) `DESIGN.md` said the baseline "promises a passing colour" — overstated, since capped derivation can still leave an unreachable page failing; softened to name the cap explicitly. Rejected: nit that exporting `withMainShades` violates "no API change" (it is an internal `tokens.js`↔`createAppTheme.js` module export, never re-exported from the public `src/index.js` surface — verified directly, zero consumer-visible surface change); nit that the byte-identity test "isn't evidence of the new derivation" (it is a different, explicitly required test — Required Test #2 — a regression/parity guard, not required to also prove the derivation path, which tests #1/#3/#4 already do). Orchestrator's own scoped run: `tests/createAppThemePageContrast.test.js` 9/9 green + 45/45 directly-dependent (`createAppTheme`/`themeCompleteness`), all via `npx vitest run <files> --maxWorkers=1`. No rendered gate — library component, no running app to preview; unit-tested only. **Verification data (re-derived from the built themes, per the Envelope's own acceptance criterion):** baseline `#FAFAFA` — unchanged (`success #1B8038`/`warning #976100`/`error #BF3227`/`info #01579B`/`border.main rgba(33,37,41,.50)`/`border.hover rgba(33,37,41,.65)`/`border.error #BF3227`). `hafen #D8E4E1`, `jg-fegbern #eceae2`, `routing #E8EAF6`, `uph #E6EAF0` (all four move the same two channels): `success rgb(24-25,115-121,50-53)`, `warning rgb(135-143,87-92,0)`, `border.main rgba(33,37,41,0.55)`; `error`/`info`/`border.hover`/`border.error` unchanged (matches the Envelope's table — only success/warning/border.main failed on these). `ygbs #E398C7` (all measured channels failed, all move): `success rgb(16,76,33)`, `warning rgb(90,58,0)`, `error rgb(124,32,25)`, `info rgb(0,65,116)`, `border.main rgba(33,37,41,0.6)`, `border.error rgb(171,45,35)`; `border.hover` unchanged (`.65` alpha already clears 3:1 even on this page). **Released as `3.8.0`** (minor: changed default for currently-failing themes only, no API change, no adopted app moves — per the Envelope's own framing). `npm view @micha.bigler/ui-core-micha version` should read `3.8.0` once `publish.yml` runs on this push. Next: add `planned` pin-bump rows in `survey_app/WORK_ORDERS.md` and `survey_contact_app/WORK_ORDERS.md` (from `2.41.3`, classify via the dependency-bump test in `AGENTS.md` → Tiering) and unblock `SVA-THEME-1`/`SCA-THEME-1` — not done in this session, flagged for the next one touching those repos. |
30
31
  | UCM-I18N-4 | [Formal/informal German per consumer; translate hard-coded English in user list and invite panel](work-orders/UCM-I18N-4.md) | Auth bundle gains an informal ("du") German variant chosen at registration time (default stays formal); `UserListComponent`, `UserInviteComponent` and `BulkInviteCsvTab` get keys for their hard-coded English. | 2026-09-29 | done | f4dfbe6 | Raised from jg-ferien's local browser test 2026-09-29; operator 2026-09-29: generic fixes go to ucm. From jg `JG-UX-10`. 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, last of the five, released together). Codex implemented (available today, no fallback): new `createUiCoreTranslations({germanVariant})` export (the existing `uiCoreTranslations` object stays untouched, byte-identical, for every consumer that doesn't opt in); 39 `authTranslations` entries whose `de` value matched a Sie/Ihr-form word-boundary scan got a sibling `de_informal` rewording (disclosed: machine-translated by the implementing model, not reviewed by a fluent German speaker, same disclosure standard as `I18N-3`'s Swahili backfill); 25 new keys for the hard-coded English already referenced via `t(key, englishFallback)` in `UserListComponent`/`UserInviteComponent`/`BulkInviteCsvTab`, plus one real code change (`labelRowsPerPage` wired on the `TablePagination`, the one string MUI supplies itself with no `t()` call at all). 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 · 2 accepted · worst accepted: defect**. Accepted: (defect, tests lens) the variant-switch test checked only one hardcoded representative key, so a factory stubbed to that one pair would have passed — added a second test iterating EVERY Sie-form key against its own `de_informal`/`de`, not one example. (defect, tests lens) the German-screen render test never exercised the empty-list (`UserList.NO_USERS`) or CSV-parse-error paths — added a case for both. Rejected: 3 lenses (envelope, regression, ui) claimed `UserList.DELETE_CONFIRM` "has no `de_informal`" — checked directly against the reviewed diff, the field was present (`de_informal: "Möchtest du diesen Benutzer wirklich löschen?"`); a stale/misread finding, not a real gap. "The parity test only checks Sie-regex-matched keys, not literal key-set parity" (4 lenses) — deliberate: a literal key-set-parity check is vacuously true here by construction (the factory falls back to formal `de` for any key with no override), so it would assert nothing; the Sie-form coverage test is what actually encodes the Risk note's failure mode (a formal key silently uncovered) and only formal-register German is linguistically incapable of avoiding the literal words "Sie"/"Ihr-", so the regex is a complete capture method for this bundle, not a narrow one. "`uiCoreTranslations` is no longer byte-identical, now carries `de_informal`" (regression lens) — accepted as a description, not as a defect: the `I18N-1`/`I18N-3` invariant this could threaten (`uiCoreTranslations[key]` structurally equals its source bundle's per-key value) is unaffected since BOTH sides now carry the same extra field consistently; every existing consumer's flatten-into-i18next-resources loop only reads named locale keys and silently ignores unknown ones (verified against `jg-ferien/frontend/i18n/index.js`'s actual merge loop). **Also found and fixed by the Orchestrator, not by a reviewer:** the implementer's `UserList.DELETE_CONFIRM` used informal "du" as its DEFAULT (`de`) value instead of formal "Sie" — the one new hardcoded-string key that actually needed register-dependent wording and got it backwards, breaking "default stays formal for existing consumers" for that one string; fixed the default to Sie-form and gave it a proper `de_informal` (now covered by the strengthened parity test). Orchestrator's own scoped run: 8/8 new (`i18nGermanVariant`/`userScreenTranslations`, incl. the two added cases) + 60/60 directly-dependent (`i18nAggregate`/`authTranslations`/`themeCompleteness`/`UserListComponent`), 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`** (minor: two new public surfaces — `EmptyState` from `PRIM-2`, `createUiCoreTranslations` here — additive only, nothing removed); this lands all five WOs (`PRIM-2`/`SHELL-7`/`MSG-20`/`AUTH-9`/`I18N-4`) in one combined release per the operator's handover instruction. **`npm view @micha.bigler/ui-core-micha version` should read `3.7.0` once `publish.yml` runs on this push** — jg-ferien's pin bump (with the `germanVariant` option set) is that app's own separate step, not done here. |
31
32
  | UCM-AUTH-9 | [Auth flows that dead-end: rejected invite password hides the form, closed signup looks open](work-orders/UCM-AUTH-9.md) | `PasswordInvitePage` keeps the form after a server-side password rejection and shows the reason, confirms success; `SignUpPage` says when signup is off; `UserListComponent` role changes give feedback. | 2026-09-29 | done | fc28d74 | Raised from jg-ferien's local browser test 2026-09-29; operator 2026-09-29: generic fixes go to ucm. From jg `JG-UX-19` + the role-feedback part of `JG-UX-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): `PasswordInvitePage` split one shared `errorKey` into link-validity (`errorKey`, hides the form) vs submit rejection (`submitErrorKey`, keeps it, reads `err.raw.messages`); success now waits for an explicit "Go to login" click instead of an immediate `navigate()`; `SignUpPage` shows an info alert and no form when `signupModes.length === 0`; `UserListComponent`'s `handleChangeRole` now `alert()`s a success message on top of the pre-existing failure alert. 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 · 9 raised · 4 accepted · worst accepted: blocker**. Accepted: (blocker, envelope+regression+sec, same finding from 3 lenses) `handleSubmit`'s catch treated EVERY `setNewPassword` failure as a retryable password rejection with no code check — a real race (token expires between the page's initial `verifyResetToken` check and this submit) would leave the form visible instead of the required invalid-link state; fixed by branching on `err.code === 'Auth.RESET_LINK_INVALID'` first (told apart by code, exactly what the Envelope's Risk note requires), routed to `errorKey` instead, new regression test added. (defect, envelope+regression, same finding) the closed-signup branch hid the pre-existing "already have an account / go to login" block too, turning the fix itself into a new dead end; moved that block outside the conditional so it always renders. (nit, duplication) the new `Auth.SIGNUP_CLOSED` key duplicated the existing, already-4-locale `Auth.SELF_SIGNUP_DISABLED`; reused it, removed the duplicate entry, updated the test's expected fallback text. Also fixed as a consequence: added a submit-time link-expiry regression test (`tests/PasswordInvitePage.test.jsx`) that would have failed against the original implementation. Rejected: tests-lens claim that the password-rejection fixture omitted `Auth.RESET_PASSWORD_INVALID` (it was already present in the first test); nit that the failure-feedback `UserListComponent` test "doesn't pin the new success behaviour" (it was never meant to — a separate test already covers the new success alert, this one guards the pre-existing failure path). Orchestrator's own scoped run: 17/17 (`PasswordInvitePage`/`SignUpPage`/`UserListComponent`, +1 new race-case test) + 58/58 directly-dependent (`i18nAggregate`/`themeCompleteness`/`authTranslations`), 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`/`MSG-20`/`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. |
32
33
  | 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. |
@@ -1,6 +1,7 @@
1
1
  import './fonts';
2
2
  import { createTheme, darken, getContrastRatio } from '@mui/material/styles';
3
- import { BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS, BASELINE_PALETTE, BASELINE_STATIC, MOTION, } from './tokens';
3
+ import { BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS, BASELINE_PALETTE, BASELINE_STATIC, MOTION, withMainShades, } from './tokens';
4
+ import { calculateContrastRatio } from './themeCompleteness';
4
5
  function findFunction(value, path) {
5
6
  if (typeof value === 'function')
6
7
  return path;
@@ -40,6 +41,41 @@ function deriveFocusColour(primary, surfaces) {
40
41
  }
41
42
  return focus;
42
43
  }
44
+ function clearsPageContrast(colour, surfaces, threshold) {
45
+ return surfaces.every((surface) => {
46
+ const ratio = calculateContrastRatio(colour, surface);
47
+ return Number.isFinite(ratio) && ratio >= threshold;
48
+ });
49
+ }
50
+ function deriveDarkenedPageColour(original, surfaces, threshold) {
51
+ if (clearsPageContrast(original, surfaces, threshold))
52
+ return original;
53
+ let colour = original;
54
+ for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
55
+ colour = darken(original, Math.min(coefficient, 0.9));
56
+ if (coefficient >= 0.9)
57
+ break;
58
+ }
59
+ return colour;
60
+ }
61
+ const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
62
+ function deriveAlphaPageColour(original, surfaces, threshold) {
63
+ if (clearsPageContrast(original, surfaces, threshold))
64
+ return original;
65
+ const match = original.match(RGBA_PATTERN);
66
+ if (!match)
67
+ return original;
68
+ const [, red, green, blue, alphaText] = match;
69
+ let alpha = Number(alphaText);
70
+ let colour = original;
71
+ while (alpha < 1) {
72
+ alpha = Math.min(1, Number((alpha + 0.05).toFixed(2)));
73
+ colour = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
74
+ if (clearsPageContrast(colour, surfaces, threshold))
75
+ return colour;
76
+ }
77
+ return colour;
78
+ }
43
79
  function createPaletteAwareComponents(palette) {
44
80
  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}`;
45
81
  const autofill = {
@@ -151,21 +187,38 @@ function createPaletteAwareComponents(palette) {
151
187
  * objects are created, so runtime app/site overrides cannot leave stale values.
152
188
  */
153
189
  export function createAppTheme(appConfig = {}) {
154
- var _a, _b;
190
+ var _a, _b, _c, _d, _e, _f;
155
191
  if (!((_a = appConfig === null || appConfig === void 0 ? void 0 : appConfig.palette) === null || _a === void 0 ? void 0 : _a.primary)) {
156
192
  throw new TypeError('createAppTheme: appConfig.palette.primary is required.');
157
193
  }
158
194
  validateStyleOverrides(appConfig.components);
159
195
  const paletteTheme = createTheme({ palette: appConfig.palette }, { palette: BASELINE_PALETTE }, { palette: appConfig.palette });
160
196
  const finalPalette = paletteTheme.palette;
161
- const computedPalette = {
162
- controlBorder: Object.assign(Object.assign({}, finalPalette.controlBorder), { focus: deriveFocusColour(finalPalette.primary.main, [
163
- '#FFFFFF',
164
- finalPalette.background.default,
165
- ]) }),
166
- };
197
+ const contrastSurfaces = ['#FFFFFF', finalPalette.background.default];
198
+ const derivedStatusEntries = {};
199
+ for (const status of ['success', 'warning', 'error', 'info']) {
200
+ if (((_c = (_b = appConfig.palette) === null || _b === void 0 ? void 0 : _b[status]) === null || _c === void 0 ? void 0 : _c.main) !== undefined)
201
+ continue;
202
+ const current = finalPalette[status];
203
+ const main = deriveDarkenedPageColour(current.main, contrastSurfaces, 4.5);
204
+ if (main !== current.main) {
205
+ derivedStatusEntries[status] = Object.assign(Object.assign({}, current), withMainShades(main, current.contrastText));
206
+ }
207
+ }
208
+ const derivedControlBorderEntries = {};
209
+ for (const state of ['main', 'hover', 'error']) {
210
+ if (((_e = (_d = appConfig.palette) === null || _d === void 0 ? void 0 : _d.controlBorder) === null || _e === void 0 ? void 0 : _e[state]) !== undefined)
211
+ continue;
212
+ const current = finalPalette.controlBorder[state];
213
+ const derived = state === 'error'
214
+ ? deriveDarkenedPageColour(current, contrastSurfaces, 3)
215
+ : deriveAlphaPageColour(current, contrastSurfaces, 3);
216
+ if (derived !== current)
217
+ derivedControlBorderEntries[state] = derived;
218
+ }
219
+ const computedPalette = Object.assign(Object.assign({}, derivedStatusEntries), { controlBorder: Object.assign(Object.assign(Object.assign({}, finalPalette.controlBorder), derivedControlBorderEntries), { focus: deriveFocusColour(finalPalette.primary.main, contrastSurfaces) }) });
167
220
  const resolvedPalette = Object.assign(Object.assign({}, finalPalette), computedPalette);
168
- const fontFamily = ((_b = appConfig.typography) === null || _b === void 0 ? void 0 : _b.fontFamily) || BASELINE_STATIC.typography.fontFamily;
221
+ const fontFamily = ((_f = appConfig.typography) === null || _f === void 0 ? void 0 : _f.fontFamily) || BASELINE_STATIC.typography.fontFamily;
169
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: {
170
223
  baseline: true,
171
224
  exemptions: BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.map((exemption) => (Object.assign({}, exemption))),
@@ -7,7 +7,7 @@ import { darken, lighten } from '@mui/material/styles';
7
7
  // though `text`/`fill`/`bg` are set correctly, so every status channel MUI
8
8
  // recognises (success/warning/error/info) must carry its own explicit
9
9
  // main/light/dark/contrastText, computed the same way augmentColor would.
10
- const withMainShades = (main, contrastText) => ({
10
+ export const withMainShades = (main, contrastText) => ({
11
11
  main,
12
12
  light: lighten(main, 0.2),
13
13
  dark: darken(main, 0.3),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "sideEffects": [
@@ -7,7 +7,9 @@ import {
7
7
  BASELINE_PALETTE,
8
8
  BASELINE_STATIC,
9
9
  MOTION,
10
+ withMainShades,
10
11
  } from './tokens';
12
+ import { calculateContrastRatio } from './themeCompleteness';
11
13
 
12
14
  function findFunction(value, path) {
13
15
  if (typeof value === 'function') return path;
@@ -53,6 +55,43 @@ function deriveFocusColour(primary, surfaces) {
53
55
  return focus;
54
56
  }
55
57
 
58
+ function clearsPageContrast(colour, surfaces, threshold) {
59
+ return surfaces.every((surface) => {
60
+ const ratio = calculateContrastRatio(colour, surface);
61
+ return Number.isFinite(ratio) && ratio >= threshold;
62
+ });
63
+ }
64
+
65
+ function deriveDarkenedPageColour(original, surfaces, threshold) {
66
+ if (clearsPageContrast(original, surfaces, threshold)) return original;
67
+
68
+ let colour = original;
69
+ for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
70
+ colour = darken(original, Math.min(coefficient, 0.9));
71
+ if (coefficient >= 0.9) break;
72
+ }
73
+ return colour;
74
+ }
75
+
76
+ const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
77
+
78
+ function deriveAlphaPageColour(original, surfaces, threshold) {
79
+ if (clearsPageContrast(original, surfaces, threshold)) return original;
80
+
81
+ const match = original.match(RGBA_PATTERN);
82
+ if (!match) return original;
83
+
84
+ const [, red, green, blue, alphaText] = match;
85
+ let alpha = Number(alphaText);
86
+ let colour = original;
87
+ while (alpha < 1) {
88
+ alpha = Math.min(1, Number((alpha + 0.05).toFixed(2)));
89
+ colour = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
90
+ if (clearsPageContrast(colour, surfaces, threshold)) return colour;
91
+ }
92
+ return colour;
93
+ }
94
+
56
95
  function createPaletteAwareComponents(palette) {
57
96
  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}`;
58
97
  const autofill = {
@@ -179,13 +218,38 @@ export function createAppTheme(appConfig = {}) {
179
218
  { palette: appConfig.palette },
180
219
  );
181
220
  const finalPalette = paletteTheme.palette;
221
+ const contrastSurfaces = ['#FFFFFF', finalPalette.background.default];
222
+ const derivedStatusEntries = {};
223
+ for (const status of ['success', 'warning', 'error', 'info']) {
224
+ if (appConfig.palette?.[status]?.main !== undefined) continue;
225
+
226
+ const current = finalPalette[status];
227
+ const main = deriveDarkenedPageColour(current.main, contrastSurfaces, 4.5);
228
+ if (main !== current.main) {
229
+ derivedStatusEntries[status] = {
230
+ ...current,
231
+ ...withMainShades(main, current.contrastText),
232
+ };
233
+ }
234
+ }
235
+
236
+ const derivedControlBorderEntries = {};
237
+ for (const state of ['main', 'hover', 'error']) {
238
+ if (appConfig.palette?.controlBorder?.[state] !== undefined) continue;
239
+
240
+ const current = finalPalette.controlBorder[state];
241
+ const derived = state === 'error'
242
+ ? deriveDarkenedPageColour(current, contrastSurfaces, 3)
243
+ : deriveAlphaPageColour(current, contrastSurfaces, 3);
244
+ if (derived !== current) derivedControlBorderEntries[state] = derived;
245
+ }
246
+
182
247
  const computedPalette = {
248
+ ...derivedStatusEntries,
183
249
  controlBorder: {
184
250
  ...finalPalette.controlBorder,
185
- focus: deriveFocusColour(finalPalette.primary.main, [
186
- '#FFFFFF',
187
- finalPalette.background.default,
188
- ]),
251
+ ...derivedControlBorderEntries,
252
+ focus: deriveFocusColour(finalPalette.primary.main, contrastSurfaces),
189
253
  },
190
254
  };
191
255
  const resolvedPalette = { ...finalPalette, ...computedPalette };
@@ -8,7 +8,7 @@ import { darken, lighten } from '@mui/material/styles';
8
8
  // though `text`/`fill`/`bg` are set correctly, so every status channel MUI
9
9
  // recognises (success/warning/error/info) must carry its own explicit
10
10
  // main/light/dark/contrastText, computed the same way augmentColor would.
11
- const withMainShades = (main, contrastText) => ({
11
+ export const withMainShades = (main, contrastText) => ({
12
12
  main,
13
13
  light: lighten(main, 0.2),
14
14
  dark: darken(main, 0.3),
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { assertThemeComplete, createAppTheme } from '../src/theme';
4
+ import { BASELINE_PALETTE } from '../src/theme/tokens';
5
+
6
+ const primary = { main: '#0F62FE' };
7
+ const pageBackgrounds = [
8
+ '#FAFAFA',
9
+ '#D8E4E1',
10
+ '#eceae2',
11
+ '#E8EAF6',
12
+ '#E6EAF0',
13
+ '#E398C7',
14
+ ];
15
+
16
+ function pageContrastFindings(theme) {
17
+ return assertThemeComplete(theme).findings.filter(({ surface }) => (
18
+ /^contrast\.(success|warning|error|info)\.main-on-(white|page)$/.test(surface)
19
+ || surface.startsWith('contrast.controlBorder.')
20
+ ));
21
+ }
22
+
23
+ describe('createAppTheme page-sensitive baseline contrast', () => {
24
+ it.each(pageBackgrounds)('derives baseline colours for page %s', (background) => {
25
+ const theme = createAppTheme({ palette: { primary, background: { default: background } } });
26
+
27
+ expect(pageContrastFindings(theme)).toEqual([]);
28
+ });
29
+
30
+ it('keeps all baseline values byte-identical when they already pass', () => {
31
+ const theme = createAppTheme({ palette: { primary } });
32
+
33
+ for (const status of ['success', 'warning', 'error', 'info']) {
34
+ expect(theme.palette[status]).toMatchObject({
35
+ main: BASELINE_PALETTE[status].main,
36
+ light: BASELINE_PALETTE[status].light,
37
+ dark: BASELINE_PALETTE[status].dark,
38
+ contrastText: BASELINE_PALETTE[status].contrastText,
39
+ });
40
+ }
41
+ for (const state of ['main', 'hover', 'error']) {
42
+ expect(theme.palette.controlBorder[state]).toBe(BASELINE_PALETTE.controlBorder[state]);
43
+ }
44
+ });
45
+
46
+ it('leaves app-supplied failing status and control-border values untouched', () => {
47
+ const appConfig = {
48
+ palette: {
49
+ primary,
50
+ background: { default: '#E398C7' },
51
+ success: { main: '#A5D6A7' },
52
+ warning: { main: '#FFE082' },
53
+ controlBorder: { main: '#FFFFFF', hover: '#FAFAFA', error: '#FFCDD2' },
54
+ },
55
+ };
56
+ const theme = createAppTheme(appConfig);
57
+ const findings = assertThemeComplete(theme).findings.map(({ surface }) => surface);
58
+
59
+ expect(theme.palette.success.main).toBe(appConfig.palette.success.main);
60
+ expect(theme.palette.warning.main).toBe(appConfig.palette.warning.main);
61
+ expect(theme.palette.controlBorder.main).toBe(appConfig.palette.controlBorder.main);
62
+ expect(theme.palette.controlBorder.hover).toBe(appConfig.palette.controlBorder.hover);
63
+ expect(theme.palette.controlBorder.error).toBe(appConfig.palette.controlBorder.error);
64
+ for (const surface of [
65
+ 'contrast.success.main-on-white',
66
+ 'contrast.success.main-on-page',
67
+ 'contrast.warning.main-on-white',
68
+ 'contrast.warning.main-on-page',
69
+ 'contrast.controlBorder.main-on-white',
70
+ 'contrast.controlBorder.main-on-page',
71
+ 'contrast.controlBorder.hover-on-white',
72
+ 'contrast.controlBorder.hover-on-page',
73
+ 'contrast.controlBorder.error-on-white',
74
+ 'contrast.controlBorder.error-on-page',
75
+ ]) {
76
+ expect(findings).toContain(surface);
77
+ }
78
+
79
+ // The two untouched status keys (error/info) -- both fail on ygbs's pink
80
+ // per the Envelope's measured table -- must still derive normally, proving
81
+ // rule 3 is checked per status key, not per whole-status-object: this
82
+ // config only overrides success/warning, so error/info stay baseline-owned
83
+ // and should move off their baseline hex and stop failing.
84
+ expect(theme.palette.error.main).not.toBe(BASELINE_PALETTE.error.main);
85
+ expect(theme.palette.info.main).not.toBe(BASELINE_PALETTE.info.main);
86
+ expect(findings).not.toContain('contrast.error.main-on-page');
87
+ expect(findings).not.toContain('contrast.info.main-on-page');
88
+ });
89
+
90
+ it('terminates and leaves findings when the darkening cap cannot pass', () => {
91
+ const create = () => createAppTheme({
92
+ palette: { primary, background: { default: '#666666' } },
93
+ });
94
+
95
+ expect(create).not.toThrow();
96
+ const theme = create();
97
+ expect(theme.palette.success.main).toBeDefined();
98
+ expect(theme.palette.warning.main).toBeDefined();
99
+ expect(theme.palette.error.main).toBeDefined();
100
+ expect(theme.palette.info.main).toBeDefined();
101
+ expect(theme.palette.controlBorder.main).toBeDefined();
102
+ expect(theme.palette.controlBorder.hover).toBeDefined();
103
+ expect(theme.palette.controlBorder.error).toBeDefined();
104
+ expect(assertThemeComplete(theme).findings.map(({ surface }) => surface)).toContain(
105
+ 'contrast.success.main-on-page',
106
+ );
107
+ });
108
+ });
@@ -60,7 +60,9 @@ Non-goals:
60
60
 
61
61
  - **Tier 3 · tests: light-output regression guard, dark completeness, focus contrast in both modes,
62
62
  and the kit's existing theme tests.** A change inside shared-core (`AGENTS.md` -> Tiering).
63
- - Precondition: **`UCM-THEME-13` frozen.**
63
+ - Precondition: **`UCM-THEME-13` frozen**, and **`UCM-THEME-15` landed**. 15 generalises the focus
64
+ derivation into page-derived status and border colours on the same surface pair; build the dark
65
+ branch on top of it, not beside it.
64
66
  - Downstream: `webshop-guenter`/`WSG-THEME-1` waits for this release and for its own pin bump.
65
67
 
66
68
  ## Risks
@@ -0,0 +1,356 @@
1
+ # UCM-THEME-15: Derive status and control-border colours against the theme's own page
2
+
3
+ # A. Envelope, authored by the Expertenchat
4
+
5
+ ## Goal & expected outcome
6
+
7
+ `createAppTheme` already derives one colour against the page it will sit on: `controlBorder.focus`
8
+ (`deriveFocusColour`, `src/theme/createAppTheme.js`). Every other page-sensitive baseline colour is
9
+ fixed. So an app or site whose `background.default` is not near-white gets baseline status and
10
+ border colours that fail WCAG on its own page.
11
+
12
+ After this order, the **baseline-supplied** status `main` colours and control-border colours are
13
+ derived the same way, and only when they would otherwise fail. A theme on the baseline's own page
14
+ resolves exactly as today. A theme on a tinted page gets colours that pass on that page.
15
+ `assertThemeComplete` then returns zero contrast findings for every baseline-supplied colour on the
16
+ estate's real site backgrounds.
17
+
18
+ **Operator decision (2026-09-29):** option (a) of three. The factory derives the colours. Contrast
19
+ findings do **not** become exemptible (option b, which would hide real accessibility failures on
20
+ customer sites), and sites do not get their own status colours (option c, new per-customer design
21
+ decisions).
22
+
23
+ ## Context the operator established
24
+
25
+ - **What the check measures** (`src/theme/themeCompleteness.js`, `contrastFindings`): for each status
26
+ key, `main` against `#FFFFFF` **and** `background.default` at **4.5:1**; `controlBorder.main/hover/
27
+ focus/error` against the same two surfaces at **3:1**. The pairs `text`-on-`bg` and
28
+ `fillText`-on-`fill` are self-contained and do not depend on the page. Contrast findings are appended
29
+ after exemption filtering (`themeCompleteness.js:371`), so no exemption can cover them. That stays.
30
+ - **The baseline values** (`src/theme/tokens.js`, `BASELINE_PALETTE`): `success.main` `#1B8038`,
31
+ `warning.main` `#976100`, `error.main` `#BF3227`, `info.main` `#01579B` (`stale` has no `main`), each
32
+ built by `withMainShades`. `controlBorder.main` `rgba(33,37,41,.50)`, `.hover` `rgba(33,37,41,.65)`,
33
+ `.error` `#BF3227`. `focus` is derived.
34
+ - **Measured on the estate's real site backgrounds (2026-09-29),** baseline colours only:
35
+
36
+ | Page `background.default` | Fails | Darkening needed (MUI `darken`) |
37
+ |---|---|---|
38
+ | baseline `#FAFAFA` | nothing | none |
39
+ | `hafen` `#D8E4E1` | success 3.84, warning 4.00, error 4.35; border.main 2.91 | 0.10 / 0.08 / 0.03; border alpha 0.52 |
40
+ | `jg-fegbern` `#eceae2` | success 4.16, warning 4.33; border.main 2.98 | 0.05 / 0.03; alpha 0.51 |
41
+ | `routing` `#E8EAF6` | success 4.18, warning 4.35; border.main 2.98 | 0.05 / 0.03; alpha 0.51 |
42
+ | `uph` `#E6EAF0` | success 4.15, warning 4.32; border.main 2.97 | 0.05 / 0.03; alpha 0.51 |
43
+ | `ygbs` `#E398C7` | success 2.29, warning 2.38, error 2.59, info 3.38; border.main 2.50, border.error 2.59 | 0.40 / 0.38 / 0.34 / 0.21; alpha 0.59 |
44
+
45
+ Sites are `survey_app/frontend/src/sites/*/theme.js`; `ygbs` also lives in `survey_contact_app`.
46
+ - **Every adopted app already passes.** All ten apps on `createAppTheme` assert
47
+ `assertThemeComplete(theme).findings` equals `[]`, so none of them has a contrast finding today.
48
+
49
+ ## The rule (decided)
50
+
51
+ 1. **Scope of derivation:** `success/warning/error/info.main`, and `controlBorder.main/hover/error`.
52
+ The derived `main` also re-derives its `light`/`dark`/`contrastText` through `withMainShades`, so a
53
+ status entry stays internally consistent. `text`, `fill`, `fillText`, `bg` are untouched.
54
+ 2. **Only when it fails.** A value that already clears its threshold against both `#FFFFFF` and
55
+ `background.default` is returned **unchanged, byte for byte**. So no theme that passes today moves.
56
+ 3. **Only baseline-supplied values.** If the app's own config sets a status `main` or a
57
+ `controlBorder` state, that value is the app's decision. It is never altered, and its finding
58
+ stays.
59
+ 4. **Direction and method:** status `main` and `controlBorder.error` are darkened with MUI `darken` in
60
+ the smallest steps that clear the threshold, as `deriveFocusColour` does. `controlBorder.main/hover`
61
+ keep their ink hue and raise their alpha instead, so they stay a translucent ink.
62
+ 5. **Same surfaces as the check:** `#FFFFFF` and `background.default`, exactly the two that
63
+ `contrastFindings` uses, just as the existing comment at `deriveFocusColour` insists.
64
+ 6. **Unreachable stays visible:** if the maximum step does not clear the threshold, the loop stops,
65
+ returns the last value and the finding remains. It never throws and always terminates. Dark pages
66
+ are `UCM-THEME-14`'s subject, not this order's.
67
+
68
+ ## Scope + non-goals
69
+
70
+ In scope: the derivation in `createAppTheme` (next to `deriveFocusColour`, which may be generalised
71
+ into one helper); `DESIGN.md` (one paragraph: which baseline colours are page-derived, and why);
72
+ `CHANGELOG.md`; a **minor** release. It is a changed default, but only for themes that currently fail,
73
+ and none of the adopted apps does.
74
+
75
+ Non-goals:
76
+ - No change to any baseline token value in `tokens.js`.
77
+ - No exemption path for contrast findings.
78
+ - No change to `text`/`fill`/`fillText`/`bg`, to `dataSeries`, or to anything the app sets itself.
79
+ - No dark-mode handling (`UCM-THEME-14`).
80
+ - No consumer change. `survey_app` and `survey_contact_app` adopt it through their own pin bump.
81
+
82
+ ## Tier · precondition / gate
83
+
84
+ - **Tier 3 · tests: derivation on the six measured pages, byte-identical output on the baseline page,
85
+ app-supplied values untouched, termination; the kit's existing theme tests.** A change inside
86
+ shared-core.
87
+ - Precondition: none.
88
+ - **Order with `UCM-THEME-14`:** this lands first. Both touch the focus derivation and the surface
89
+ pair, and 14 is waiting on a design round (`UCM-THEME-13`) anyway.
90
+ - Downstream: `survey_app`/`SVA-THEME-1` and `survey_contact_app`/`SCA-THEME-1` (site half) wait for
91
+ this release **and** for their own pin bump.
92
+
93
+ ## Risks
94
+
95
+ - **`ygbs` will look different.** Its status colours darken by up to 40 % (success `#1B8038` becomes
96
+ roughly `#104D22`). That is the price of legibility on a mid-pink page. It is visible on the customer
97
+ site, and the operator sees it in `SVA-THEME-1`'s acceptance.
98
+ - **Silent change for a future app on a tinted page.** Expected and intended: the baseline promises a
99
+ passing colour, not a fixed hex. `DESIGN.md` has to say so, or someone will read the hex in
100
+ `tokens.js` and expect it on screen.
101
+ - **Rule 3 needs to know what the app set.** The merged palette alone cannot tell baseline from app
102
+ values. Read the app's config (`appConfig.palette`) before deciding, not the resolved theme.
103
+ - **`light`/`dark` shades shift with a derived `main`.** Components using `error.dark` for hover move
104
+ with it. Correct, but name it in the changelog.
105
+
106
+ ## Required tests to WRITE (you write them and run YOUR OWN new ones; the Orchestrator's run is the gate)
107
+
108
+ Kit test suite, next to the existing theme tests:
109
+
110
+ 1. **Six measured pages.** For each `background.default` in the table, with only `palette.primary`
111
+ given, `assertThemeComplete` has no `contrast.*.main-on-*` and no `contrast.controlBorder.*`
112
+ finding.
113
+ 2. **No movement where nothing fails.** On the baseline page, the resolved status entries and
114
+ `controlBorder` equal today's values exactly. Show that the test fails when one baseline hex is
115
+ altered by hand.
116
+ 3. **App values are untouched.** A config that sets `success.main` to a failing colour on a tinted
117
+ page keeps exactly that colour, and the finding remains.
118
+ 4. **Termination.** A page on which the maximum step cannot reach the threshold (a mid-grey such as
119
+ `#767676`) returns without throwing and keeps the finding.
120
+
121
+ Run, do not write: the kit's existing theme and `kitSxRegistry` tests.
122
+
123
+ ## Parity guardrail
124
+
125
+ No change for any theme that passes today. No API change, no new option.
126
+
127
+ ---
128
+
129
+ # B. Implementation map, filled by the Orchestrator and ADDRESSED TO THE IMPLEMENTER
130
+
131
+ ## Context package
132
+
133
+ ### Current shape (`src/theme/createAppTheme.js`, read in full before editing)
134
+
135
+ - `clearsContrast(colour, surfaces)` (line ~39): `surfaces.every((s) => getContrastRatio(colour, s) >= 3)`
136
+ — hardcoded to 3:1 and to MUI's own `getContrastRatio`. Used ONLY by `deriveFocusColour`.
137
+ - `deriveFocusColour(primary, surfaces)` (line ~47): darkens in `0.05` steps (MUI `darken`), capped at
138
+ coefficient `0.9`, stops and returns the last value if unreached (never throws).
139
+ - `createAppTheme(appConfig)` (line ~170): throws if `!appConfig?.palette?.primary`. Builds
140
+ `paletteTheme = createTheme({palette: appConfig.palette}, {palette: BASELINE_PALETTE}, {palette:
141
+ appConfig.palette})` (app layered, then baseline, then app again — this is why an app-set value
142
+ always wins in `finalPalette`, and why a value present in `BASELINE_PALETTE` but absent from
143
+ `appConfig.palette` is a baseline value). `finalPalette = paletteTheme.palette`. Then:
144
+ ```js
145
+ const computedPalette = {
146
+ controlBorder: {
147
+ ...finalPalette.controlBorder,
148
+ focus: deriveFocusColour(finalPalette.primary.main, ['#FFFFFF', finalPalette.background.default]),
149
+ },
150
+ };
151
+ const resolvedPalette = { ...finalPalette, ...computedPalette };
152
+ ```
153
+ This is the exact seam this WO extends — do not restructure the surrounding merge order,
154
+ `validateStyleOverrides`, or the final `createTheme(...)` call's argument layering.
155
+
156
+ ### Do NOT touch `clearsContrast`/`deriveFocusColour` — add parallel, separate logic instead
157
+
158
+ - The byte-identical-on-baseline-page requirement (test #2) and "no change to anything not
159
+ described here" mean the existing focus derivation must keep behaving exactly as it does today.
160
+ Do not generalise `clearsContrast` into a shared threshold-aware helper by EDITING it in place —
161
+ add new, separate functions for this WO's derivations. (You may factor out a shared shape later if
162
+ you can prove byte-for-byte equivalence, but the safe, required path is: leave the existing two
163
+ functions untouched, add new ones alongside.)
164
+ - **Use `calculateContrastRatio` from `./themeCompleteness`** (already exported, already used by
165
+ `contrastFindings` — the same function the check itself uses) for every NEW contrast test in this
166
+ WO, not MUI's `getContrastRatio`. This matters concretely for `controlBorder.main`/`.hover`: their
167
+ baseline values are translucent (`rgba(33,37,41,.50)`), and `calculateContrastRatio` alpha-composites
168
+ over the background before computing luminance (see its own docstring) — `getContrastRatio` is not
169
+ documented to do this. Using the wrong function here would silently pick the wrong alpha.
170
+
171
+ ### The two derivation shapes (rule 4)
172
+
173
+ 1. **Darken-based** (status `main` for success/warning/error/info; `controlBorder.error`): same
174
+ step/cap shape as `deriveFocusColour` (start `coefficient = 0.05`, `darken(original, min(coefficient,
175
+ 0.9))`, step `+0.05`, stop and return the last value if `coefficient >= 0.9` without clearing) but
176
+ parameterised by threshold (`4.5` for status `main`, `3` for `controlBorder.error`) and using
177
+ `calculateContrastRatio`.
178
+ 2. **Alpha-raising** (`controlBorder.main`, `.hover`): the baseline values are the fixed shape
179
+ `rgba(33, 37, 41, <alpha>)` (`.50` and `.65`). Parse `r`,`g`,`b`,`a` (a small regex is enough — this
180
+ path is NEVER invoked on an app-supplied value, see rule 3 below, so it never needs to handle an
181
+ arbitrary CSS colour format). Step `alpha` up by `0.05`, capped at `1`, producing
182
+ `rgba(${r}, ${g}, ${b}, ${alpha})` each step, stopping when `calculateContrastRatio` clears `3`
183
+ against both surfaces or `alpha` reaches `1` (never throws; if unreached at `alpha = 1`, return that
184
+ last candidate and the finding remains — same "give up, stay visible" contract as the darken path).
185
+
186
+ ### Rule 3 — baseline vs app-supplied, decided from `appConfig.palette`, not the resolved theme
187
+
188
+ Before deriving anything, for each of the 6 targets check the RAW input, not `finalPalette`:
189
+ - Status keys: `appConfig.palette?.[statusKey]?.main !== undefined` → app-supplied, skip entirely
190
+ (leave `finalPalette[statusKey]` exactly as merged, findings stay if any).
191
+ - `controlBorder.main`/`.hover`/`.error`: `appConfig.palette?.controlBorder?.[state] !== undefined` →
192
+ app-supplied, skip.
193
+ - Only when NOT app-supplied: read the value off `finalPalette` (the merged/baseline value), check it
194
+ against `['#FFFFFF', finalPalette.background.default]` at the relevant threshold, and derive only if
195
+ it fails. **A value that already clears both thresholds is returned unchanged, byte for byte** — do
196
+ not run it through `darken`/alpha-step at all if it already passes (this is what keeps the baseline
197
+ page byte-identical: on `#FAFAFA`, everything already clears, so nothing here ever fires).
198
+
199
+ ### Re-deriving a status entry consistently (rule 1)
200
+
201
+ When a status `main` is derived, rebuild that status's `main`/`light`/`dark`/`contrastText` via the
202
+ existing `withMainShades` (`src/theme/tokens.js`, already exported — import it, or re-derive the same
203
+ shades inline using `lighten`/`darken` exactly as `withMainShades` does; prefer importing the real
204
+ function over duplicating its formula) called as `withMainShades(derivedMain, existingContrastText)`
205
+ — `existingContrastText` is `finalPalette[statusKey].contrastText`, unchanged (every baseline status
206
+ already carries `'#FFFFFF'`; do not hardcode `'#FFFFFF'` here, read it off the entry so an
207
+ app-overridden `contrastText` on an otherwise-baseline `main` is respected). `text`/`fill`/`fillText`/
208
+ `bg` on that same status object are spread through unchanged — merge shape:
209
+ `{ ...finalPalette[statusKey], ...withMainShades(derivedMain, finalPalette[statusKey].contrastText) }`.
210
+
211
+ ### Wiring it into `createAppTheme`
212
+
213
+ Extend the existing `computedPalette` object (do not add a second, separate merge step) so
214
+ `resolvedPalette` picks up both the existing `controlBorder.focus` line and the new derivations, e.g.
215
+ (shape, not literal code to paste — adapt to how you factor the helper functions):
216
+ ```js
217
+ const computedPalette = {
218
+ ...derivedStatusEntries, // { success?, warning?, error?, info? } — only keys that were derived
219
+ controlBorder: {
220
+ ...finalPalette.controlBorder,
221
+ ...derivedControlBorderEntries, // { main?, hover?, error? } — only keys that were derived
222
+ focus: deriveFocusColour(finalPalette.primary.main, ['#FFFFFF', finalPalette.background.default]),
223
+ },
224
+ };
225
+ ```
226
+ `stale` has no `main` (`tokens.js`) — `contrastFindings` already skips it via `if (!main) continue;`;
227
+ do not add derivation logic for `stale`.
228
+
229
+ ### Test file
230
+
231
+ New file next to the existing theme tests (`tests/createAppTheme.test.js` already exists — read it
232
+ first; extend it if its structure fits, or add `tests/createAppThemePageContrast.test.js`).
233
+ Use the six real background values from the Envelope's table directly (already verified against
234
+ `survey_app/frontend/src/sites/*/theme.js` — `hafen` `#D8E4E1`, `jg-fegbern` `#eceae2`, `routing`
235
+ `#E8EAF6`, `uph` `#E6EAF0`, `ygbs` `#E398C7`, plus the baseline `#FAFAFA`). Any valid `primary.main`
236
+ works for these (the check is independent of primary) — reuse the kit's own test convention, e.g.
237
+ `'#0F62FE'` (see `tests/StatTile.test.jsx`).
238
+
239
+ 1. For each of the 6 pages: `createAppTheme({ palette: { primary: { main: '#0F62FE' }, background: {
240
+ default: <page hex> } } })`, then `assertThemeComplete(theme).findings` has no entry whose
241
+ `surface` starts with `contrast.` for any status `main-on-*` or any `contrast.controlBorder.*`
242
+ (other, unrelated findings — e.g. undeclared surfaces this WO doesn't touch — are not this test's
243
+ concern; assert on the specific surface-name prefixes, not on the whole array being empty, unless
244
+ this repo's fixture already guarantees a clean baseline call is fully empty — check
245
+ `tests/createAppTheme.test.js`/`tests/themeCompleteness.test.js` for the existing convention here).
246
+ 2. On the baseline page (`background.default` left at its `BASELINE_STATIC`/`BASELINE_PALETTE`
247
+ default, i.e. no override given), `theme.palette.success.main` (and `.light`/`.dark`/
248
+ `.contrastText`), `.warning.main`, `.error.main`, `.info.main`, `.controlBorder.main`, `.hover`,
249
+ `.error` all equal today's exact `BASELINE_PALETTE`/derived values (read them from `tokens.js`,
250
+ do not hand-copy hexes into the test — import `BASELINE_PALETTE` and compare against it directly
251
+ so the test can't drift from the source of truth). Then, per the Envelope's own instruction, show
252
+ this test actually pins something: temporarily hand-edit one baseline hex in a throwaway local copy
253
+ (or otherwise demonstrate) that the assertion fails if the value moved — narrate how you did this
254
+ in your `PROGRESS` line rather than leaving a permanently-broken toggle in the committed test.
255
+ 3. A config that sets e.g. `palette.success.main` to a colour that fails on a tinted page (construct
256
+ one deliberately, e.g. a light green on `ygbs`'s pink) keeps EXACTLY that colour after
257
+ `createAppTheme` (`theme.palette.success.main === appConfig.palette.success.main`), and
258
+ `assertThemeComplete` still reports that specific `contrast.success.main-on-*` finding (proving
259
+ rule 3's app-supplied values are never touched AND their findings are never silently hidden).
260
+ 4. `createAppTheme({ palette: { primary: { main: '#0F62FE' }, background: { default: '#767676' } } })`
261
+ does not throw, returns a defined `theme.palette.success.main` (etc.), and
262
+ `assertThemeComplete(theme).findings` still contains the relevant `contrast.*` entries (mid-grey
263
+ `#767676` is deliberately unreachable within the darken cap — confirm this numerically before
264
+ relying on it: `calculateContrastRatio('#FFFFFF', '#767676')` and the darkened-`main` variants
265
+ should stay below `4.5` even at the `0.9` darken cap; if your arithmetic says otherwise, pick
266
+ another representative unreachable grey and say which and why in `PROGRESS`).
267
+
268
+ Run, do not write: `tests/createAppTheme.test.js` (existing) and `tests/themeCompleteness.test.js`
269
+ (existing) as the directly-dependent regression set — your own focused run only, the Orchestrator
270
+ re-runs both as the gate.
271
+
272
+ ### `DESIGN.md` and `CHANGELOG.md`
273
+
274
+ - `DESIGN.md`: one paragraph — which baseline colours are page-derived (`success`/`warning`/`error`/
275
+ `info.main`, `controlBorder.main`/`.hover`/`.error`, alongside the existing `controlBorder.focus`)
276
+ and why (a fixed hex cannot promise WCAG on every page background; the baseline promises a passing
277
+ colour, not a fixed one — state this explicitly so a future reader doesn't expect the `tokens.js`
278
+ hex to appear unmodified on every site). If `DESIGN.md` does not exist at the repo root, check for
279
+ an equivalent doc (grep for "baseline" or "token" in root `*.md` files) before creating a new one.
280
+ - `CHANGELOG.md`: entry naming the changed default (derived colours are possible now, "byte identical
281
+ when passing today"), and explicitly naming that `light`/`dark` shades move WITH a derived `main`
282
+ (Risk note: a component styled with `error.dark` for hover moves too).
283
+
284
+ ## Do-not-touch / invariants
285
+
286
+ - No change to any literal in `BASELINE_PALETTE`/`BASELINE_STATIC` (`tokens.js`).
287
+ - No exemption path added anywhere in `themeCompleteness.js`.
288
+ - `text`/`fill`/`fillText`/`bg` on every status object, `dataSeries`, and anything the app sets itself
289
+ (beyond the 6 targets named here) are untouched.
290
+ - No dark-mode handling (`UCM-THEME-14`'s subject).
291
+ - Do not touch `survey_app`/`survey_contact_app` — this WO is entirely inside `ui-core-micha`.
292
+
293
+ ## Target repo working directory (absolute)
294
+
295
+ `C:\Users\biglmi\Documents\webapps\ui-core-micha` (branch `main`)
296
+
297
+ ## Preamble — a REQUIRED block IN this file, not something appended at invocation
298
+
299
+ > The text above is the COMPLETE spec — the committed WO file's content, not a plan to refine; there
300
+ > is no separate plan file. Read the nearest `AGENTS.md`, the relevant `.codex/skills/<role>/SKILL.md`, and the
301
+ > app `MEMORY.md` ONLY for conventions. Stay in scope; do not touch auth/permissions/deps/schema/CI
302
+ > unless the spec says so; do not update `MEMORY.md`. **Do NOT edit `WORK_ORDERS.md` — the register
303
+ > row and the review verdicts are the orchestrator's alone.** **Your tools are for editing source
304
+ > and test files (and `DESIGN.md`/`CHANGELOG.md` as named above) and for running the tests you wrote
305
+ > — nothing else.** Do NOT install dependencies, touch a lockfile, run a package manager, or tidy up
306
+ > stray files; if something in the repo state blocks you, stop and report it as
307
+ > `RESULT: BLOCKED <reason>` instead of fixing it. Do NOT `git add`/`commit`/`push` — leave every
308
+ > change uncommitted in the working tree for the orchestrator's independent review. WRITE the tests
309
+ > the `Required tests` section calls for AND **RUN the tests you just wrote** to confirm they execute
310
+ > and pass — that is the ONLY test run you do (NOT the app's affected/full suite, NOT any review).
311
+ > The orchestrator re-runs the authoritative set + does the independent review after you finish —
312
+ > those are the gate; your own run does not count as the gate.
313
+ >
314
+ > Narrate continuously: a `PLAN: <step1> | <step2> | …` line up front, then a single-line
315
+ > `PROGRESS: [<n>/<total>] <present-tense action>` before every relevant action (and `… done` on
316
+ > completion), spaced so no gap exceeds ~2 min, stdout unbuffered, plus exactly one final
317
+ > `RESULT: DONE|BLOCKED <reason>`.
318
+
319
+ ---
320
+
321
+ # C. Orchestrator only, NOT ADDRESSED TO THE IMPLEMENTER
322
+
323
+ > **If you are the implementer reading this work order as your own specification: STOP at this line.**
324
+ > Everything below describes what the Orchestrator does AFTER you finish. You do none of it: no
325
+ > reviewers, no verification run, no register edit, no `git add`/`commit`/`push`.
326
+
327
+ ### Execution directive
328
+
329
+ Check `.claude/codex-status.md` first (newest-first; read it with `head`). No line for today means use
330
+ Codex. Before `git push`, run `git log origin/main..HEAD` and push only if every listed commit is this
331
+ order's (parallel sessions share this checkout).
332
+
333
+ ### Review routing
334
+
335
+ Tier 3: independent `reviewer` (all configured lenses) and `ui_reviewer`, concurrent, one batch,
336
+ before the commit. No `sec_reviewer`. Ask `ui_reviewer` specifically about rule 3: can a
337
+ baseline-supplied value be told apart from an app-supplied one in every merge path?
338
+
339
+ ### Verification
340
+
341
+ The contrast values in the Envelope table are the acceptance data: re-derive them from the built
342
+ themes and put the derived hex per site in the register Notiz. No prototype is in scope. The visual
343
+ consequence is accepted per site in `SVA-THEME-1`, not here.
344
+
345
+ ### Register + commit + release
346
+
347
+ Row -> `done` with the review Notiz in the `AGENTS.md` shape and the published version. The publish
348
+ runs on push to `main` when `package.json`/`src/**` change (`.github/workflows/publish.yml`). Then add
349
+ `planned` pin-bump rows in `survey_app/WORK_ORDERS.md` and `survey_contact_app/WORK_ORDERS.md` (from
350
+ `2.41.3`; classify each by the dependency-bump test in `AGENTS.md` -> Tiering, reading the changelog
351
+ from `2.41.3` on) and unblock `SVA-THEME-1` / `SCA-THEME-1` against them.
352
+
353
+ ### Mini-handover
354
+
355
+ `Orchestrator: implement work-orders/UCM-THEME-15.md in ui-core-micha (main). git pull first, read the
356
+ WO, then follow orchestrate-codex.`