@zitadel/config 1.0.0-alpha.21 → 1.0.0-alpha.23

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.
Files changed (49) hide show
  1. package/README.md +2 -0
  2. package/defaults/README-branding.md +1 -1
  3. package/defaults/README-flows.md +9 -4
  4. package/defaults/default-login.json +0 -27
  5. package/dist/branding-contrast.d.mts +107 -0
  6. package/dist/branding-contrast.d.mts.map +1 -0
  7. package/dist/branding-contrast.mjs +229 -0
  8. package/dist/branding-contrast.mjs.map +1 -0
  9. package/dist/css-color.d.mts +36 -0
  10. package/dist/css-color.d.mts.map +1 -0
  11. package/dist/css-color.mjs +231 -0
  12. package/dist/css-color.mjs.map +1 -0
  13. package/dist/{defaults-Bh4_xVbh.d.mts → defaults-BxGCGdqS.d.mts} +5 -3
  14. package/dist/defaults-BxGCGdqS.d.mts.map +1 -0
  15. package/dist/{defaults-DPq0o3iU.mjs → defaults-DV6hY2KT.mjs} +25 -53
  16. package/dist/defaults-DV6hY2KT.mjs.map +1 -0
  17. package/dist/defaults.d.mts +1 -1
  18. package/dist/defaults.mjs +1 -1
  19. package/dist/index.d.mts +3 -3
  20. package/dist/index.mjs +4 -4
  21. package/dist/meta-schemas-OowgSUfU.mjs +1173 -0
  22. package/dist/meta-schemas-OowgSUfU.mjs.map +1 -0
  23. package/dist/meta-schemas.d.mts +4 -3
  24. package/dist/meta-schemas.d.mts.map +1 -1
  25. package/dist/meta-schemas.mjs +1 -1
  26. package/dist/schemas-7oNU25Qz.mjs +173 -0
  27. package/dist/schemas-7oNU25Qz.mjs.map +1 -0
  28. package/dist/schemas.d.mts +12 -5
  29. package/dist/schemas.d.mts.map +1 -1
  30. package/dist/schemas.mjs +3 -69
  31. package/dist/validate.d.mts +8 -2
  32. package/dist/validate.d.mts.map +1 -1
  33. package/dist/validate.mjs +9 -2
  34. package/dist/validate.mjs.map +1 -1
  35. package/meta-schemas/auth-method.json +1 -0
  36. package/meta-schemas/auth-methods.json +5 -3
  37. package/meta-schemas/branding.json +268 -19
  38. package/meta-schemas/flow-definition.json +375 -171
  39. package/meta-schemas/idp-connection.json +397 -0
  40. package/meta-schemas/property-name.json +1 -0
  41. package/meta-schemas/sso-auth-method.json +54 -0
  42. package/meta-schemas/user-property.json +10 -13
  43. package/meta-schemas/user-schema.json +20 -13
  44. package/package.json +18 -5
  45. package/dist/defaults-Bh4_xVbh.d.mts.map +0 -1
  46. package/dist/defaults-DPq0o3iU.mjs.map +0 -1
  47. package/dist/meta-schemas-z4IlqbLd.mjs +0 -531
  48. package/dist/meta-schemas-z4IlqbLd.mjs.map +0 -1
  49. package/dist/schemas.mjs.map +0 -1
package/README.md CHANGED
@@ -9,6 +9,8 @@ Versioned Zitadel local config schemas and defaults — the contract behind the
9
9
  | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
10
10
  | `@zitadel/config/schemas` | Zod schemas for the local config files (`schemaConfigSchema`, `flowConfigSchema`, `brandingConfigSchema`) |
11
11
  | `@zitadel/config/branding-url` | Canonical loopback HTTP URL predicate shared by config and browser render gates |
12
+ | `@zitadel/config/branding-contrast` | Contrast pairs, thresholds and measurement for a branding palette |
13
+ | `@zitadel/config/css-color` | Resolves the colour forms a branding palette accepts to sRGB |
12
14
  | `@zitadel/config/validate` | Validation entry points the CLI runs on `plan`/`apply` |
13
15
  | `@zitadel/config/normalize` | Normalization used to compare local files against server state |
14
16
  | `@zitadel/config/defaults` | The versioned default user schema, login flow, and branding designs |
@@ -38,7 +38,7 @@ by re-applying an earlier template.
38
38
  {
39
39
  "$schema": "../meta/branding.json",
40
40
  "layout": "split",
41
- "liquid_template_file": "./login.liquid",
41
+ "liquid_template": { "$file": "./login.liquid" },
42
42
  "logo_url": "https://example.com/logo.svg",
43
43
  "hero_url": "https://example.com/hero.jpg"
44
44
  }
