@micha.bigler/ui-core-micha 3.10.0 → 3.11.1
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 +13 -0
- package/DESIGN.md +11 -0
- package/WORK_ORDERS.md +4 -2
- package/dev/main.jsx +3 -3
- package/dist/components/UserListComponent.js +9 -1
- package/dist/i18n/authTranslations.js +12 -0
- package/dist/theme/createAppTheme.js +123 -20
- package/dist/theme/themeCompleteness.js +13 -7
- package/dist/theme/tokens.js +42 -0
- package/docs/DARK-TOKENS.md +140 -0
- package/package.json +1 -1
- package/src/components/UserListComponent.jsx +12 -0
- package/src/i18n/authTranslations.ts +12 -0
- package/src/theme/createAppTheme.js +139 -12
- package/src/theme/themeCompleteness.js +10 -4
- package/src/theme/tokens.js +72 -0
- package/tests/UserListComponent.test.jsx +24 -1
- package/tests/authTranslations.test.js +22 -0
- package/tests/createAppThemeDark.test.js +127 -0
- package/tests/createAppThemeLightGuard.test.js +40 -0
- package/tests/fixtures/light-theme-baseline.json +2228 -0
- package/tests/userScreenTranslations.test.jsx +53 -0
- package/work-orders/UCM-I18N-5.md +211 -0
- package/work-orders/UCM-THEME-14.md +178 -8
- package/work-orders/assets/UCM-THEME-13-dark-prototype.html +390 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Dark-mode baseline tokens
|
|
2
|
+
|
|
3
|
+
Reference sheet for `UCM-THEME-13`. **This file is the value source** for the dark baseline: `UCM-THEME-14`
|
|
4
|
+
(dark mode in `createAppTheme`) takes every dark value from here by token name, and if code and this
|
|
5
|
+
sheet ever disagree, this sheet is the one to fix first.
|
|
6
|
+
|
|
7
|
+
Instrument: [`work-orders/assets/UCM-THEME-13-dark-prototype.html`](../work-orders/assets/UCM-THEME-13-dark-prototype.html)
|
|
8
|
+
(the screen the values were judged on, at 375 px and 1280 px, with contrasts computed in the page).
|
|
9
|
+
|
|
10
|
+
## Frozen
|
|
11
|
+
|
|
12
|
+
**Frozen 2026-10-02 by the operator** ("Passt so, bitte einfrieren"), after two value rounds.
|
|
13
|
+
|
|
14
|
+
## Structural decisions (operator, 2026-10-01)
|
|
15
|
+
|
|
16
|
+
1. **Complete baseline.** Every mode-dependent token class of the light baseline has a dark value, so an app
|
|
17
|
+
that turns dark on is complete and `assertThemeComplete` can judge dark as it judges light.
|
|
18
|
+
2. **Outlined first, as in light.** Resting surfaces carry no shadow. Structure comes from borders and a
|
|
19
|
+
small surface step. Only overlays (dialog, menu, popover, drawer) stand out, through a lighter surface;
|
|
20
|
+
their shadow is an edge, not the means of separation.
|
|
21
|
+
3. **Primary is derived unless the app sets it.** The factory lightens the app's light primary until it
|
|
22
|
+
clears 4.5:1 on the dark page. An app that supplies its own dark primary (webshop-guenter: `#D49040`)
|
|
23
|
+
keeps it.
|
|
24
|
+
4. **Fixed dark status set, derivation as a safety net.** The status colours below are decided values. On
|
|
25
|
+
an app page that is not the baseline page, the factory lightens a status colour that would fail there,
|
|
26
|
+
the dark counterpart of the darkening `UCM-THEME-15` does on light pages.
|
|
27
|
+
|
|
28
|
+
## Token table
|
|
29
|
+
|
|
30
|
+
Every value below is either **distilled** (found in the estate, source named), **derived** (computed by a
|
|
31
|
+
stated rule from a decided value) or **invented** (no precedent anywhere; labelled, unvalidated).
|
|
32
|
+
|
|
33
|
+
### Surfaces and ink
|
|
34
|
+
|
|
35
|
+
| Token | Light (today) | Dark | Origin |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| `background.default` (page) | `#FAFAFA` | `#14181B` | distilled: cockpit OBS-10, cockpit KB-23 (`#14181b`), ucm SHELL-2 (`#14171c`) |
|
|
38
|
+
| `background.paper` (card) | `#FFFFFF` | `#1B2126` | distilled: cockpit OBS-10, KB-23 |
|
|
39
|
+
| `background.subtle` | `#F4F5F6` | `#20262C` | distilled: cockpit OBS-10, KB-23 (`surface2`) |
|
|
40
|
+
| overlay surface (dialog, menu, popover, drawer) | = paper, plus shadow | `#232A31` | derived: one step above paper on the same hue, ratio 1.12:1 to paper; new in dark |
|
|
41
|
+
| `divider` | `rgba(33,37,41,.10)` | `#2B333A` | distilled: cockpit OBS-10, KB-23 (`border`) |
|
|
42
|
+
| `ink.primary` / `text.primary` | `#212529` | `#E6EBEF` | distilled: cockpit KB-23 (`ink`); OBS-10 `#e7ebee` |
|
|
43
|
+
| `ink.secondary` / `text.secondary` | `#5B6670` | `#AAB5BF` | distilled: cockpit KB-23 (`ink2`) |
|
|
44
|
+
| `ink.muted` / `text.disabled` | `#6A7178` | `#87919A` | distilled: cockpit OBS-10 (`text3`) |
|
|
45
|
+
|
|
46
|
+
### Controls
|
|
47
|
+
|
|
48
|
+
| Token | Light (today) | Dark | Origin |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `controlBorder.main` | `rgba(33,37,41,.50)` | `rgba(230,235,239,.38)` | derived: `ink.primary` at the smallest alpha that clears 3:1 on page and paper |
|
|
51
|
+
| `controlBorder.hover` | `rgba(33,37,41,.65)` | `rgba(230,235,239,.53)` | derived: the light step (+0.15) carried over |
|
|
52
|
+
| `controlBorder.error` | `#BF3227` | `#E58B80` | = `error.main` (dark) |
|
|
53
|
+
| `controlBorder.focus` | derived from primary | derived from primary | rule: lighten the primary until it clears 3:1 on page and paper |
|
|
54
|
+
| autofill field | paper inset, `ink.primary` text and caret | paper inset, `ink.primary` text and caret | derived: the palette-aware block in `createAppTheme.js:97-108` already resolves from the palette and wins over the static light block in `tokens.js`; dark follows once the dark palette is in place |
|
|
55
|
+
|
|
56
|
+
### Status
|
|
57
|
+
|
|
58
|
+
`main` = `text` in dark. `light`/`dark`/`contrastText` follow from `main` through `withMainShades`, with
|
|
59
|
+
`contrastText` = `#14181B`. A solid fill takes the status colour with the page colour as its text (dark
|
|
60
|
+
text on a light fill, the same rule as the primary button).
|
|
61
|
+
|
|
62
|
+
| Status | `text` = `main` | `bg` | `fill` | `fillText` | Origin |
|
|
63
|
+
|---|---|---|---|---|---|
|
|
64
|
+
| success | `#7BC496` | `#1C2A22` | `#7BC496` | `#14181B` | distilled: cockpit KB-23 (`ok`, `ok-bg`) |
|
|
65
|
+
| warning | `#D8BD7E` | `#2A2418` | `#D8BD7E` | `#14181B` | distilled: cockpit KB-23 (`warn`, `warn-bg`) |
|
|
66
|
+
| error | `#E58B80` | `#3A2320` | `#E58B80` | `#14181B` | distilled: cockpit KB-23 (`err`), OBS-10 (`danger-bg`) |
|
|
67
|
+
| info | `#8FB8E0` | `#1A2633` | `#8FB8E0` | `#14181B` | **invented**: no dark info hue anywhere in the estate |
|
|
68
|
+
| stale | `#AAB5BF` | `#20262C` | `#AAB5BF` | `#14181B` | **invented**, like the light `stale` it mirrors: `ink.secondary` on `subtle` |
|
|
69
|
+
|
|
70
|
+
### Data series and shadows
|
|
71
|
+
|
|
72
|
+
| Token | Light (today) | Dark | Origin |
|
|
73
|
+
|---|---|---|---|
|
|
74
|
+
| `dataSeries.categorical` | `#3D5A99 #3E80B8 #2E8F8A #7A5FA8 #9C4F86 #8A7355` | `#506BA3 #3E80B8 #2E8F8A #7A5FA8 #A1588C #8A7355` | derived: each light colour lightened in 0.05 steps until it clears 3:1 on paper; only slots 1 and 5 move |
|
|
75
|
+
| `shadow.rest` | `none` | `none` | structural decision 2 |
|
|
76
|
+
| `shadow.overlay` | `0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)` | `0 8px 28px rgba(0,0,0,.45)` | invented: on near-black the light shadow is invisible; the surface step carries the separation |
|
|
77
|
+
| scrim (behind a modal) | MUI default | `rgba(0,0,0,.55)` | invented |
|
|
78
|
+
|
|
79
|
+
### App identity in dark (rules, not values)
|
|
80
|
+
|
|
81
|
+
| What | Rule | Worked examples (computed) |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| primary, app sets none | lighten the light primary in 0.05 steps until it clears 4.5:1 on `#14181B` | `#468AB2` stays `#468AB2` (4.71:1); hram `#2F4F96` -> `#6D84B6` (4.78:1); survey_app `#432CA1` -> `#8576C2` (4.56:1) |
|
|
84
|
+
| primary button text | whichever of `#FFFFFF` and `#14181B` contrasts more with the derived primary | `#14181B` in all three examples |
|
|
85
|
+
| primary, app sets its own | the app's value | webshop-guenter `#D49040` with `#0E0B08` text (7.35:1) |
|
|
86
|
+
| status on an app page that is not `#14181B` | lighten the status colour until it clears 4.5:1 on its `bg` and on the page | webshop-guenter `#0E0B08`: no status colour needs to move |
|
|
87
|
+
|
|
88
|
+
## Contrast table (computed 2026-10-02 with the instrument's own functions)
|
|
89
|
+
|
|
90
|
+
| Pair | Foreground | Background | Ratio | Threshold |
|
|
91
|
+
|---|---|---|---|---|
|
|
92
|
+
| ink.primary on page / paper / overlay | `#E6EBEF` | `#14181B` / `#1B2126` / `#232A31` | 14.87 / 13.54 / 12.09 | 4.5 |
|
|
93
|
+
| ink.secondary on page / paper / overlay | `#AAB5BF` | same | 8.56 / 7.79 / 6.96 | 4.5 |
|
|
94
|
+
| ink.muted on page / paper | `#87919A` | `#14181B` / `#1B2126` | 5.57 / 5.07 | 3 |
|
|
95
|
+
| success text on bg; main on page / paper; fillText on fill | `#7BC496` | | 7.25; 8.65 / 7.88; 8.65 | 4.5 |
|
|
96
|
+
| warning, same pairs | `#D8BD7E` | | 8.43; 9.78 / 8.90; 9.78 | 4.5 |
|
|
97
|
+
| error, same pairs | `#E58B80` | | 5.78; 7.09 / 6.45; 7.09 | 4.5 |
|
|
98
|
+
| info, same pairs | `#8FB8E0` | | 7.38; 8.59 / 7.82; 8.59 | 4.5 |
|
|
99
|
+
| stale text on bg; fillText on fill | `#AAB5BF` | `#20262C` | 7.32; 8.56 | 4.5 |
|
|
100
|
+
| controlBorder.main on page / paper | `rgba(230,235,239,.38)` | | 3.18 / 3.14 | 3 |
|
|
101
|
+
| controlBorder.hover on page / paper | `rgba(230,235,239,.53)` | | 4.98 / 4.79 | 3 |
|
|
102
|
+
| controlBorder.error on paper | `#E58B80` | `#1B2126` | 6.45 | 3 |
|
|
103
|
+
| dataSeries 1-6 on paper | as above | `#1B2126` | 3.07 / 3.86 / 4.19 / 3.12 / 3.32 / 3.61 | 3 |
|
|
104
|
+
| surface steps: paper/page, overlay/paper, divider/paper, subtle/paper | | | 1.10 / 1.12 / 1.27 / 1.06 | none (structure, not text) |
|
|
105
|
+
|
|
106
|
+
Every pair passes. Two pairs sit close to their threshold: `controlBorder.main` (3.14:1 on paper) and
|
|
107
|
+
`dataSeries` slot 1 (3.07:1). A paper colour that moves lighter breaks them first.
|
|
108
|
+
|
|
109
|
+
## Rejected, and why
|
|
110
|
+
|
|
111
|
+
- **Neutral base B** (`#121212` / `#1C1C1C`, no cast, near MUI) and **C** (`#16181B` / `#1D2024`, the light
|
|
112
|
+
baseline's own cool ink carried into dark). Round 1: at this lightness none of the three could be told
|
|
113
|
+
apart, so the cast was decided on principle: A is the one the estate already uses in three places.
|
|
114
|
+
- **Surface step 2** (paper `#1F262C`, overlay `#29313A`) and **step 3** (paper `#232B32`, overlay
|
|
115
|
+
`#2F3942`). Round 2: step 1, the estate's own, reads as card and dialog already; the larger steps lower
|
|
116
|
+
text and border contrast on cards for no gain the operator could see.
|
|
117
|
+
|
|
118
|
+
## Identity versus baseline
|
|
119
|
+
|
|
120
|
+
The baseline owns everything above. An app owns, in dark as in light, only its primary (optional in dark,
|
|
121
|
+
derived otherwise) and its font. An app with a decided brand (webshop-guenter) may own its dark surfaces and
|
|
122
|
+
ink too; that is the app's identity bucket, not a baseline change.
|
|
123
|
+
|
|
124
|
+
## Unverified
|
|
125
|
+
|
|
126
|
+
Declared, not demonstrated, because a static instrument cannot show them: hover and pressed states, focus
|
|
127
|
+
rings in a running app, native date and select pickers, scrollbars, transitions, and how browsers paint
|
|
128
|
+
autofill in dark. `UCM-THEME-14`'s rendered check is where they are first seen.
|
|
129
|
+
|
|
130
|
+
## Known code that assumes light (input for `UCM-THEME-14`)
|
|
131
|
+
|
|
132
|
+
- `src/theme/createAppTheme.js:221` and `src/theme/themeCompleteness.js:298`: contrast surfaces include a
|
|
133
|
+
fixed `#FFFFFF`; the derivations only darken.
|
|
134
|
+
- `src/theme/tokens.js` `autofill`: fixed `#FFFFFF` inset and `#212529` text. **Shadowed** by the
|
|
135
|
+
palette-aware block in `src/theme/createAppTheme.js:97-108` (corrected 2026-10-02: the first version of
|
|
136
|
+
this sheet listed it as live). Harmless in both modes, but a duplicate that can mislead.
|
|
137
|
+
- `BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS`: the `background.paper` reason ("deliberately MUI white") is
|
|
138
|
+
false in dark.
|
|
139
|
+
- `src/components/QrSignupManager.jsx:361`: `#ffffff` behind a QR code. **Stays white on purpose**: a QR code
|
|
140
|
+
needs a light quiet zone to scan.
|
package/package.json
CHANGED
|
@@ -34,6 +34,17 @@ export const USER_LIST_HEADER_CELL_SX = { fontWeight: 600, whiteSpace: 'nowrap'
|
|
|
34
34
|
export const USER_LIST_EMPTY_CELL_SX = { py: 4, color: 'text.secondary' };
|
|
35
35
|
export const USER_LIST_BODY_CELL_SX = { verticalAlign: 'top', py: 1 };
|
|
36
36
|
|
|
37
|
+
// Extracted so the count === -1 ("more than") branch is directly unit-testable -- the live
|
|
38
|
+
// TablePagination call site always passes a real count from sortedUsers.length, so that branch
|
|
39
|
+
// can never actually be exercised through a render of this component itself (UCM-I18N-5 review).
|
|
40
|
+
export function formatDisplayedRowsLabel(t) {
|
|
41
|
+
return ({ from, to, count }) => (
|
|
42
|
+
count === -1
|
|
43
|
+
? t('UserList.DISPLAYED_ROWS_MORE_THAN', '{{from}}–{{to}} of more than {{to}}', { from, to })
|
|
44
|
+
: t('UserList.DISPLAYED_ROWS', '{{from}}–{{to}} of {{count}}', { from, to, count })
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
export function UserListComponent({
|
|
38
49
|
roles = DEFAULT_ROLES,
|
|
39
50
|
currentUser,
|
|
@@ -597,6 +608,7 @@ export function UserListComponent({
|
|
|
597
608
|
setPage(0);
|
|
598
609
|
}}
|
|
599
610
|
labelRowsPerPage={t('UserList.ROWS_PER_PAGE', 'Rows per page:')}
|
|
611
|
+
labelDisplayedRows={formatDisplayedRowsLabel(t)}
|
|
600
612
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
|
601
613
|
/>
|
|
602
614
|
</Box>
|
|
@@ -2053,6 +2053,18 @@ export const authTranslations = {
|
|
|
2053
2053
|
"en": "Rows per page:",
|
|
2054
2054
|
"sw": "Safu kwa ukurasa:"
|
|
2055
2055
|
},
|
|
2056
|
+
"UserList.DISPLAYED_ROWS": {
|
|
2057
|
+
"de": "{{from}}–{{to}} von {{count}}",
|
|
2058
|
+
"fr": "{{from}}–{{to}} sur {{count}}",
|
|
2059
|
+
"en": "{{from}}–{{to}} of {{count}}",
|
|
2060
|
+
"sw": "{{from}}–{{to}} ya {{count}}"
|
|
2061
|
+
},
|
|
2062
|
+
"UserList.DISPLAYED_ROWS_MORE_THAN": {
|
|
2063
|
+
"de": "{{from}}–{{to}} von mehr als {{to}}",
|
|
2064
|
+
"fr": "{{from}}–{{to}} sur plus de {{to}}",
|
|
2065
|
+
"en": "{{from}}–{{to}} of more than {{to}}",
|
|
2066
|
+
"sw": "{{from}}–{{to}} ya zaidi ya {{to}}"
|
|
2067
|
+
},
|
|
2056
2068
|
"Auth.INVITE_SENT_SUCCESS": {
|
|
2057
2069
|
"de": "Einladung gesendet.",
|
|
2058
2070
|
"fr": "Invitation envoyée.",
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import './fonts';
|
|
2
2
|
|
|
3
|
-
import { createTheme, darken, getContrastRatio } from '@mui/material/styles';
|
|
3
|
+
import { createTheme, darken, decomposeColor, getContrastRatio, lighten } from '@mui/material/styles';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS,
|
|
7
|
+
BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK,
|
|
7
8
|
BASELINE_PALETTE,
|
|
9
|
+
BASELINE_PALETTE_DARK,
|
|
8
10
|
BASELINE_STATIC,
|
|
11
|
+
DARK_OVERLAY_SHADOW,
|
|
12
|
+
DARK_OVERLAY_SURFACE,
|
|
13
|
+
DARK_SCRIM,
|
|
9
14
|
MOTION,
|
|
10
15
|
withMainShades,
|
|
11
16
|
} from './tokens';
|
|
@@ -42,6 +47,21 @@ function clearsContrast(colour, surfaces) {
|
|
|
42
47
|
return surfaces.every((surface) => getContrastRatio(colour, surface) >= 3);
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
function normalizeColour(colour) {
|
|
51
|
+
const match = colour.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/);
|
|
52
|
+
if (!match) return colour;
|
|
53
|
+
return `#${match.slice(1).map((channel) => Number(channel).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function lightenColour(colour, coefficient) {
|
|
57
|
+
const normalized = normalizeColour(colour);
|
|
58
|
+
const match = normalized.match(/^#([\da-f]{6})$/i);
|
|
59
|
+
if (!match) return normalizeColour(lighten(colour, coefficient));
|
|
60
|
+
const channels = [0, 2, 4].map((offset) => parseInt(match[1].slice(offset, offset + 2), 16));
|
|
61
|
+
return `#${channels.map((channel) => Math.round(channel + (255 - channel) * coefficient)
|
|
62
|
+
.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
// Darkened until it clears 3:1 against BOTH '#FFFFFF' and background.default
|
|
46
66
|
// -- the exact two surfaces assertThemeComplete's contrastFindings checks --
|
|
47
67
|
// so deriving against background.paper alone (which need not be white for an
|
|
@@ -55,6 +75,16 @@ function deriveFocusColour(primary, surfaces) {
|
|
|
55
75
|
return focus;
|
|
56
76
|
}
|
|
57
77
|
|
|
78
|
+
function deriveLightenedColour(original, surfaces) {
|
|
79
|
+
if (clearsContrast(original, surfaces)) return original;
|
|
80
|
+
let colour = original;
|
|
81
|
+
for (let coefficient = 0.05; !clearsContrast(colour, surfaces); coefficient += 0.05) {
|
|
82
|
+
colour = lightenColour(original, Math.min(coefficient, 0.9));
|
|
83
|
+
if (coefficient >= 0.9) break;
|
|
84
|
+
}
|
|
85
|
+
return colour;
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
function clearsPageContrast(colour, surfaces, threshold) {
|
|
59
89
|
return surfaces.every((surface) => {
|
|
60
90
|
const ratio = calculateContrastRatio(colour, surface);
|
|
@@ -73,6 +103,35 @@ function deriveDarkenedPageColour(original, surfaces, threshold) {
|
|
|
73
103
|
return colour;
|
|
74
104
|
}
|
|
75
105
|
|
|
106
|
+
function deriveLightenedPageColour(original, surfaces, threshold) {
|
|
107
|
+
if (clearsPageContrast(original, surfaces, threshold)) return original;
|
|
108
|
+
|
|
109
|
+
let colour = original;
|
|
110
|
+
for (let coefficient = 0.05; !clearsPageContrast(colour, surfaces, threshold); coefficient += 0.05) {
|
|
111
|
+
colour = lightenColour(original, Math.min(coefficient, 0.9));
|
|
112
|
+
if (coefficient >= 0.9) break;
|
|
113
|
+
}
|
|
114
|
+
return colour;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ink.primary may be supplied as hex or rgb(); decomposeColor normalises both.
|
|
118
|
+
function inkChannels(ink) {
|
|
119
|
+
return decomposeColor(ink).values.slice(0, 3).map((value) => Math.round(value));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 0.02 steps: the frozen sheet value (0.38) is the first even step that clears
|
|
123
|
+
// 3:1; a 0.01 search would land on 0.37 and drift from the frozen baseline.
|
|
124
|
+
function deriveDarkAlphaColour(ink, surfaces) {
|
|
125
|
+
const [red, green, blue] = inkChannels(ink);
|
|
126
|
+
let alpha = 0;
|
|
127
|
+
while (alpha <= 1) {
|
|
128
|
+
const colour = `rgba(${red},${green},${blue},${alpha.toFixed(2)})`;
|
|
129
|
+
if (clearsPageContrast(colour, surfaces, 3)) return colour;
|
|
130
|
+
alpha = Number((alpha + 0.02).toFixed(2));
|
|
131
|
+
}
|
|
132
|
+
return `rgba(${red},${green},${blue},1)`;
|
|
133
|
+
}
|
|
134
|
+
|
|
76
135
|
const RGBA_PATTERN = /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d*\.?\d+)\s*\)$/;
|
|
77
136
|
|
|
78
137
|
function deriveAlphaPageColour(original, surfaces, threshold) {
|
|
@@ -92,7 +151,7 @@ function deriveAlphaPageColour(original, surfaces, threshold) {
|
|
|
92
151
|
return colour;
|
|
93
152
|
}
|
|
94
153
|
|
|
95
|
-
function createPaletteAwareComponents(palette) {
|
|
154
|
+
function createPaletteAwareComponents(palette, isDark) {
|
|
96
155
|
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}`;
|
|
97
156
|
const autofill = {
|
|
98
157
|
'&:-webkit-autofill': {
|
|
@@ -117,7 +176,7 @@ function createPaletteAwareComponents(palette) {
|
|
|
117
176
|
]),
|
|
118
177
|
);
|
|
119
178
|
|
|
120
|
-
|
|
179
|
+
const components = {
|
|
121
180
|
MuiButton: {
|
|
122
181
|
styleOverrides: {
|
|
123
182
|
root: { transition: stateTransition },
|
|
@@ -199,6 +258,22 @@ function createPaletteAwareComponents(palette) {
|
|
|
199
258
|
styleOverrides: alertStyles,
|
|
200
259
|
},
|
|
201
260
|
};
|
|
261
|
+
|
|
262
|
+
if (isDark) {
|
|
263
|
+
for (const component of ['MuiDialog', 'MuiMenu', 'MuiPopover', 'MuiDrawer']) {
|
|
264
|
+
components[component] = {
|
|
265
|
+
styleOverrides: {
|
|
266
|
+
paper: {
|
|
267
|
+
backgroundColor: DARK_OVERLAY_SURFACE,
|
|
268
|
+
boxShadow: DARK_OVERLAY_SHADOW,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
components.MuiBackdrop = { styleOverrides: { root: { backgroundColor: DARK_SCRIM } } };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return components;
|
|
202
277
|
}
|
|
203
278
|
|
|
204
279
|
/**
|
|
@@ -212,19 +287,29 @@ export function createAppTheme(appConfig = {}) {
|
|
|
212
287
|
}
|
|
213
288
|
validateStyleOverrides(appConfig.components);
|
|
214
289
|
|
|
290
|
+
const isDark = appConfig.palette.mode === 'dark';
|
|
291
|
+
const baselinePalette = isDark ? BASELINE_PALETTE_DARK : BASELINE_PALETTE;
|
|
292
|
+
|
|
215
293
|
const paletteTheme = createTheme(
|
|
216
294
|
{ palette: appConfig.palette },
|
|
217
|
-
{ palette:
|
|
295
|
+
{ palette: baselinePalette },
|
|
218
296
|
{ palette: appConfig.palette },
|
|
219
297
|
);
|
|
220
298
|
const finalPalette = paletteTheme.palette;
|
|
221
|
-
const contrastSurfaces =
|
|
299
|
+
const contrastSurfaces = isDark
|
|
300
|
+
? [finalPalette.background.default, finalPalette.background.paper]
|
|
301
|
+
: ['#FFFFFF', finalPalette.background.default];
|
|
222
302
|
const derivedStatusEntries = {};
|
|
223
303
|
for (const status of ['success', 'warning', 'error', 'info']) {
|
|
224
304
|
if (appConfig.palette?.[status]?.main !== undefined) continue;
|
|
225
305
|
|
|
226
306
|
const current = finalPalette[status];
|
|
227
|
-
const
|
|
307
|
+
const statusSurfaces = isDark
|
|
308
|
+
? [finalPalette.background.default, current.bg]
|
|
309
|
+
: contrastSurfaces;
|
|
310
|
+
const main = isDark
|
|
311
|
+
? deriveLightenedPageColour(current.main, statusSurfaces, 4.5)
|
|
312
|
+
: deriveDarkenedPageColour(current.main, statusSurfaces, 4.5);
|
|
228
313
|
if (main !== current.main) {
|
|
229
314
|
derivedStatusEntries[status] = {
|
|
230
315
|
...current,
|
|
@@ -238,19 +323,57 @@ export function createAppTheme(appConfig = {}) {
|
|
|
238
323
|
if (appConfig.palette?.controlBorder?.[state] !== undefined) continue;
|
|
239
324
|
|
|
240
325
|
const current = finalPalette.controlBorder[state];
|
|
241
|
-
const derived =
|
|
242
|
-
?
|
|
243
|
-
|
|
326
|
+
const derived = isDark
|
|
327
|
+
? state === 'error'
|
|
328
|
+
? deriveLightenedPageColour(current, [finalPalette.background.default, finalPalette.error.bg], 3)
|
|
329
|
+
: deriveDarkAlphaColour(finalPalette.ink.primary, contrastSurfaces)
|
|
330
|
+
: state === 'error'
|
|
331
|
+
? deriveDarkenedPageColour(current, contrastSurfaces, 3)
|
|
332
|
+
: deriveAlphaPageColour(current, contrastSurfaces, 3);
|
|
244
333
|
if (derived !== current) derivedControlBorderEntries[state] = derived;
|
|
245
334
|
}
|
|
335
|
+
if (isDark && appConfig.palette?.controlBorder?.hover === undefined) {
|
|
336
|
+
const main = derivedControlBorderEntries.main || finalPalette.controlBorder.main;
|
|
337
|
+
const alpha = Number(main.match(/,([\d.]+)\)$/)?.[1] || 0);
|
|
338
|
+
const hoverAlpha = Math.min(1, Number((alpha + 0.15).toFixed(2)));
|
|
339
|
+
const [red, green, blue] = inkChannels(finalPalette.ink.primary);
|
|
340
|
+
derivedControlBorderEntries.hover = `rgba(${red},${green},${blue},${hoverAlpha.toFixed(2)})`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const primaryMain = isDark
|
|
344
|
+
? deriveLightenedPageColour(finalPalette.primary.main, [finalPalette.background.default], 4.5)
|
|
345
|
+
: finalPalette.primary.main;
|
|
346
|
+
const primaryContrastText = isDark
|
|
347
|
+
? (getContrastRatio('#FFFFFF', primaryMain) >= getContrastRatio(finalPalette.background.default, primaryMain)
|
|
348
|
+
? '#FFFFFF'
|
|
349
|
+
: finalPalette.background.default)
|
|
350
|
+
: finalPalette.primary.contrastText;
|
|
351
|
+
const derivedSeries = isDark && appConfig.palette?.dataSeries?.categorical === undefined
|
|
352
|
+
? finalPalette.dataSeries.categorical.map((colour) => (
|
|
353
|
+
deriveLightenedPageColour(colour, [finalPalette.background.paper], 3)
|
|
354
|
+
))
|
|
355
|
+
: finalPalette.dataSeries.categorical;
|
|
246
356
|
|
|
247
357
|
const computedPalette = {
|
|
248
358
|
...derivedStatusEntries,
|
|
249
359
|
controlBorder: {
|
|
250
360
|
...finalPalette.controlBorder,
|
|
251
361
|
...derivedControlBorderEntries,
|
|
252
|
-
focus:
|
|
362
|
+
focus: isDark
|
|
363
|
+
? (appConfig.palette?.controlBorder?.focus
|
|
364
|
+
?? deriveLightenedColour(primaryMain, contrastSurfaces))
|
|
365
|
+
: deriveFocusColour(finalPalette.primary.main, contrastSurfaces),
|
|
366
|
+
},
|
|
367
|
+
primary: {
|
|
368
|
+
...finalPalette.primary,
|
|
369
|
+
// A lightened main needs its own light/dark shades, or hover states keep
|
|
370
|
+
// the shades of the colour that was replaced.
|
|
371
|
+
...(isDark && primaryMain !== finalPalette.primary.main
|
|
372
|
+
? withMainShades(primaryMain, primaryContrastText)
|
|
373
|
+
: { main: primaryMain }),
|
|
374
|
+
contrastText: primaryContrastText,
|
|
253
375
|
},
|
|
376
|
+
dataSeries: { categorical: derivedSeries },
|
|
254
377
|
};
|
|
255
378
|
const resolvedPalette = { ...finalPalette, ...computedPalette };
|
|
256
379
|
const fontFamily = appConfig.typography?.fontFamily || BASELINE_STATIC.typography.fontFamily;
|
|
@@ -266,20 +389,24 @@ export function createAppTheme(appConfig = {}) {
|
|
|
266
389
|
...BASELINE_STATIC.fontLoading,
|
|
267
390
|
weights: [...BASELINE_STATIC.fontLoading.weights],
|
|
268
391
|
},
|
|
392
|
+
shadow: isDark ? { ...BASELINE_STATIC.shadow, overlay: DARK_OVERLAY_SHADOW } : BASELINE_STATIC.shadow,
|
|
269
393
|
palette: resolvedPalette,
|
|
270
394
|
themeCompleteness: {
|
|
271
395
|
baseline: true,
|
|
272
|
-
exemptions:
|
|
396
|
+
exemptions: (isDark
|
|
397
|
+
? BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK
|
|
398
|
+
: BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS).map((exemption) => ({
|
|
273
399
|
...exemption,
|
|
274
400
|
})),
|
|
275
401
|
},
|
|
276
402
|
},
|
|
277
403
|
{
|
|
278
|
-
components: createPaletteAwareComponents(resolvedPalette),
|
|
404
|
+
components: createPaletteAwareComponents(resolvedPalette, isDark),
|
|
279
405
|
},
|
|
280
406
|
appConfig,
|
|
281
407
|
{
|
|
282
408
|
palette: {
|
|
409
|
+
...(isDark ? computedPalette : {}),
|
|
283
410
|
dataSeries: {
|
|
284
411
|
categorical: [...resolvedPalette.dataSeries.categorical],
|
|
285
412
|
},
|
|
@@ -294,10 +294,16 @@ function contrastFindings(theme) {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
297
|
+
const dark = theme.palette?.mode === 'dark';
|
|
298
|
+
const backgrounds = dark
|
|
299
|
+
? {
|
|
300
|
+
paper: theme.palette?.background?.paper,
|
|
301
|
+
page: theme.palette?.background?.default,
|
|
302
|
+
}
|
|
303
|
+
: {
|
|
304
|
+
white: '#FFFFFF',
|
|
305
|
+
page: theme.palette?.background?.default,
|
|
306
|
+
};
|
|
301
307
|
for (const status of STATUS_KEYS) {
|
|
302
308
|
const main = theme.palette?.[status]?.main;
|
|
303
309
|
if (!main) continue;
|
package/src/theme/tokens.js
CHANGED
|
@@ -113,6 +113,73 @@ export const BASELINE_PALETTE = {
|
|
|
113
113
|
},
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
+
export const DARK_OVERLAY_SURFACE = '#232A31';
|
|
117
|
+
export const DARK_OVERLAY_SHADOW = '0 8px 28px rgba(0,0,0,.45)';
|
|
118
|
+
export const DARK_SCRIM = 'rgba(0,0,0,.55)';
|
|
119
|
+
|
|
120
|
+
export const BASELINE_PALETTE_DARK = {
|
|
121
|
+
mode: 'dark',
|
|
122
|
+
ink: {
|
|
123
|
+
primary: '#E6EBEF',
|
|
124
|
+
secondary: '#AAB5BF',
|
|
125
|
+
muted: '#87919A',
|
|
126
|
+
},
|
|
127
|
+
text: {
|
|
128
|
+
primary: '#E6EBEF',
|
|
129
|
+
secondary: '#AAB5BF',
|
|
130
|
+
disabled: '#87919A',
|
|
131
|
+
},
|
|
132
|
+
background: {
|
|
133
|
+
default: '#14181B',
|
|
134
|
+
paper: '#1B2126',
|
|
135
|
+
subtle: '#20262C',
|
|
136
|
+
},
|
|
137
|
+
divider: '#2B333A',
|
|
138
|
+
controlBorder: {
|
|
139
|
+
// These are replaced by the dark alpha derivation in createAppTheme.
|
|
140
|
+
main: 'rgba(230,235,239,0)',
|
|
141
|
+
hover: 'rgba(230,235,239,0)',
|
|
142
|
+
error: '#E58B80',
|
|
143
|
+
},
|
|
144
|
+
success: {
|
|
145
|
+
...withMainShades('#7BC496', '#14181B'),
|
|
146
|
+
text: '#7BC496',
|
|
147
|
+
fill: '#7BC496',
|
|
148
|
+
fillText: '#14181B',
|
|
149
|
+
bg: '#1C2A22',
|
|
150
|
+
},
|
|
151
|
+
warning: {
|
|
152
|
+
...withMainShades('#D8BD7E', '#14181B'),
|
|
153
|
+
text: '#D8BD7E',
|
|
154
|
+
fill: '#D8BD7E',
|
|
155
|
+
fillText: '#14181B',
|
|
156
|
+
bg: '#2A2418',
|
|
157
|
+
},
|
|
158
|
+
error: {
|
|
159
|
+
...withMainShades('#E58B80', '#14181B'),
|
|
160
|
+
text: '#E58B80',
|
|
161
|
+
fill: '#E58B80',
|
|
162
|
+
fillText: '#14181B',
|
|
163
|
+
bg: '#3A2320',
|
|
164
|
+
},
|
|
165
|
+
info: {
|
|
166
|
+
...withMainShades('#8FB8E0', '#14181B'),
|
|
167
|
+
text: '#8FB8E0',
|
|
168
|
+
fill: '#8FB8E0',
|
|
169
|
+
fillText: '#14181B',
|
|
170
|
+
bg: '#1A2633',
|
|
171
|
+
},
|
|
172
|
+
stale: {
|
|
173
|
+
text: '#AAB5BF',
|
|
174
|
+
fill: '#AAB5BF',
|
|
175
|
+
fillText: '#14181B',
|
|
176
|
+
bg: '#20262C',
|
|
177
|
+
},
|
|
178
|
+
dataSeries: {
|
|
179
|
+
categorical: SERIES_COLOURS,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
|
|
116
183
|
export const OVERLAY_SHADOW =
|
|
117
184
|
'0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)';
|
|
118
185
|
|
|
@@ -399,3 +466,8 @@ export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS = [
|
|
|
399
466
|
reason: `The canonical ${variant} weight deliberately equals MUI's numeric default.`,
|
|
400
467
|
})),
|
|
401
468
|
];
|
|
469
|
+
|
|
470
|
+
export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK =
|
|
471
|
+
BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.filter(
|
|
472
|
+
({ surface }) => surface !== 'palette.background.paper',
|
|
473
|
+
);
|
|
@@ -33,7 +33,7 @@ vi.mock('react-i18next', () => {
|
|
|
33
33
|
};
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
import { UserListComponent } from '../src/components/UserListComponent';
|
|
36
|
+
import { UserListComponent, formatDisplayedRowsLabel } from '../src/components/UserListComponent';
|
|
37
37
|
|
|
38
38
|
const currentUser = { id: 99, is_superuser: true, role: 'admin' };
|
|
39
39
|
const user = { id: 1, email: 'person@example.com', role: 'student' };
|
|
@@ -167,3 +167,26 @@ describe('UserListComponent delete actions', () => {
|
|
|
167
167
|
expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
|
+
|
|
171
|
+
describe('formatDisplayedRowsLabel (UCM-I18N-5)', () => {
|
|
172
|
+
// A real i18next instance's t() supports {{var}} interpolation; this fake mirrors just that,
|
|
173
|
+
// so these assertions pin the KEY and VARS formatDisplayedRowsLabel passes through -- the
|
|
174
|
+
// count === -1 ("more than") branch can never be reached by rendering UserListComponent itself
|
|
175
|
+
// (its own TablePagination always receives a real, non-negative count), so this is the only way
|
|
176
|
+
// to exercise it at all (UCM-I18N-5 review, tests lens).
|
|
177
|
+
function fakeT(key, fallback, vars = {}) {
|
|
178
|
+
const template = { [key]: fallback }[key] ?? fallback;
|
|
179
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(vars[name] ?? `{{${name}}}`));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
it('uses the exact-count key when count is a real number', () => {
|
|
183
|
+
const label = formatDisplayedRowsLabel(fakeT)({ from: 1, to: 10, count: 42 });
|
|
184
|
+
expect(label).toBe('1–10 of 42');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('uses the "more than" key, without a literal count, when count is -1', () => {
|
|
188
|
+
const label = formatDisplayedRowsLabel(fakeT)({ from: 1, to: 10, count: -1 });
|
|
189
|
+
expect(label).toBe('1–10 of more than 10');
|
|
190
|
+
expect(label).not.toContain('-1');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -50,3 +50,25 @@ describe('authTranslations — AUTH-2 raw backend error codes', () => {
|
|
|
50
50
|
expect(authTranslations['Auth.MFA_AUTHENTICATE_FAILED']).toBeDefined();
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
describe('authTranslations — UserList pagination labels', () => {
|
|
55
|
+
it.each(['UserList.DISPLAYED_ROWS', 'UserList.DISPLAYED_ROWS_MORE_THAN'])('%s has all supported locale entries', (key) => {
|
|
56
|
+
expect(authTranslations[key]).toBeDefined();
|
|
57
|
+
for (const locale of ['de', 'fr', 'en', 'sw']) {
|
|
58
|
+
expect(authTranslations[key][locale]).toEqual(expect.any(String));
|
|
59
|
+
expect(authTranslations[key][locale].trim()).not.toBe('');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// A nonempty string isn't enough -- a locale missing an interpolation placeholder the
|
|
64
|
+
// others carry would silently render the literal "{{count}}" to a real user (UCM-I18N-5 review).
|
|
65
|
+
it.each(['de', 'fr', 'en', 'sw'])('%s has {{from}} and {{to}} in both pagination keys, and {{count}} only in the exact-count one', (locale) => {
|
|
66
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS'][locale]).toContain('{{from}}');
|
|
67
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS'][locale]).toContain('{{to}}');
|
|
68
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS'][locale]).toContain('{{count}}');
|
|
69
|
+
|
|
70
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS_MORE_THAN'][locale]).toContain('{{from}}');
|
|
71
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS_MORE_THAN'][locale]).toContain('{{to}}');
|
|
72
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS_MORE_THAN'][locale]).not.toContain('{{count}}');
|
|
73
|
+
});
|
|
74
|
+
});
|