@micha.bigler/ui-core-micha 3.9.1 → 3.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/DESIGN.md +11 -0
- package/README.md +16 -0
- package/WORK_ORDERS.md +6 -2
- package/dev/main.jsx +3 -3
- package/dist/index.js +2 -0
- package/dist/testing/checkKitIntegration.js +201 -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/index.js +3 -0
- package/src/testing/checkKitIntegration.js +233 -0
- package/src/theme/createAppTheme.js +139 -12
- package/src/theme/themeCompleteness.js +10 -4
- package/src/theme/tokens.js +72 -0
- package/tests/checkKitIntegration.test.jsx +219 -0
- package/tests/createAppThemeDark.test.js +127 -0
- package/tests/createAppThemeLightGuard.test.js +40 -0
- package/tests/fixtures/light-theme-baseline.json +2228 -0
- package/work-orders/UCM-I18N-5.md +80 -0
- package/work-orders/UCM-TEST-1.md +304 -0
- package/work-orders/UCM-THEME-14.md +178 -8
- package/work-orders/assets/UCM-THEME-13-dark-prototype.html +390 -0
|
@@ -237,7 +237,7 @@ export function calculateContrastRatio(foreground, background) {
|
|
|
237
237
|
return (light + 0.05) / (dark + 0.05);
|
|
238
238
|
}
|
|
239
239
|
function contrastFindings(theme) {
|
|
240
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
240
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
|
241
241
|
const findings = [];
|
|
242
242
|
for (const status of STATUS_KEYS) {
|
|
243
243
|
const tokens = (_a = theme.palette) === null || _a === void 0 ? void 0 : _a[status];
|
|
@@ -262,12 +262,18 @@ function contrastFindings(theme) {
|
|
|
262
262
|
});
|
|
263
263
|
}
|
|
264
264
|
}
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
265
|
+
const dark = ((_b = theme.palette) === null || _b === void 0 ? void 0 : _b.mode) === 'dark';
|
|
266
|
+
const backgrounds = dark
|
|
267
|
+
? {
|
|
268
|
+
paper: (_d = (_c = theme.palette) === null || _c === void 0 ? void 0 : _c.background) === null || _d === void 0 ? void 0 : _d.paper,
|
|
269
|
+
page: (_f = (_e = theme.palette) === null || _e === void 0 ? void 0 : _e.background) === null || _f === void 0 ? void 0 : _f.default,
|
|
270
|
+
}
|
|
271
|
+
: {
|
|
272
|
+
white: '#FFFFFF',
|
|
273
|
+
page: (_h = (_g = theme.palette) === null || _g === void 0 ? void 0 : _g.background) === null || _h === void 0 ? void 0 : _h.default,
|
|
274
|
+
};
|
|
269
275
|
for (const status of STATUS_KEYS) {
|
|
270
|
-
const main = (
|
|
276
|
+
const main = (_k = (_j = theme.palette) === null || _j === void 0 ? void 0 : _j[status]) === null || _k === void 0 ? void 0 : _k.main;
|
|
271
277
|
if (!main)
|
|
272
278
|
continue;
|
|
273
279
|
for (const [surface, background] of Object.entries(backgrounds)) {
|
|
@@ -283,7 +289,7 @@ function contrastFindings(theme) {
|
|
|
283
289
|
}
|
|
284
290
|
}
|
|
285
291
|
for (const state of ['main', 'hover', 'focus', 'error']) {
|
|
286
|
-
const colour = (
|
|
292
|
+
const colour = (_m = (_l = theme.palette) === null || _l === void 0 ? void 0 : _l.controlBorder) === null || _m === void 0 ? void 0 : _m[state];
|
|
287
293
|
if (!colour)
|
|
288
294
|
continue;
|
|
289
295
|
for (const [surface, background] of Object.entries(backgrounds)) {
|
package/dist/theme/tokens.js
CHANGED
|
@@ -78,6 +78,47 @@ export const BASELINE_PALETTE = {
|
|
|
78
78
|
categorical: SERIES_COLOURS,
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
|
+
export const DARK_OVERLAY_SURFACE = '#232A31';
|
|
82
|
+
export const DARK_OVERLAY_SHADOW = '0 8px 28px rgba(0,0,0,.45)';
|
|
83
|
+
export const DARK_SCRIM = 'rgba(0,0,0,.55)';
|
|
84
|
+
export const BASELINE_PALETTE_DARK = {
|
|
85
|
+
mode: 'dark',
|
|
86
|
+
ink: {
|
|
87
|
+
primary: '#E6EBEF',
|
|
88
|
+
secondary: '#AAB5BF',
|
|
89
|
+
muted: '#87919A',
|
|
90
|
+
},
|
|
91
|
+
text: {
|
|
92
|
+
primary: '#E6EBEF',
|
|
93
|
+
secondary: '#AAB5BF',
|
|
94
|
+
disabled: '#87919A',
|
|
95
|
+
},
|
|
96
|
+
background: {
|
|
97
|
+
default: '#14181B',
|
|
98
|
+
paper: '#1B2126',
|
|
99
|
+
subtle: '#20262C',
|
|
100
|
+
},
|
|
101
|
+
divider: '#2B333A',
|
|
102
|
+
controlBorder: {
|
|
103
|
+
// These are replaced by the dark alpha derivation in createAppTheme.
|
|
104
|
+
main: 'rgba(230,235,239,0)',
|
|
105
|
+
hover: 'rgba(230,235,239,0)',
|
|
106
|
+
error: '#E58B80',
|
|
107
|
+
},
|
|
108
|
+
success: Object.assign(Object.assign({}, withMainShades('#7BC496', '#14181B')), { text: '#7BC496', fill: '#7BC496', fillText: '#14181B', bg: '#1C2A22' }),
|
|
109
|
+
warning: Object.assign(Object.assign({}, withMainShades('#D8BD7E', '#14181B')), { text: '#D8BD7E', fill: '#D8BD7E', fillText: '#14181B', bg: '#2A2418' }),
|
|
110
|
+
error: Object.assign(Object.assign({}, withMainShades('#E58B80', '#14181B')), { text: '#E58B80', fill: '#E58B80', fillText: '#14181B', bg: '#3A2320' }),
|
|
111
|
+
info: Object.assign(Object.assign({}, withMainShades('#8FB8E0', '#14181B')), { text: '#8FB8E0', fill: '#8FB8E0', fillText: '#14181B', bg: '#1A2633' }),
|
|
112
|
+
stale: {
|
|
113
|
+
text: '#AAB5BF',
|
|
114
|
+
fill: '#AAB5BF',
|
|
115
|
+
fillText: '#14181B',
|
|
116
|
+
bg: '#20262C',
|
|
117
|
+
},
|
|
118
|
+
dataSeries: {
|
|
119
|
+
categorical: SERIES_COLOURS,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
81
122
|
export const OVERLAY_SHADOW = '0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)';
|
|
82
123
|
export const MOTION = {
|
|
83
124
|
duration: {
|
|
@@ -348,3 +389,4 @@ export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS = [
|
|
|
348
389
|
reason: `The canonical ${variant} weight deliberately equals MUI's numeric default.`,
|
|
349
390
|
})),
|
|
350
391
|
];
|
|
392
|
+
export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS_DARK = BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.filter(({ surface }) => surface !== 'palette.background.paper');
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Dark-mode baseline tokens
|
|
2
|
+
|
|
3
|
+
Reference sheet for `UCM-THEME-13`. **This file is the value source** for the dark baseline: `UCM-THEME-14`
|
|
4
|
+
(dark mode in `createAppTheme`) takes every dark value from here by token name, and if code and this
|
|
5
|
+
sheet ever disagree, this sheet is the one to fix first.
|
|
6
|
+
|
|
7
|
+
Instrument: [`work-orders/assets/UCM-THEME-13-dark-prototype.html`](../work-orders/assets/UCM-THEME-13-dark-prototype.html)
|
|
8
|
+
(the screen the values were judged on, at 375 px and 1280 px, with contrasts computed in the page).
|
|
9
|
+
|
|
10
|
+
## Frozen
|
|
11
|
+
|
|
12
|
+
**Frozen 2026-10-02 by the operator** ("Passt so, bitte einfrieren"), after two value rounds.
|
|
13
|
+
|
|
14
|
+
## Structural decisions (operator, 2026-10-01)
|
|
15
|
+
|
|
16
|
+
1. **Complete baseline.** Every mode-dependent token class of the light baseline has a dark value, so an app
|
|
17
|
+
that turns dark on is complete and `assertThemeComplete` can judge dark as it judges light.
|
|
18
|
+
2. **Outlined first, as in light.** Resting surfaces carry no shadow. Structure comes from borders and a
|
|
19
|
+
small surface step. Only overlays (dialog, menu, popover, drawer) stand out, through a lighter surface;
|
|
20
|
+
their shadow is an edge, not the means of separation.
|
|
21
|
+
3. **Primary is derived unless the app sets it.** The factory lightens the app's light primary until it
|
|
22
|
+
clears 4.5:1 on the dark page. An app that supplies its own dark primary (webshop-guenter: `#D49040`)
|
|
23
|
+
keeps it.
|
|
24
|
+
4. **Fixed dark status set, derivation as a safety net.** The status colours below are decided values. On
|
|
25
|
+
an app page that is not the baseline page, the factory lightens a status colour that would fail there,
|
|
26
|
+
the dark counterpart of the darkening `UCM-THEME-15` does on light pages.
|
|
27
|
+
|
|
28
|
+
## Token table
|
|
29
|
+
|
|
30
|
+
Every value below is either **distilled** (found in the estate, source named), **derived** (computed by a
|
|
31
|
+
stated rule from a decided value) or **invented** (no precedent anywhere; labelled, unvalidated).
|
|
32
|
+
|
|
33
|
+
### Surfaces and ink
|
|
34
|
+
|
|
35
|
+
| Token | Light (today) | Dark | Origin |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| `background.default` (page) | `#FAFAFA` | `#14181B` | distilled: cockpit OBS-10, cockpit KB-23 (`#14181b`), ucm SHELL-2 (`#14171c`) |
|
|
38
|
+
| `background.paper` (card) | `#FFFFFF` | `#1B2126` | distilled: cockpit OBS-10, KB-23 |
|
|
39
|
+
| `background.subtle` | `#F4F5F6` | `#20262C` | distilled: cockpit OBS-10, KB-23 (`surface2`) |
|
|
40
|
+
| overlay surface (dialog, menu, popover, drawer) | = paper, plus shadow | `#232A31` | derived: one step above paper on the same hue, ratio 1.12:1 to paper; new in dark |
|
|
41
|
+
| `divider` | `rgba(33,37,41,.10)` | `#2B333A` | distilled: cockpit OBS-10, KB-23 (`border`) |
|
|
42
|
+
| `ink.primary` / `text.primary` | `#212529` | `#E6EBEF` | distilled: cockpit KB-23 (`ink`); OBS-10 `#e7ebee` |
|
|
43
|
+
| `ink.secondary` / `text.secondary` | `#5B6670` | `#AAB5BF` | distilled: cockpit KB-23 (`ink2`) |
|
|
44
|
+
| `ink.muted` / `text.disabled` | `#6A7178` | `#87919A` | distilled: cockpit OBS-10 (`text3`) |
|
|
45
|
+
|
|
46
|
+
### Controls
|
|
47
|
+
|
|
48
|
+
| Token | Light (today) | Dark | Origin |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `controlBorder.main` | `rgba(33,37,41,.50)` | `rgba(230,235,239,.38)` | derived: `ink.primary` at the smallest alpha that clears 3:1 on page and paper |
|
|
51
|
+
| `controlBorder.hover` | `rgba(33,37,41,.65)` | `rgba(230,235,239,.53)` | derived: the light step (+0.15) carried over |
|
|
52
|
+
| `controlBorder.error` | `#BF3227` | `#E58B80` | = `error.main` (dark) |
|
|
53
|
+
| `controlBorder.focus` | derived from primary | derived from primary | rule: lighten the primary until it clears 3:1 on page and paper |
|
|
54
|
+
| autofill field | paper inset, `ink.primary` text and caret | paper inset, `ink.primary` text and caret | derived: the palette-aware block in `createAppTheme.js:97-108` already resolves from the palette and wins over the static light block in `tokens.js`; dark follows once the dark palette is in place |
|
|
55
|
+
|
|
56
|
+
### Status
|
|
57
|
+
|
|
58
|
+
`main` = `text` in dark. `light`/`dark`/`contrastText` follow from `main` through `withMainShades`, with
|
|
59
|
+
`contrastText` = `#14181B`. A solid fill takes the status colour with the page colour as its text (dark
|
|
60
|
+
text on a light fill, the same rule as the primary button).
|
|
61
|
+
|
|
62
|
+
| Status | `text` = `main` | `bg` | `fill` | `fillText` | Origin |
|
|
63
|
+
|---|---|---|---|---|---|
|
|
64
|
+
| success | `#7BC496` | `#1C2A22` | `#7BC496` | `#14181B` | distilled: cockpit KB-23 (`ok`, `ok-bg`) |
|
|
65
|
+
| warning | `#D8BD7E` | `#2A2418` | `#D8BD7E` | `#14181B` | distilled: cockpit KB-23 (`warn`, `warn-bg`) |
|
|
66
|
+
| error | `#E58B80` | `#3A2320` | `#E58B80` | `#14181B` | distilled: cockpit KB-23 (`err`), OBS-10 (`danger-bg`) |
|
|
67
|
+
| info | `#8FB8E0` | `#1A2633` | `#8FB8E0` | `#14181B` | **invented**: no dark info hue anywhere in the estate |
|
|
68
|
+
| stale | `#AAB5BF` | `#20262C` | `#AAB5BF` | `#14181B` | **invented**, like the light `stale` it mirrors: `ink.secondary` on `subtle` |
|
|
69
|
+
|
|
70
|
+
### Data series and shadows
|
|
71
|
+
|
|
72
|
+
| Token | Light (today) | Dark | Origin |
|
|
73
|
+
|---|---|---|---|
|
|
74
|
+
| `dataSeries.categorical` | `#3D5A99 #3E80B8 #2E8F8A #7A5FA8 #9C4F86 #8A7355` | `#506BA3 #3E80B8 #2E8F8A #7A5FA8 #A1588C #8A7355` | derived: each light colour lightened in 0.05 steps until it clears 3:1 on paper; only slots 1 and 5 move |
|
|
75
|
+
| `shadow.rest` | `none` | `none` | structural decision 2 |
|
|
76
|
+
| `shadow.overlay` | `0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)` | `0 8px 28px rgba(0,0,0,.45)` | invented: on near-black the light shadow is invisible; the surface step carries the separation |
|
|
77
|
+
| scrim (behind a modal) | MUI default | `rgba(0,0,0,.55)` | invented |
|
|
78
|
+
|
|
79
|
+
### App identity in dark (rules, not values)
|
|
80
|
+
|
|
81
|
+
| What | Rule | Worked examples (computed) |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| primary, app sets none | lighten the light primary in 0.05 steps until it clears 4.5:1 on `#14181B` | `#468AB2` stays `#468AB2` (4.71:1); hram `#2F4F96` -> `#6D84B6` (4.78:1); survey_app `#432CA1` -> `#8576C2` (4.56:1) |
|
|
84
|
+
| primary button text | whichever of `#FFFFFF` and `#14181B` contrasts more with the derived primary | `#14181B` in all three examples |
|
|
85
|
+
| primary, app sets its own | the app's value | webshop-guenter `#D49040` with `#0E0B08` text (7.35:1) |
|
|
86
|
+
| status on an app page that is not `#14181B` | lighten the status colour until it clears 4.5:1 on its `bg` and on the page | webshop-guenter `#0E0B08`: no status colour needs to move |
|
|
87
|
+
|
|
88
|
+
## Contrast table (computed 2026-10-02 with the instrument's own functions)
|
|
89
|
+
|
|
90
|
+
| Pair | Foreground | Background | Ratio | Threshold |
|
|
91
|
+
|---|---|---|---|---|
|
|
92
|
+
| ink.primary on page / paper / overlay | `#E6EBEF` | `#14181B` / `#1B2126` / `#232A31` | 14.87 / 13.54 / 12.09 | 4.5 |
|
|
93
|
+
| ink.secondary on page / paper / overlay | `#AAB5BF` | same | 8.56 / 7.79 / 6.96 | 4.5 |
|
|
94
|
+
| ink.muted on page / paper | `#87919A` | `#14181B` / `#1B2126` | 5.57 / 5.07 | 3 |
|
|
95
|
+
| success text on bg; main on page / paper; fillText on fill | `#7BC496` | | 7.25; 8.65 / 7.88; 8.65 | 4.5 |
|
|
96
|
+
| warning, same pairs | `#D8BD7E` | | 8.43; 9.78 / 8.90; 9.78 | 4.5 |
|
|
97
|
+
| error, same pairs | `#E58B80` | | 5.78; 7.09 / 6.45; 7.09 | 4.5 |
|
|
98
|
+
| info, same pairs | `#8FB8E0` | | 7.38; 8.59 / 7.82; 8.59 | 4.5 |
|
|
99
|
+
| stale text on bg; fillText on fill | `#AAB5BF` | `#20262C` | 7.32; 8.56 | 4.5 |
|
|
100
|
+
| controlBorder.main on page / paper | `rgba(230,235,239,.38)` | | 3.18 / 3.14 | 3 |
|
|
101
|
+
| controlBorder.hover on page / paper | `rgba(230,235,239,.53)` | | 4.98 / 4.79 | 3 |
|
|
102
|
+
| controlBorder.error on paper | `#E58B80` | `#1B2126` | 6.45 | 3 |
|
|
103
|
+
| dataSeries 1-6 on paper | as above | `#1B2126` | 3.07 / 3.86 / 4.19 / 3.12 / 3.32 / 3.61 | 3 |
|
|
104
|
+
| surface steps: paper/page, overlay/paper, divider/paper, subtle/paper | | | 1.10 / 1.12 / 1.27 / 1.06 | none (structure, not text) |
|
|
105
|
+
|
|
106
|
+
Every pair passes. Two pairs sit close to their threshold: `controlBorder.main` (3.14:1 on paper) and
|
|
107
|
+
`dataSeries` slot 1 (3.07:1). A paper colour that moves lighter breaks them first.
|
|
108
|
+
|
|
109
|
+
## Rejected, and why
|
|
110
|
+
|
|
111
|
+
- **Neutral base B** (`#121212` / `#1C1C1C`, no cast, near MUI) and **C** (`#16181B` / `#1D2024`, the light
|
|
112
|
+
baseline's own cool ink carried into dark). Round 1: at this lightness none of the three could be told
|
|
113
|
+
apart, so the cast was decided on principle: A is the one the estate already uses in three places.
|
|
114
|
+
- **Surface step 2** (paper `#1F262C`, overlay `#29313A`) and **step 3** (paper `#232B32`, overlay
|
|
115
|
+
`#2F3942`). Round 2: step 1, the estate's own, reads as card and dialog already; the larger steps lower
|
|
116
|
+
text and border contrast on cards for no gain the operator could see.
|
|
117
|
+
|
|
118
|
+
## Identity versus baseline
|
|
119
|
+
|
|
120
|
+
The baseline owns everything above. An app owns, in dark as in light, only its primary (optional in dark,
|
|
121
|
+
derived otherwise) and its font. An app with a decided brand (webshop-guenter) may own its dark surfaces and
|
|
122
|
+
ink too; that is the app's identity bucket, not a baseline change.
|
|
123
|
+
|
|
124
|
+
## Unverified
|
|
125
|
+
|
|
126
|
+
Declared, not demonstrated, because a static instrument cannot show them: hover and pressed states, focus
|
|
127
|
+
rings in a running app, native date and select pickers, scrollbars, transitions, and how browsers paint
|
|
128
|
+
autofill in dark. `UCM-THEME-14`'s rendered check is where they are first seen.
|
|
129
|
+
|
|
130
|
+
## Known code that assumes light (input for `UCM-THEME-14`)
|
|
131
|
+
|
|
132
|
+
- `src/theme/createAppTheme.js:221` and `src/theme/themeCompleteness.js:298`: contrast surfaces include a
|
|
133
|
+
fixed `#FFFFFF`; the derivations only darken.
|
|
134
|
+
- `src/theme/tokens.js` `autofill`: fixed `#FFFFFF` inset and `#212529` text. **Shadowed** by the
|
|
135
|
+
palette-aware block in `src/theme/createAppTheme.js:97-108` (corrected 2026-10-02: the first version of
|
|
136
|
+
this sheet listed it as live). Harmless in both modes, but a duplicate that can mislead.
|
|
137
|
+
- `BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS`: the `background.paper` reason ("deliberately MUI white") is
|
|
138
|
+
false in dark.
|
|
139
|
+
- `src/components/QrSignupManager.jsx:361`: `#ffffff` behind a QR code. **Stays white on purpose**: a QR code
|
|
140
|
+
needs a light quiet zone to scan.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -20,6 +20,9 @@ export {
|
|
|
20
20
|
THEME_COMPLETENESS_SURFACES,
|
|
21
21
|
} from './theme';
|
|
22
22
|
|
|
23
|
+
// --- 0.1 Consumer integration ---
|
|
24
|
+
export { checkKitIntegration } from './testing/checkKitIntegration';
|
|
25
|
+
|
|
23
26
|
// --- 1. Auth Context (Essentiell für den Wrapper) ---
|
|
24
27
|
export { AuthContext, AuthProvider } from './auth/AuthContext';
|
|
25
28
|
export { UserMenu } from './auth/UserMenu';
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import React, { act } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { MemoryRouter } from 'react-router-dom';
|
|
4
|
+
import i18next from 'i18next';
|
|
5
|
+
|
|
6
|
+
import apiClient from '../auth/apiClient';
|
|
7
|
+
import { LoginPage } from '../pages/LoginPage';
|
|
8
|
+
import { SignUpPage } from '../pages/SignUpPage';
|
|
9
|
+
import { PasswordInvitePage } from '../pages/PasswordInvitePage';
|
|
10
|
+
import { uiCoreTranslations } from '../i18n/uiCoreTranslations';
|
|
11
|
+
|
|
12
|
+
const KIT_LANGUAGES = ['de', 'en', 'fr', 'sw'];
|
|
13
|
+
const LOGIN_PATH = '/api/auth/browser/v1/auth/login';
|
|
14
|
+
const RAW_CODE = /^[a-z][a-z0-9_]*$/;
|
|
15
|
+
|
|
16
|
+
function pageName(Page) {
|
|
17
|
+
return Page?.displayName || Page?.name || 'page';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function appLanguages(i18n) {
|
|
21
|
+
const candidates = [
|
|
22
|
+
i18n?.options?.supportedLngs,
|
|
23
|
+
Object.keys(i18n?.options?.resources || {}),
|
|
24
|
+
i18n?.languages,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
const languages = Array.isArray(candidate)
|
|
29
|
+
? candidate.filter((language) => language && language !== 'cimode')
|
|
30
|
+
: [];
|
|
31
|
+
if (languages.length > 0) return languages;
|
|
32
|
+
}
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function textNodes(container) {
|
|
37
|
+
const result = [];
|
|
38
|
+
const document = container.ownerDocument;
|
|
39
|
+
const filter = document.defaultView?.NodeFilter || NodeFilter;
|
|
40
|
+
const walker = document.createTreeWalker(container, filter.SHOW_TEXT);
|
|
41
|
+
let node;
|
|
42
|
+
while ((node = walker.nextNode())) {
|
|
43
|
+
const text = node.textContent.trim();
|
|
44
|
+
if (text) result.push(text);
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const KIT_KEYS = new Set(Object.keys(uiCoreTranslations));
|
|
50
|
+
// Every legitimate string the kit itself ships, across all locales -- a short,
|
|
51
|
+
// all-lowercase, no-space word can still be entirely legitimate rendered prose
|
|
52
|
+
// (e.g. `Auth.LOGIN_OR`'s German value is literally "oder", which the bare
|
|
53
|
+
// RAW_CODE shape below would otherwise flag as a raw backend code). Exempting
|
|
54
|
+
// anything that IS one of the kit's own translated values keeps the check
|
|
55
|
+
// looking for text that is NEITHER a key NOR a real translation -- which is
|
|
56
|
+
// exactly what a leaked backend code is, and a legitimate short word never is.
|
|
57
|
+
const KIT_VALUES = new Set(
|
|
58
|
+
Object.values(uiCoreTranslations).flatMap((entry) => Object.values(entry)),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
function scanPage(container, name) {
|
|
62
|
+
return textNodes(container)
|
|
63
|
+
.filter((text) => (
|
|
64
|
+
KIT_KEYS.has(text)
|
|
65
|
+
|| (!KIT_VALUES.has(text) && text.length > 2 && RAW_CODE.test(text))
|
|
66
|
+
))
|
|
67
|
+
.map((text) => ({
|
|
68
|
+
check: 'render',
|
|
69
|
+
reason: `${name} rendered raw kit key or backend code: ${text}`,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function renderPage(Page, wrapper, initialEntries = ['/']) {
|
|
74
|
+
const container = document.createElement('div');
|
|
75
|
+
const root = createRoot(container);
|
|
76
|
+
try {
|
|
77
|
+
await act(async () => {
|
|
78
|
+
root.render(React.createElement(
|
|
79
|
+
MemoryRouter,
|
|
80
|
+
{ initialEntries },
|
|
81
|
+
React.createElement(wrapper, null, React.createElement(Page)),
|
|
82
|
+
));
|
|
83
|
+
await Promise.resolve();
|
|
84
|
+
});
|
|
85
|
+
return { container, findings: scanPage(container, pageName(Page)) };
|
|
86
|
+
} finally {
|
|
87
|
+
await act(async () => {
|
|
88
|
+
root.unmount();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function transportFailure(config) {
|
|
94
|
+
return Promise.reject(new Error(`checkKitIntegration blocked request: ${config?.url || 'unknown'}`));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loginFailure() {
|
|
98
|
+
return Promise.reject({
|
|
99
|
+
response: {
|
|
100
|
+
status: 400,
|
|
101
|
+
data: {
|
|
102
|
+
errors: [{
|
|
103
|
+
code: 'invalid',
|
|
104
|
+
param: 'email',
|
|
105
|
+
message: 'The email address is invalid.',
|
|
106
|
+
}],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function renderFindings(Page, wrapper, initialEntries) {
|
|
113
|
+
try {
|
|
114
|
+
const rendered = await renderPage(Page, wrapper, initialEntries);
|
|
115
|
+
return rendered.findings;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return [{
|
|
118
|
+
check: 'render',
|
|
119
|
+
reason: `${pageName(Page)} threw while rendering: ${error?.message || String(error)}`,
|
|
120
|
+
}];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function errorTextFindings(i18n, wrapper) {
|
|
125
|
+
const Page = LoginPage;
|
|
126
|
+
const name = pageName(Page);
|
|
127
|
+
const container = document.createElement('div');
|
|
128
|
+
const root = createRoot(container);
|
|
129
|
+
try {
|
|
130
|
+
await act(async () => {
|
|
131
|
+
root.render(React.createElement(
|
|
132
|
+
MemoryRouter,
|
|
133
|
+
{ initialEntries: ['/login'] },
|
|
134
|
+
React.createElement(wrapper, null, React.createElement(Page)),
|
|
135
|
+
));
|
|
136
|
+
await Promise.resolve();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const inputs = Array.from(container.querySelectorAll('input'));
|
|
140
|
+
const email = inputs.find((input) => input.type === 'email');
|
|
141
|
+
const password = inputs.find((input) => input.type === 'password');
|
|
142
|
+
const form = container.querySelector('form');
|
|
143
|
+
if (!email || !password || !form) {
|
|
144
|
+
return [{ check: 'error-text', reason: `${name} did not render its login form.` }];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const setValue = (input, value) => {
|
|
148
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
149
|
+
input.ownerDocument.defaultView.HTMLInputElement.prototype,
|
|
150
|
+
'value',
|
|
151
|
+
).set;
|
|
152
|
+
setter.call(input, value);
|
|
153
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
154
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
setValue(email, 'not-an-email@example.com');
|
|
159
|
+
setValue(password, 'password');
|
|
160
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
await Promise.resolve();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const expected = i18n.t('Auth.field.email.invalid');
|
|
166
|
+
const renderedTexts = textNodes(container);
|
|
167
|
+
if (renderedTexts.includes(expected)) return [];
|
|
168
|
+
return [{
|
|
169
|
+
check: 'error-text',
|
|
170
|
+
reason: `${name} did not show ${expected} for the allauth invalid-email response.`,
|
|
171
|
+
}];
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return [{
|
|
174
|
+
check: 'error-text',
|
|
175
|
+
reason: `${name} error flow failed: ${error?.message || String(error)}`,
|
|
176
|
+
}];
|
|
177
|
+
} finally {
|
|
178
|
+
await act(async () => {
|
|
179
|
+
root.unmount();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Check that an app's i18n instance and providers wire the kit pages correctly. */
|
|
185
|
+
export async function checkKitIntegration({
|
|
186
|
+
i18n,
|
|
187
|
+
wrapper,
|
|
188
|
+
pages = [LoginPage, SignUpPage, PasswordInvitePage],
|
|
189
|
+
}) {
|
|
190
|
+
const findings = [];
|
|
191
|
+
const languages = appLanguages(i18n);
|
|
192
|
+
const sharedLanguages = KIT_LANGUAGES.filter((language) => languages.includes(language));
|
|
193
|
+
|
|
194
|
+
for (const [key] of Object.entries(uiCoreTranslations)) {
|
|
195
|
+
for (const language of sharedLanguages) {
|
|
196
|
+
// fallbackLng: false forces a true per-language check -- without it, a
|
|
197
|
+
// consumer's own fallbackLng config (e.g. "en") would let a key missing
|
|
198
|
+
// in "fr" silently resolve through the fallback and read as present.
|
|
199
|
+
if (!i18n?.exists(key, { lng: language, fallbackLng: false })) {
|
|
200
|
+
findings.push({
|
|
201
|
+
check: 'catalogue',
|
|
202
|
+
reason: `Missing kit translation key ${key} for language ${language}.`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!i18next.isInitialized || i18n !== i18next) {
|
|
209
|
+
findings.push({
|
|
210
|
+
check: 'instance',
|
|
211
|
+
reason: 'The app i18n is not the initialized kit default instance; Accept-Language will be missing or wrong.',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const originalAdapter = apiClient.defaults.adapter;
|
|
216
|
+
apiClient.defaults.adapter = async (config) => {
|
|
217
|
+
if (config?.url === LOGIN_PATH || String(config?.url || '').endsWith(LOGIN_PATH)) {
|
|
218
|
+
return loginFailure();
|
|
219
|
+
}
|
|
220
|
+
return transportFailure(config);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
for (const Page of pages) {
|
|
225
|
+
findings.push(...await renderFindings(Page, wrapper));
|
|
226
|
+
}
|
|
227
|
+
findings.push(...await errorTextFindings(i18n, wrapper));
|
|
228
|
+
} finally {
|
|
229
|
+
apiClient.defaults.adapter = originalAdapter;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { findings };
|
|
233
|
+
}
|