@@ -30,6 +30,11 @@ The common workflow:
30
30
  2. Run `zitadel plan` to preview the change.
31
31
  3. Run `zitadel apply` to publish it.
32
32
 
33
+ Every apply of an edited flow publishes a new immutable revision. Earlier
34
+ revisions stay readable by id. A login that pins the flow's name serves its
35
+ newest revision. A login without a pin serves the project's newest active
36
+ unscoped flow, whatever its name, so revising a flow can make it the default.
37
+
33
38
  Typical edits:
34
39
 
35
40
  - **Change which fields a step collects** — edit `steps[].fields[]`.
@@ -97,7 +102,7 @@ anything here afterwards.
97
102
 
98
103
  Editing a schema publishes a new immutable revision. When you `apply` a
99
104
  schema edit, the CLI rewrites `user_schema` in the flow files pinned to
100
- the old revision and updates the flows in the same run — the plan
101
- announces the re-pin beforehand, and the rewrite shows up in your git
102
- diff. Remember to update `steps[].fields[]` yourself when the edit
103
- added or removed properties the flow should collect.
105
+ the old revision and publishes new revisions of those flows in the same
106
+ run — the plan announces the re-pin beforehand, and the rewrite shows up
107
+ in your git diff. Remember to update `steps[].fields[]` yourself when the
108
+ edit added or removed properties the flow should collect.
@@ -20,12 +20,6 @@
20
20
  "primary": true,
21
21
  "text_key": "identifier.action.continue"
22
22
  },
23
- {
24
- "name": "passkey",
25
- "kind": "passkey",
26
- "primary": false,
27
- "text_key": "identifier.action.passkey"
28
- },
29
23
  {
30
24
  "name": "register",
31
25
  "kind": "navigate",
@@ -37,9 +31,6 @@
37
31
  "submit": {
38
32
  "target": "password"
39
33
  },
40
- "passkey": {
41
- "target": "done"
42
- },
43
34
  "user_not_found": {
44
35
  "target": "register"
45
36
  },
@@ -60,20 +51,11 @@
60
51
  "kind": "submit",
61
52
  "primary": true,
62
53
  "text_key": "password.action.signin"
63
- },
64
- {
65
- "name": "passkey",
66
- "kind": "passkey",
67
- "primary": false,
68
- "text_key": "password.action.passkey"
69
54
  }
70
55
  ],
71
56
  "transitions": {
72
57
  "submit": {
73
58
  "target": "done"
74
- },
75
- "passkey": {
76
- "target": "done"
77
59
  }
78
60
  }
79
61
  },
@@ -89,12 +71,6 @@
89
71
  "primary": true,
90
72
  "text_key": "register.action.password"
91
73
  },
