mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat(theme): add semantic tokens, focus ring, and reduced-motion support
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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,10 @@ import '@mantine/core/styles.css';
|
|||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import '@mantine/spotlight/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 './styles.css';
|
||||||
import './app/i18n/config';
|
import './app/i18n/config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -3,6 +3,10 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import '@mantine/notifications/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/theme/portal.css';
|
||||||
|
|
||||||
import './app/i18n/config';
|
import './app/i18n/config';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from './lib/theme/palettes';
|
|||||||
export * from './lib/theme/base-theme';
|
export * from './lib/theme/base-theme';
|
||||||
export * from './lib/theme/ema-theme';
|
export * from './lib/theme/ema-theme';
|
||||||
export * from './lib/theme/portal-theme';
|
export * from './lib/theme/portal-theme';
|
||||||
|
export * from './lib/theme/status-tone';
|
||||||
export * from './lib/date/date-displayer';
|
export * from './lib/date/date-displayer';
|
||||||
export * from './lib/date/use-date-displayer';
|
export * from './lib/date/use-date-displayer';
|
||||||
export * from './lib/date/ethiopic';
|
export * from './lib/date/ethiopic';
|
||||||
|
|||||||
178
libs/shared/src/lib/theme/semantic.css
Normal file
178
libs/shared/src/lib/theme/semantic.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
libs/shared/src/lib/theme/status-tone.ts
Normal file
51
libs/shared/src/lib/theme/status-tone.ts
Normal file
@@ -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<StatusTone, string> = {
|
||||||
|
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)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user