From b63e07305ad0d7c8bf779adc456f58de3817c590 Mon Sep 17 00:00:00 2001 From: fitse-yotor Date: Fri, 21 Aug 2026 12:11:20 +0300 Subject: [PATCH] feat(theme): add semantic tokens, focus ring, and reduced-motion support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the layer feature code has been missing: tokens that name a role — page surface, subtle border, danger — instead of a colour. Every one resolves to a Mantine variable rather than a literal, so they follow the colour scheme for free and cannot drift from the theme. A parallel palette of raw hexes would have recreated exactly the problem this exists to fix. Also closes three accessibility gaps that had no implementation anywhere in the codebase: no :focus-visible rule, no screen-reader-only utility, and no global prefers-reduced-motion handling. The focus work found a real bug, and nearly introduced a worse one. Mantine already rings its own controls, so the first attempt deferred to it with `outline: none` on .mantine-focus-auto. That suppressed Mantine's ring without replacing it, leaving portal buttons with no focus indicator at all — and it only showed up in one app, because which rule won depended on stylesheet order. The rules use identical values, so overlapping them is invisible and safe; opting out is not. The focus test now asserts computed outline width and style, not just pixels, since a screenshot alone would not have caught this. `status-tone.ts` establishes the six-tone vocabulary that the 48 scattered status→colour maps will eventually collapse onto. Nothing consumes it yet. No visual change: the 8 existing baselines pass unmodified. Co-Authored-By: Claude Opus 5 --- apps/backoffice/src/main.tsx | 4 + apps/e2e/visual/theme.spec.ts | 39 ++++ .../backoffice-focus-ring-chromium-win32.png | Bin 0 -> 1464 bytes .../portal-focus-ring-chromium-win32.png | Bin 0 -> 1442 bytes apps/portal/src/main.tsx | 4 + libs/shared/src/index.ts | 1 + libs/shared/src/lib/theme/semantic.css | 178 ++++++++++++++++++ libs/shared/src/lib/theme/status-tone.ts | 51 +++++ 8 files changed, 277 insertions(+) create mode 100644 apps/e2e/visual/theme.spec.ts-snapshots/backoffice-focus-ring-chromium-win32.png create mode 100644 apps/e2e/visual/theme.spec.ts-snapshots/portal-focus-ring-chromium-win32.png create mode 100644 libs/shared/src/lib/theme/semantic.css create mode 100644 libs/shared/src/lib/theme/status-tone.ts diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index 5d991e0df..e85c16aba 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -4,6 +4,10 @@ import '@mantine/core/styles.css'; import '@mantine/notifications/styles.css'; import '@mantine/dates/styles.css'; import '@mantine/spotlight/styles.css'; +// After Mantine's CSS (it defines the variables these tokens resolve to), +// before the app's own, which may override them. Relative because the +// @ema-platform aliases are tsconfig paths, which do not carry subpaths. +import '../../../libs/shared/src/lib/theme/semantic.css'; import './styles.css'; import './app/i18n/config'; import { App } from './app/app'; diff --git a/apps/e2e/visual/theme.spec.ts b/apps/e2e/visual/theme.spec.ts index eee86e15f..0618fa396 100644 --- a/apps/e2e/visual/theme.spec.ts +++ b/apps/e2e/visual/theme.spec.ts @@ -58,4 +58,43 @@ for (const app of APPS) { }); } } + + /** + * The focus ring, captured while actually focused. + * + * The full-page shots above can't show this: nothing is focused in them, so + * a regression that removed the ring entirely would leave them all green. + * Keyboard focus specifically, because `:focus-visible` deliberately does + * not match a mouse click. + */ + test(`${app.name} — focus ring is visible`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await gotoGallery(page, app.url, 'light'); + + const section = page.locator('section, div').filter({ hasText: 'Focus states' }).last(); + await section.scrollIntoViewIfNeeded(); + + const button = page.getByRole('button', { name: 'Button', exact: true }); + await button.focus(); + await expect(button).toBeFocused(); + + // Assert the ring in computed styles as well as pixels. A screenshot alone + // would still pass if the outline came from somewhere unintended, and a + // token that failed to resolve leaves an empty string rather than an error. + const ring = await button.evaluate((el) => { + const s = getComputedStyle(el); + return { + width: s.outlineWidth, + style: s.outlineStyle, + token: getComputedStyle(document.documentElement) + .getPropertyValue('--ema-focus-ring') + .trim(), + }; + }); + expect(ring.style).toBe('solid'); + expect(ring.width).toBe('2px'); + expect(ring.token).not.toBe(''); + + await expect(button).toHaveScreenshot(`${app.name}-focus-ring.png`); + }); } diff --git a/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-focus-ring-chromium-win32.png b/apps/e2e/visual/theme.spec.ts-snapshots/backoffice-focus-ring-chromium-win32.png new file mode 100644 index 0000000000000000000000000000000000000000..434007dca81d23a270f57cafdae32a8fa1a66075 GIT binary patch literal 1464 zcmV;p1xNacP)mLlp>%<{4aOj$)5+!tgFir77%`a<6`cXbh9ZIi{6TaHk}y*v z3~-19S%M@6gULdoAh;QRl!So{%Mb=QK*rX%U0K)me)+EA91BjeZs^)iF1_#j-hID! zukZVP-*@+p)^+`Mx4yJWI(}ZJDR?%Qe{F7zMI<1CxYem`+VA`7N587Uv<&8xC8F6x z5La8(mz^CgZQ8g|R8^Vy=tv4r^>*tA8-m}S3N-(w-s;f00y+#UVmM;tL!{kC&6>i^ z$YNtnh9NJvs^wMEFE`XNBgmaA;)(pXJl8$itSM~et0K=ryQFWc^?mt6cTj=IfTf{0 z-?U`5U=@k(AgrjC4xJ7rSxI7CN!#>HcH1(MAmH$s;EL_uF8?sI@cj_O6B|on3nsI= z4rN;;9MM$H+%=!S5sx!`(>K2NcHfpG#ngR3y=qSeC=#ZQX9}{ogAD=VN~`k72x|Ya zz^WZyhyp%bXr4cfzj#9>(6=9R%da>)(eFhAIJ&pL9`|HAL!$7-q3$SA8&OoK`Mw(q zG&*zibr4OUzPQ5E-<=9T^l=D%JD0xdh9_#q`d=ujPP&Yv zY#z?}VK8X+8Lz@PFLW#otbf8MUUaf$fV+p+! zY?omO2;1f%w_I$Lhy)wnsP1kuo&iJVBJ+ylKbxS6sf-XOB+o%SB%64!?4z`cz+9_3{DA03e3(uwE!lvx`v~w2>3C0^p_z$3RGob#Q z`j6sNij$OsO+)w)Nu)1si`2J{{eYPUG9hj-o!oFCK_tnv0*QGRH2Vh&%n%(^ycnDK z5b17FsjK4hc{!%cu{0(bT^EKVLjM0y{*172rpX@!%%)LV%F3o|%G23g_G1jLa7WGt zBQmhTEb``3vG^$-4ZLitbn2o)rO#O1ct!d7S7k;PmzqQsKF(ilRj;(F5J61OWHF04 zaV(A2F5e;@{wX-hLK2s6DkUFxwzO&EM^l@Z-uYVO_j^%NG00030|MEk@HUIzs21!IgR09CABthn$ SRys-m0000sDJVLt@SjU$QR7@j2r~qtfeimb zj0QE4e=spd!T`l7GnxLe5JaJfuENO5*w|Rv25SrLdhK0XzgK?E1sAh!=-N-3^nKsE zdoSPnzW06azTYE3Sse|>cOCS)dKHF-m5U9Ioo>xEA#WqM=9}W)ymknw7y6WHU=1 z;>#b@8F>mT2S(K0hy0g@)jWyWx?ZAcc3&CxN0-dkz4dICj-`Bo*x{dpZD&Rm6;cD1 zz^W3%^Gl7QK!=t1hu`>5b%k@Y7-n|G#!{PZ^NS*3D5pET{Y&q;J_Tn*%7D6b4Jd8D zhb-A-PA++DA>TWsVhvOI^4nY2NeoRLI2PR7dR>h|N`iVFKPS^PEG*=3{yV$El-m=| zmzZ{!yysg#qycZN&V2F_ecx4;pxPb1kx#x$3$>BYj$TLShaO(ry^fw7O{IlBG0_4$=wK7k7B_YnKe^$4R$Lj-O30gWu%j`7r4jR3QDp{hUP98Fn(A5>RWB z#LuOR(b};{7A( zw^3DbxSfC>bjb?>q*OXJ}kwEH)8VRxi4P zAGC}PX*~#A>-3hZp}jt?*3L-~J@2~lajQH8lV|E85P5<{&=yd4rzTlUY@vx*ZnZJy zTOwl?X)ZCYBoebYP6V)tpKcIWSXd(gPGF16#gA%?x!MIp>(DARR_M$;B?xR;g|w}T zfh0&;b6!`oE&tHA{Fc`<7l6{%GP2h52`1NiyWOYkb8G*nd!hjVj!(+wQ@PGd*PC}NU@Mjx!5xIDpkGGK zX^!V^j}=d5VUJ@fBaQuuLy>UT6tr}6qKd@G|CB!MS?Z7^xA*fie-n@NC}kxq-qmQ5 z0~UQ{p5_ADT46d!-sW_|Z)>hwP-A^Ae^aY=Dr|1P?)1g+xs=)U=BuO5yKe7ByIYL_ zDYok??ff{gd5seIO%}v_L%|I~yLO(p0gY={10gf&yN=wa)boA_O zg287KREX?MrRCEiZ{UuhH7O4*8w#uqlh7 z+ynA^`+eiS7Qt!%XKw_<@k74YNFXZDw68^Yj*~q;gbF|HCU_^cYpdbBO wp2TcfC;bHg0RR6r2mPu5000I_L_t&o09|)FurPD4F#rGn07*qoM6N<$g3N8bAOHXW literal 0 HcmV?d00001 diff --git a/apps/portal/src/main.tsx b/apps/portal/src/main.tsx index 1297bc950..56a6956cd 100644 --- a/apps/portal/src/main.tsx +++ b/apps/portal/src/main.tsx @@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client'; import '@mantine/core/styles.css'; import '@mantine/dates/styles.css'; import '@mantine/notifications/styles.css'; +// After Mantine's CSS (it defines the variables these tokens resolve to), +// before the app's own, which may override them. Relative because the +// @ema-platform aliases are tsconfig paths, which do not carry subpaths. +import '../../../libs/shared/src/lib/theme/semantic.css'; import './app/theme/portal.css'; import './app/i18n/config'; diff --git a/libs/shared/src/index.ts b/libs/shared/src/index.ts index 040d0f5ff..ce06ea28f 100644 --- a/libs/shared/src/index.ts +++ b/libs/shared/src/index.ts @@ -2,6 +2,7 @@ export * from './lib/theme/palettes'; export * from './lib/theme/base-theme'; export * from './lib/theme/ema-theme'; export * from './lib/theme/portal-theme'; +export * from './lib/theme/status-tone'; export * from './lib/date/date-displayer'; export * from './lib/date/use-date-displayer'; export * from './lib/date/ethiopic'; diff --git a/libs/shared/src/lib/theme/semantic.css b/libs/shared/src/lib/theme/semantic.css new file mode 100644 index 000000000..f40f547e6 --- /dev/null +++ b/libs/shared/src/lib/theme/semantic.css @@ -0,0 +1,178 @@ +/* ============================================================================ + Semantic tokens. + + These name a *role* — "the page background", "a subtle border", "danger" — + rather than a colour. Feature code should reach for these instead of a hex, + because a hex cannot follow the colour scheme and a Mantine shade index + (`gray.5`) says nothing about why that shade was chosen. + + Every token resolves to a Mantine variable rather than a literal. That is + deliberate: Mantine already recomputes its own variables under + [data-mantine-color-scheme], so tokens defined in terms of them switch for + free and can never drift from the theme. A parallel palette of raw hexes + would recreate exactly the problem this layer exists to fix. + + Loaded once per app, after Mantine's CSS. + ============================================================================ */ + +:root { + /* --- Surfaces ---------------------------------------------------------- */ + /* The page itself, a raised card, and a recessed well. */ + --ema-surface-page: var(--mantine-color-gray-0); + --ema-surface-raised: var(--mantine-color-white); + --ema-surface-sunken: var(--mantine-color-gray-1); + + /* --- Borders ----------------------------------------------------------- */ + /* Subtle separates rows; strong outlines an input or a focused container. */ + --ema-border-subtle: var(--mantine-color-gray-2); + --ema-border-strong: var(--mantine-color-gray-4); + + /* --- Text -------------------------------------------------------------- */ + /* Secondary must stay a *text* colour: it has to clear 4.5:1, not 3:1, so + it deliberately sits darker than the gray-5 that reads as "dimmed". */ + --ema-text-primary: var(--mantine-color-gray-9); + --ema-text-secondary: var(--mantine-color-gray-7); + --ema-text-disabled: var(--mantine-color-gray-5); + + /* --- Status ------------------------------------------------------------ + Six tones, which is the entire vocabulary a status needs. Domain statuses + map onto these rather than each picking their own colour. + + `-fg` is text on the app background; `-bg` is a tint to sit that text on. + Both are needed because a badge and a label have different contrast + requirements against the same surface. */ + --ema-status-success-fg: var(--mantine-color-green-8); + --ema-status-success-bg: var(--mantine-color-green-0); + --ema-status-warning-fg: var(--mantine-color-yellow-8); + --ema-status-warning-bg: var(--mantine-color-yellow-0); + --ema-status-danger-fg: var(--mantine-color-red-8); + --ema-status-danger-bg: var(--mantine-color-red-0); + --ema-status-info-fg: var(--mantine-color-blue-8); + --ema-status-info-bg: var(--mantine-color-blue-0); + --ema-status-pending-fg: var(--mantine-color-orange-8); + --ema-status-pending-bg: var(--mantine-color-orange-0); + --ema-status-neutral-fg: var(--mantine-color-gray-7); + --ema-status-neutral-bg: var(--mantine-color-gray-1); + + /* --- Focus ------------------------------------------------------------- + One ring for the whole platform. Sized to stay visible against both a + white card and a tinted surface. */ + --ema-focus-ring: var(--mantine-primary-color-filled); + --ema-focus-ring-width: 2px; + --ema-focus-ring-offset: 2px; +} + +[data-mantine-color-scheme='dark'] { + /* Dark is not light inverted. Surfaces lift with elevation rather than + dropping, and text steps down from white rather than up from black. */ + --ema-surface-page: var(--mantine-color-dark-8); + --ema-surface-raised: var(--mantine-color-dark-7); + --ema-surface-sunken: var(--mantine-color-dark-9); + + --ema-border-subtle: var(--mantine-color-dark-4); + --ema-border-strong: var(--mantine-color-dark-3); + + --ema-text-primary: var(--mantine-color-gray-0); + --ema-text-secondary: var(--mantine-color-gray-4); + --ema-text-disabled: var(--mantine-color-dark-2); + + /* Saturated mid-shades go muddy on a dark ground; these step lighter so the + foreground still clears 4.5:1 and the tint stays distinguishable. */ + --ema-status-success-fg: var(--mantine-color-green-4); + --ema-status-success-bg: var(--mantine-color-green-9); + --ema-status-warning-fg: var(--mantine-color-yellow-4); + --ema-status-warning-bg: var(--mantine-color-yellow-9); + --ema-status-danger-fg: var(--mantine-color-red-4); + --ema-status-danger-bg: var(--mantine-color-red-9); + --ema-status-info-fg: var(--mantine-color-blue-4); + --ema-status-info-bg: var(--mantine-color-blue-9); + --ema-status-pending-fg: var(--mantine-color-orange-4); + --ema-status-pending-bg: var(--mantine-color-orange-9); + --ema-status-neutral-fg: var(--mantine-color-gray-4); + --ema-status-neutral-bg: var(--mantine-color-dark-5); +} + +/* ============================================================================ + Focus. + + The codebase had no :focus-visible rule anywhere, which is the single + largest accessibility gap in it. :focus-visible rather than :focus so a + mouse click does not leave a ring behind — that is the behaviour that gets + focus rings deleted from designs in the first place. + ============================================================================ */ + +/* Mantine already rings its own controls (`.mantine-focus-auto:focus-visible` + resolves to the same 2px solid primary). This rule is the safety net for + everything it does not own: plain anchors, custom elements, and the + UnstyledButtons this codebase uses for its own controls. + + Note there is deliberately no `outline: none` opt-out for the Mantine + classes. An earlier attempt at one suppressed Mantine's working ring and + left portal buttons with no focus indicator at all — which of the two rules + won came down to stylesheet order, and that differs between the apps. + Matching values mean overlap is invisible, so overlap is the safe default. */ +:focus-visible { + outline: var(--ema-focus-ring-width) solid var(--ema-focus-ring); + outline-offset: var(--ema-focus-ring-offset); +} + +/* ============================================================================ + Screen-reader-only utility. + + No equivalent existed anywhere in the codebase, so anything needing a text + alternative had nowhere to put it. + ============================================================================ */ + +.ema-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + /* clip-path rather than the legacy clip: it does not force a layer and is + not deprecated. */ + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* A skip link is sr-only until focused, then must be plainly visible. */ +.ema-skip-link { + position: absolute; + top: 0; + left: 0; + z-index: 9999; + padding: 0.75rem 1.25rem; + background: var(--ema-surface-raised); + color: var(--ema-text-primary); + border: 1px solid var(--ema-border-strong); + border-radius: 0 0 var(--mantine-radius-md) 0; + font-weight: 600; + text-decoration: none; + /* Off-screen rather than display:none, so it stays focusable. */ + transform: translateY(-150%); +} + +.ema-skip-link:focus-visible { + transform: translateY(0); +} + +/* ============================================================================ + Reduced motion. + + Honour the OS setting globally. Animation is not removed outright — a + near-instant transition still conveys that something changed, without the + movement that triggers vestibular symptoms. + ============================================================================ */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/libs/shared/src/lib/theme/status-tone.ts b/libs/shared/src/lib/theme/status-tone.ts new file mode 100644 index 000000000..4d68cf716 --- /dev/null +++ b/libs/shared/src/lib/theme/status-tone.ts @@ -0,0 +1,51 @@ +/** + * The platform's status vocabulary. + * + * There are 48 separate status→colour maps across the codebase, each deciding + * independently what "pending" looks like. They disagree. The fix is not one + * bigger map — domain statuses genuinely differ per feature — but one small set + * of *tones* that every domain maps onto, so the colour decision is made six + * times instead of forty-eight. + * + * `semantic.css` carries the CSS-variable form of these for stylesheet use. + * This module is for the many places that need a Mantine `color` prop instead. + */ + +export type StatusTone = + | 'success' + | 'warning' + | 'danger' + | 'info' + | 'pending' + | 'neutral'; + +/** + * Tone → Mantine colour name. + * + * Deliberately the only place a tone becomes a colour. Changing the platform's + * idea of "warning" is an edit here, not a sweep through 48 files. + */ +export const STATUS_TONE_COLOR: Record = { + success: 'green', + warning: 'yellow', + danger: 'red', + info: 'blue', + pending: 'orange', + neutral: 'gray', +}; + +/** + * Tone → CSS custom properties, for inline styles and stylesheets. + * + * Returns variable references rather than resolved colours so the values keep + * following the active colour scheme. + */ +export function statusToneVars(tone: StatusTone): { + color: string; + background: string; +} { + return { + color: `var(--ema-status-${tone}-fg)`, + background: `var(--ema-status-${tone}-bg)`, + }; +}