92
- {
93
- "name": "passkey_register",
94
- "kind": "passkey_register",
95
- "primary": false,
96
- "text_key": "register.action.passkey"
97
- },
98
74
  {
99
75
  "name": "sign_in",
100
76
  "kind": "navigate",
@@ -106,9 +82,6 @@
106
82
  "submit": {
107
83
  "target": "register-password"
108
84
  },
109
- "passkey_register": {
110
- "target": "done"
111
- },
112
85
  "user_already_exists": {
113
86
  "target": "password"
114
87
  },
@@ -0,0 +1,107 @@
1
+ //#region src/branding-contrast.d.ts
2
+ /**
3
+ * Contrast checks for a branding revision's palettes.
4
+ *
5
+ * Advisory, not a gate. The server rejects a colour whose *form* is unsafe
6
+ * (`branding-css.ts`); this reports a pair whose *result* is hard to read.
7
+ * Publishing is not blocked on it: which pairs a surface actually renders
8
+ * depends on the step, the text size, and the template, so a revision that
9
+ * reads poorly is a judgement for the person publishing it rather than a
10
+ * value the API can refuse.
11
+ *
12
+ * Every colour form the palette accepts is measured, `color-mix()` and
13
+ * `currentColor` included (`css-color.ts`). A value that still cannot be
14
+ * resolved comes back `unresolved`, so a caller says "not checked" rather than
15
+ * implying a pass.
16
+ *
17
+ * Translucent values are composited before measuring: what a reader sees is
18
+ * the blend, so a ratio against the raw value would describe a colour nobody
19
+ * sees. Each role composites over what is actually behind it — a button fill
20
+ * over the card, the card over the page — rather than over the page in every
21
+ * case. A translucent page has the host application behind it, which is not
22
+ * ours to know, so that pair stays unresolved.
23
+ */
24
+ /** WCAG 2.2 AA: body text. */
25
+ declare const CONTRAST_AA_TEXT = 4.5;
26
+ /** WCAG 2.2 AA: large text, and the boundary of a user-interface component. */
27
+ declare const CONTRAST_AA_LARGE = 3;
28
+ type BrandingPaletteColors = Record<string, string | undefined>;
29
+ type ContrastPair = {
30
+ /**
31
+ * Stable join key, `foreground/background`. Downstream attaches a warning to
32
+ * a row and counts issues per section by this id, so it must not change even
33
+ * if the palette keys behind it are renamed.
34
+ */
35
+ id: string; /** Palette key carrying the foreground colour. */
36
+ foreground: string; /** Palette key carrying the surface it sits on. */
37
+ background: string; /** The ratio this pair has to reach. */
38
+ minimum: number; /** What the reader sees, for a message that names the thing rather than the key. */
39
+ describes: string;
40
+ };
41
+ /**
42
+ * The pairs a login surface actually puts next to each other. A palette key
43
+ * appears more than once where the design system paints it on more than one
44
+ * surface: `text` lands on both the page and the card, and a brand can pick a
45
+ * background those two disagree about.
46
+ */
47
+ declare const BRANDING_CONTRAST_PAIRS: readonly ContrastPair[];
48
+ type ContrastFinding = ContrastPair & {
49
+ status: "pass" | "fail" | "unresolved"; /** Absent when the pair could not be measured. */
50
+ ratio?: number;
51
+ };
52
+ /**
53
+ * Contrast ratio between two colours, per WCAG 2.2. Order does not matter.
54
+ * Both must be opaque: composite them first, since the ratio depends on what
55
+ * a translucent value is sitting on.
56
+ */
57
+ declare function contrastRatio(a: string, b: string, context?: ContrastContext): number | undefined;
58
+ type ContrastContext = {
59
+ currentColor?: string;
60
+ };
61
+ /**
62
+ * Check one side's palette. A pair whose colours are not both set is skipped
63
+ * rather than reported: an omitted key takes the maintained default for that
64
+ * side, which the design system already holds to this bar.
65
+ */
66
+ declare function checkPaletteContrast(palette: BrandingPaletteColors | undefined): ContrastFinding[];
67
+ /**
68
+ * The warnings a revision carries, in the shape the console renders and the
69
+ * API will later return: one entry per failing pair per side, and nothing for
70
+ * a pair that passes or could not be measured. An empty array means no
71
+ * warnings.
72
+ *
73
+ * Warning-only by decision: a failing pair never blocks saving or publishing.
74
+ */
75
+ declare function contrastIssues(branding: {
76
+ theme?: {
77
+ light?: {
78
+ palette?: BrandingPaletteColors;
79
+ };
80
+ dark?: {
81
+ palette?: BrandingPaletteColors;
82
+ };
83
+ };
84
+ }): ContrastIssue[];
85
+ type ContrastIssue = {
86
+ theme: "light" | "dark"; /** Stable pair id, e.g. `on_primary/primary`. */
87
+ pair: string; /** Measured ratio, rounded to two decimals. */
88
+ ratio: number; /** The ratio this pair had to reach. */
89
+ required: number;
90
+ };
91
+ /** Both sides of a revision, keyed by the side each finding belongs to. */
92
+ declare function checkBrandingContrast(branding: {
93
+ theme?: {
94
+ light?: {
95
+ palette?: BrandingPaletteColors;
96
+ };
97
+ dark?: {
98
+ palette?: BrandingPaletteColors;
99
+ };
100
+ };
101
+ }): {
102
+ light: ContrastFinding[];
103
+ dark: ContrastFinding[];
104
+ };
105
+ //#endregion
106
+ export { BRANDING_CONTRAST_PAIRS, BrandingPaletteColors, CONTRAST_AA_LARGE, CONTRAST_AA_TEXT, ContrastContext, ContrastFinding, ContrastIssue, ContrastPair, checkBrandingContrast, checkPaletteContrast, contrastIssues, contrastRatio };
107
+ //# sourceMappingURL=branding-contrast.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branding-contrast.d.mts","names":[],"sources":["../src/branding-contrast.ts"],"mappings":";;AAwBA;;;;;AAEA;;;;;AAIA;;;;;AAEA;;;;;;;cARa,gBAAA;;cAEA,iBAAA;AAAA,KAID,qBAAA,GAAwB,MAAA;AAAA,KAExB,YAAA;EA0CX;;;;AAED;EAtCE,EAAA;EAEA,UAAA,UAoC4B;EAlC5B,UAAA,UAqCA;EAnCA,OAAA,UAmCK;EAjCL,SAAA;AAAA;;;;;;;cASW,uBAAA,WAAkC,YAAA;AAAA,KAqBnC,eAAA,GAAkB,YAAA;EAC5B,MAAA,kCAkByB;EAhBzB,KAAA;AAAA;;AA+BF;;;;iBAvBgB,aAAA,CAAc,CAAA,UAAW,CAAA,UAAW,OAAA,GAAU,eAAA;AAAA,KAQlD,eAAA;EAAoB,YAAA;AAAA;;AAyFhC;;;;iBA1EgB,oBAAA,CAAqB,OAAA,EAAS,qBAAA,eAAoC,eAAA;;;;;;;;;iBA0ElE,cAAA,CAAe,QAAA;EAC7B,KAAA;IAAU,KAAA;MAAU,OAAA,GAAU,qBAAA;IAAA;IAAyB,IAAA;MAAS,OAAA,GAAU,qBAAA;IAAA;EAAA;AAAA,IACxE,aAAA;AAAA,KAiBQ,aAAA;EACV,KAAA,oBAIA;EAFA,IAAA,UAIQ;EAFR,KAAA,UAMc;EAJd,QAAA;AAAA;;iBAIc,qBAAA,CAAsB,QAAA;EACpC,KAAA;IAAU,KAAA;MAAU,OAAA,GAAU,qBAAA;IAAA;IAAyB,IAAA;MAAS,OAAA,GAAU,qBAAA;IAAA;EAAA;AAAA;EACtE,KAAA,EAAO,eAAA;EAAmB,IAAA,EAAM,eAAA;AAAA"}
@@ -0,0 +1,229 @@
1
+ import { compositeOver, parseCssColor, relativeLuminance } from "./css-color.mjs";
2
+ //#region src/branding-contrast.ts
3
+ /**
4
+ * Contrast checks for a branding revision's palettes.
5
+ *
6
+ * Advisory, not a gate. The server rejects a colour whose *form* is unsafe
7
+ * (`branding-css.ts`); this reports a pair whose *result* is hard to read.
8
+ * Publishing is not blocked on it: which pairs a surface actually renders
9
+ * depends on the step, the text size, and the template, so a revision that
10
+ * reads poorly is a judgement for the person publishing it rather than a
11
+ * value the API can refuse.
12
+ *
13
+ * Every colour form the palette accepts is measured, `color-mix()` and
14
+ * `currentColor` included (`css-color.ts`). A value that still cannot be
15
+ * resolved comes back `unresolved`, so a caller says "not checked" rather than
16
+ * implying a pass.
17
+ *
18
+ * Translucent values are composited before measuring: what a reader sees is
19
+ * the blend, so a ratio against the raw value would describe a colour nobody
20
+ * sees. Each role composites over what is actually behind it — a button fill
21
+ * over the card, the card over the page — rather than over the page in every
22
+ * case. A translucent page has the host application behind it, which is not
23
+ * ours to know, so that pair stays unresolved.
24
+ */
25
+ /** WCAG 2.2 AA: body text. */
26
+ const CONTRAST_AA_TEXT = 4.5;
27
+ /** WCAG 2.2 AA: large text, and the boundary of a user-interface component. */
28
+ const CONTRAST_AA_LARGE = 3;
29
+ /**
30
+ * The pairs a login surface actually puts next to each other. A palette key
31
+ * appears more than once where the design system paints it on more than one
32
+ * surface: `text` lands on both the page and the card, and a brand can pick a
33
+ * background those two disagree about.
34
+ */
35
+ const BRANDING_CONTRAST_PAIRS = [
36
+ {
37
+ id: "text/background",
38
+ foreground: "text",
39
+ background: "background",
40
+ minimum: CONTRAST_AA_TEXT,
41
+ describes: "body text on the page"
42
+ },
43
+ {
44
+ id: "text/surface",
45
+ foreground: "text",
46
+ background: "surface",
47
+ minimum: CONTRAST_AA_TEXT,
48
+ describes: "body text on the card"
49
+ },
50
+ {
51
+ id: "text_muted/surface",
52
+ foreground: "text_muted",
53
+ background: "surface",
54
+ minimum: CONTRAST_AA_TEXT,
55
+ describes: "secondary text on the card"
56
+ },
57
+ {
58
+ id: "text/muted",
59
+ foreground: "text",
60
+ background: "muted",
61
+ minimum: CONTRAST_AA_TEXT,
62
+ describes: "the secondary button's label"
63
+ },
64
+ {
65
+ id: "text_muted/muted",
66
+ foreground: "text_muted",
67
+ background: "muted",
68
+ minimum: CONTRAST_AA_TEXT,
69
+ describes: "supporting text in an alert"
70
+ },
71
+ {
72
+ id: "on_primary/primary",
73
+ foreground: "on_primary",
74
+ background: "primary",
75
+ minimum: CONTRAST_AA_TEXT,
76
+ describes: "the primary button's label"
77
+ },
78
+ {
79
+ id: "link/surface",
80
+ foreground: "link",
81
+ background: "surface",
82
+ minimum: CONTRAST_AA_TEXT,
83
+ describes: "links on the card"
84
+ },
85
+ {
86
+ id: "error/surface",
87
+ foreground: "error",
88
+ background: "surface",
89
+ minimum: CONTRAST_AA_TEXT,
90
+ describes: "error text on the card"
91
+ },
92
+ {
93
+ id: "success/surface",
94
+ foreground: "success",
95
+ background: "surface",
96
+ minimum: CONTRAST_AA_TEXT,
97
+ describes: "success text on the card"
98
+ },
99
+ {
100
+ id: "warning/surface",
101
+ foreground: "warning",
102
+ background: "surface",
103
+ minimum: CONTRAST_AA_TEXT,
104
+ describes: "warning text on the card"
105
+ },
106
+ {
107
+ id: "border/surface",
108
+ foreground: "border",
109
+ background: "surface",
110
+ minimum: 3,
111
+ describes: "the outline of inputs"
112
+ }
113
+ ];
114
+ /**
115
+ * Contrast ratio between two colours, per WCAG 2.2. Order does not matter.
116
+ * Both must be opaque: composite them first, since the ratio depends on what
117
+ * a translucent value is sitting on.
118
+ */
119
+ function contrastRatio(a, b, context) {
120
+ const first = parseCssColor(a, context ?? {});
121
+ const second = parseCssColor(b, context ?? {});
122
+ if (!first || !second) return void 0;
123
+ if (first.a < 1 || second.a < 1) return void 0;
124
+ return ratioOf(first, second);
125
+ }
126
+ function ratioOf(a, b) {
127
+ const la = relativeLuminance(a);
128
+ const lb = relativeLuminance(b);
129
+ const lighter = Math.max(la, lb);
130
+ const darker = Math.min(la, lb);
131
+ return (lighter + .05) / (darker + .05);
132
+ }
133
+ /**
134
+ * Check one side's palette. A pair whose colours are not both set is skipped
135
+ * rather than reported: an omitted key takes the maintained default for that
136
+ * side, which the design system already holds to this bar.
137
+ */
138
+ function checkPaletteContrast(palette) {
139
+ if (!palette) return [];
140
+ const context = { currentColor: palette["text"] };
141
+ const findings = [];
142
+ for (const pair of BRANDING_CONTRAST_PAIRS) {
143
+ const foreground = palette[pair.foreground];
144
+ const background = palette[pair.background];
145
+ if (!foreground || !background) continue;
146
+ const resolved = resolvePair(foreground, pair.background, palette, context);
147
+ if (!resolved) {
148
+ findings.push({
149
+ ...pair,
150
+ status: "unresolved"
151
+ });
152
+ continue;
153
+ }
154
+ const ratio = ratioOf(resolved.foreground, resolved.background);
155
+ findings.push({
156
+ ...pair,
157
+ status: ratio >= pair.minimum ? "pass" : "fail",
158
+ ratio
159
+ });
160
+ }
161
+ return findings;
162
+ }
163
+ /**
164
+ * What sits behind each painted role. The card sits on the page; the button
165
+ * and alert fills sit on the card. A role absent from this map is the page
166
+ * itself, which has the host application behind it.
167
+ */
168
+ const BACKDROP = {
169
+ surface: "background",
170
+ primary: "surface",
171
+ muted: "surface"
172
+ };
173
+ function resolvePair(foregroundValue, backgroundRole, palette, context) {
174
+ const fg = parseCssColor(foregroundValue, context);
175
+ const bg = resolveSurface(backgroundRole, palette, context, /* @__PURE__ */ new Set());
176
+ if (!fg || !bg) return void 0;
177
+ return {
178
+ foreground: compositeOver(fg, bg),
179
+ background: bg
180
+ };
181
+ }
182
+ /** Resolve a role to an opaque colour, compositing it down its own stack. */
183
+ function resolveSurface(role, palette, context, seen) {
184
+ if (seen.has(role)) return void 0;
185
+ seen.add(role);
186
+ const value = palette[role];
187
+ if (!value) return void 0;
188
+ const color = parseCssColor(value, context);
189
+ if (!color) return void 0;
190
+ if (color.a >= 1) return color;
191
+ const behind = BACKDROP[role];
192
+ if (!behind) return void 0;
193
+ const backdrop = resolveSurface(behind, palette, context, seen);
194
+ if (!backdrop) return void 0;
195
+ return compositeOver(color, backdrop);
196
+ }
197
+ /**
198
+ * The warnings a revision carries, in the shape the console renders and the
199
+ * API will later return: one entry per failing pair per side, and nothing for
200
+ * a pair that passes or could not be measured. An empty array means no
201
+ * warnings.
202
+ *
203
+ * Warning-only by decision: a failing pair never blocks saving or publishing.
204
+ */
205
+ function contrastIssues(branding) {
206
+ const byTheme = checkBrandingContrast(branding);
207
+ const issues = [];
208
+ for (const theme of ["light", "dark"]) for (const finding of byTheme[theme]) {
209
+ if (finding.status !== "fail" || finding.ratio === void 0) continue;
210
+ issues.push({
211
+ theme,
212
+ pair: finding.id,
213
+ ratio: Math.round(finding.ratio * 100) / 100,
214
+ required: finding.minimum
215
+ });
216
+ }
217
+ return issues;
218
+ }
219
+ /** Both sides of a revision, keyed by the side each finding belongs to. */
220
+ function checkBrandingContrast(branding) {
221
+ return {
222
+ light: checkPaletteContrast(branding.theme?.light?.palette),
223
+ dark: checkPaletteContrast(branding.theme?.dark?.palette)
224
+ };
225
+ }
226
+ //#endregion
227
+ export { BRANDING_CONTRAST_PAIRS, CONTRAST_AA_LARGE, CONTRAST_AA_TEXT, checkBrandingContrast, checkPaletteContrast, contrastIssues, contrastRatio };
228
+
229
+ //# sourceMappingURL=branding-contrast.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"branding-contrast.mjs","names":[],"sources":["../src/branding-contrast.ts"],"sourcesContent":["/**\n * Contrast checks for a branding revision's palettes.\n *\n * Advisory, not a gate. The server rejects a colour whose *form* is unsafe\n * (`branding-css.ts`); this reports a pair whose *result* is hard to read.\n * Publishing is not blocked on it: which pairs a surface actually renders\n * depends on the step, the text size, and the template, so a revision that\n * reads poorly is a judgement for the person publishing it rather than a\n * value the API can refuse.\n *\n * Every colour form the palette accepts is measured, `color-mix()` and\n * `currentColor` included (`css-color.ts`). A value that still cannot be\n * resolved comes back `unresolved`, so a caller says \"not checked\" rather than\n * implying a pass.\n *\n * Translucent values are composited before measuring: what a reader sees is\n * the blend, so a ratio against the raw value would describe a colour nobody\n * sees. Each role composites over what is actually behind it — a button fill\n * over the card, the card over the page — rather than over the page in every\n * case. A translucent page has the host application behind it, which is not\n * ours to know, so that pair stays unresolved.\n */\n\n/** WCAG 2.2 AA: body text. */\nexport const CONTRAST_AA_TEXT = 4.5;\n/** WCAG 2.2 AA: large text, and the boundary of a user-interface component. */\nexport const CONTRAST_AA_LARGE = 3;\n\nimport { compositeOver, parseCssColor, relativeLuminance, type Rgba } from \"./css-color.js\";\n\nexport type BrandingPaletteColors = Record<string, string | undefined>;\n\nexport type ContrastPair = {\n /**\n * Stable join key, `foreground/background`. Downstream attaches a warning to\n * a row and counts issues per section by this id, so it must not change even\n * if the palette keys behind it are renamed.\n */\n id: string;\n /** Palette key carrying the foreground colour. */\n foreground: string;\n /** Palette key carrying the surface it sits on. */\n background: string;\n /** The ratio this pair has to reach. */\n minimum: number;\n /** What the reader sees, for a message that names the thing rather than the key. */\n describes: string;\n};\n\n/**\n * The pairs a login surface actually puts next to each other. A palette key\n * appears more than once where the design system paints it on more than one\n * surface: `text` lands on both the page and the card, and a brand can pick a\n * background those two disagree about.\n */\nexport const BRANDING_CONTRAST_PAIRS: readonly ContrastPair[] = [\n { id: \"text/background\", foreground: \"text\", background: \"background\", minimum: CONTRAST_AA_TEXT, describes: \"body text on the page\" },\n { id: \"text/surface\", foreground: \"text\", background: \"surface\", minimum: CONTRAST_AA_TEXT, describes: \"body text on the card\" },\n { id: \"text_muted/surface\", foreground: \"text_muted\", background: \"surface\", minimum: CONTRAST_AA_TEXT, describes: \"secondary text on the card\" },\n // `muted` fills the secondary button and the alert, both of which draw text\n // on it — a palette can read perfectly on the card and be unreadable here.\n { id: \"text/muted\", foreground: \"text\", background: \"muted\", minimum: CONTRAST_AA_TEXT, describes: \"the secondary button's label\" },\n { id: \"text_muted/muted\", foreground: \"text_muted\", background: \"muted\", minimum: CONTRAST_AA_TEXT, describes: \"supporting text in an alert\" },\n { id: \"on_primary/primary\", foreground: \"on_primary\", background: \"primary\", minimum: CONTRAST_AA_TEXT, describes: \"the primary button's label\" },\n { id: \"link/surface\", foreground: \"link\", background: \"surface\", minimum: CONTRAST_AA_TEXT, describes: \"links on the card\" },\n { id: \"error/surface\", foreground: \"error\", background: \"surface\", minimum: CONTRAST_AA_TEXT, describes: \"error text on the card\" },\n { id: \"success/surface\", foreground: \"success\", background: \"surface\", minimum: CONTRAST_AA_TEXT, describes: \"success text on the card\" },\n { id: \"warning/surface\", foreground: \"warning\", background: \"surface\", minimum: CONTRAST_AA_TEXT, describes: \"warning text on the card\" },\n // The one pair that is not text. A field outline is a user-interface\n // component boundary, which WCAG 2.2 holds to 3:1 rather than 4.5:1 — an\n // input the visitor cannot find is a real failure, not decoration. Dropping\n // it would make `required` a constant, which is the only argument for\n // leaving it out.\n { id: \"border/surface\", foreground: \"border\", background: \"surface\", minimum: CONTRAST_AA_LARGE, describes: \"the outline of inputs\" },\n];\n\nexport type ContrastFinding = ContrastPair & {\n status: \"pass\" | \"fail\" | \"unresolved\";\n /** Absent when the pair could not be measured. */\n ratio?: number;\n};\n\n/**\n * Contrast ratio between two colours, per WCAG 2.2. Order does not matter.\n * Both must be opaque: composite them first, since the ratio depends on what\n * a translucent value is sitting on.\n */\nexport function contrastRatio(a: string, b: string, context?: ContrastContext): number | undefined {\n const first = parseCssColor(a, context ?? {});\n const second = parseCssColor(b, context ?? {});\n if (!first || !second) return undefined;\n if (first.a < 1 || second.a < 1) return undefined;\n return ratioOf(first, second);\n}\n\nexport type ContrastContext = { currentColor?: string };\n\nfunction ratioOf(a: Rgba, b: Rgba): number {\n const la = relativeLuminance(a);\n const lb = relativeLuminance(b);\n const lighter = Math.max(la, lb);\n const darker = Math.min(la, lb);\n return (lighter + 0.05) / (darker + 0.05);\n}\n\n/**\n * Check one side's palette. A pair whose colours are not both set is skipped\n * rather than reported: an omitted key takes the maintained default for that\n * side, which the design system already holds to this bar.\n */\nexport function checkPaletteContrast(palette: BrandingPaletteColors | undefined): ContrastFinding[] {\n if (!palette) return [];\n // `currentColor` on a palette key means the text colour it would inherit.\n // Everything the pairs below draw sits on the card, so that is the side's\n // own `text` — which is why this is answerable here and not in general CSS.\n const context = { currentColor: palette[\"text\"] };\n const findings: ContrastFinding[] = [];\n for (const pair of BRANDING_CONTRAST_PAIRS) {\n const foreground = palette[pair.foreground];\n const background = palette[pair.background];\n if (!foreground || !background) continue;\n const resolved = resolvePair(foreground, pair.background, palette, context);\n if (!resolved) {\n findings.push({ ...pair, status: \"unresolved\" });\n continue;\n }\n const ratio = ratioOf(resolved.foreground, resolved.background);\n findings.push({ ...pair, status: ratio >= pair.minimum ? \"pass\" : \"fail\", ratio });\n }\n return findings;\n}\n\n/**\n * What sits behind each painted role. The card sits on the page; the button\n * and alert fills sit on the card. A role absent from this map is the page\n * itself, which has the host application behind it.\n */\nconst BACKDROP: Record<string, string> = {\n surface: \"background\",\n primary: \"surface\",\n muted: \"surface\",\n};\n\nfunction resolvePair(\n foregroundValue: string,\n backgroundRole: string,\n palette: BrandingPaletteColors,\n context: ContrastContext,\n): { foreground: Rgba; background: Rgba } | undefined {\n const fg = parseCssColor(foregroundValue, context);\n const bg = resolveSurface(backgroundRole, palette, context, new Set());\n if (!fg || !bg) return undefined;\n return { foreground: compositeOver(fg, bg), background: bg };\n}\n\n/** Resolve a role to an opaque colour, compositing it down its own stack. */\nfunction resolveSurface(\n role: string,\n palette: BrandingPaletteColors,\n context: ContrastContext,\n seen: Set<string>,\n): Rgba | undefined {\n if (seen.has(role)) return undefined;\n seen.add(role);\n const value = palette[role];\n if (!value) return undefined;\n const color = parseCssColor(value, context);\n if (!color) return undefined;\n if (color.a >= 1) return color;\n const behind = BACKDROP[role];\n if (!behind) return undefined;\n const backdrop = resolveSurface(behind, palette, context, seen);\n if (!backdrop) return undefined;\n return compositeOver(color, backdrop);\n}\n\n/**\n * The warnings a revision carries, in the shape the console renders and the\n * API will later return: one entry per failing pair per side, and nothing for\n * a pair that passes or could not be measured. An empty array means no\n * warnings.\n *\n * Warning-only by decision: a failing pair never blocks saving or publishing.\n */\nexport function contrastIssues(branding: {\n theme?: { light?: { palette?: BrandingPaletteColors }; dark?: { palette?: BrandingPaletteColors } };\n}): ContrastIssue[] {\n const byTheme = checkBrandingContrast(branding);\n const issues: ContrastIssue[] = [];\n for (const theme of [\"light\", \"dark\"] as const) {\n for (const finding of byTheme[theme]) {\n if (finding.status !== \"fail\" || finding.ratio === undefined) continue;\n issues.push({\n theme,\n pair: finding.id,\n ratio: Math.round(finding.ratio * 100) / 100,\n required: finding.minimum,\n });\n }\n }\n return issues;\n}\n\nexport type ContrastIssue = {\n theme: \"light\" | \"dark\";\n /** Stable pair id, e.g. `on_primary/primary`. */\n pair: string;\n /** Measured ratio, rounded to two decimals. */\n ratio: number;\n /** The ratio this pair had to reach. */\n required: number;\n};\n\n/** Both sides of a revision, keyed by the side each finding belongs to. */\nexport function checkBrandingContrast(branding: {\n theme?: { light?: { palette?: BrandingPaletteColors }; dark?: { palette?: BrandingPaletteColors } };\n}): { light: ContrastFinding[]; dark: ContrastFinding[] } {\n return {\n light: checkPaletteContrast(branding.theme?.light?.palette),\n dark: checkPaletteContrast(branding.theme?.dark?.palette),\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ //#region src/css-color.d.ts
2
+ /** An sRGB colour, channels 0-255, alpha 0-1. */
3
+ type Rgba = {
4
+ r: number;
5
+ g: number;
6
+ b: number;
7
+ a: number;
8
+ };
9
+ type ResolveContext = {
10
+ /**
11
+ * What `currentColor` inherits at the point this value is painted. Left
12
+ * unset, `currentColor` stays unresolved rather than being guessed at.
13
+ */
14
+ currentColor?: string;
15
+ };
16
+ /**
17
+ * Parse any colour form a palette accepts. Returns `undefined` for a value
18
+ * that is malformed, or for `currentColor` with no context to resolve it
19
+ * against.
20
+ */
21
+ declare function parseCssColor(input: string, context?: ResolveContext): Rgba | undefined;
22
+ /**
23
+ * Composite a colour over an opaque backdrop. A translucent foreground is what
24
+ * the reader sees blended with what is behind it, so a ratio measured against
25
+ * the raw value would describe a colour nobody sees.
26
+ */
27
+ declare function compositeOver(color: Rgba, backdrop: Rgba): Rgba;
28
+ /** WCAG 2.2 relative luminance of an opaque sRGB colour. */
29
+ declare function relativeLuminance({
30
+ r,
31
+ g,
32
+ b
33
+ }: Rgba): number;
34
+ //#endregion
35
+ export { ResolveContext, Rgba, compositeOver, parseCssColor, relativeLuminance };
36
+ //# sourceMappingURL=css-color.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"css-color.d.mts","names":[],"sources":["../src/css-color.ts"],"mappings":";;KA8BY,IAAA;EAAS,CAAA;EAAW,CAAA;EAAW,CAAA;EAAW,CAAA;AAAA;AAAA,KAE1C,cAAA;EAF0C;;;AAEtD;EAKE,YAAA;AAAA;;;AA0CF;;;iBAAgB,aAAA,CAAc,KAAA,UAAe,OAAA,GAAS,cAAA,GAAsB,IAAA;;;;;;iBAqK5D,aAAA,CAAc,KAAA,EAAO,IAAA,EAAM,QAAA,EAAU,IAAA,GAAO,IAAA;AAA5D;AAAA,iBAYgB,iBAAA,CAAA;EAAoB,CAAA;EAAG,CAAA;EAAG;AAAA,GAAK,IAAA"}