@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.
- package/README.md +2 -0
- package/defaults/README-branding.md +1 -1
- package/defaults/README-flows.md +9 -4
- package/defaults/default-login.json +0 -27
- package/dist/branding-contrast.d.mts +107 -0
- package/dist/branding-contrast.d.mts.map +1 -0
- package/dist/branding-contrast.mjs +229 -0
- package/dist/branding-contrast.mjs.map +1 -0
- package/dist/css-color.d.mts +36 -0
- package/dist/css-color.d.mts.map +1 -0
- package/dist/css-color.mjs +231 -0
- package/dist/css-color.mjs.map +1 -0
- package/dist/{defaults-Bh4_xVbh.d.mts → defaults-BxGCGdqS.d.mts} +5 -3
- package/dist/defaults-BxGCGdqS.d.mts.map +1 -0
- package/dist/{defaults-DPq0o3iU.mjs → defaults-DV6hY2KT.mjs} +25 -53
- package/dist/defaults-DV6hY2KT.mjs.map +1 -0
- package/dist/defaults.d.mts +1 -1
- package/dist/defaults.mjs +1 -1
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +4 -4
- package/dist/meta-schemas-OowgSUfU.mjs +1173 -0
- package/dist/meta-schemas-OowgSUfU.mjs.map +1 -0
- package/dist/meta-schemas.d.mts +4 -3
- package/dist/meta-schemas.d.mts.map +1 -1
- package/dist/meta-schemas.mjs +1 -1
- package/dist/schemas-7oNU25Qz.mjs +173 -0
- package/dist/schemas-7oNU25Qz.mjs.map +1 -0
- package/dist/schemas.d.mts +12 -5
- package/dist/schemas.d.mts.map +1 -1
- package/dist/schemas.mjs +3 -69
- package/dist/validate.d.mts +8 -2
- package/dist/validate.d.mts.map +1 -1
- package/dist/validate.mjs +9 -2
- package/dist/validate.mjs.map +1 -1
- package/meta-schemas/auth-method.json +1 -0
- package/meta-schemas/auth-methods.json +5 -3
- package/meta-schemas/branding.json +268 -19
- package/meta-schemas/flow-definition.json +375 -171
- package/meta-schemas/idp-connection.json +397 -0
- package/meta-schemas/property-name.json +1 -0
- package/meta-schemas/sso-auth-method.json +54 -0
- package/meta-schemas/user-property.json +10 -13
- package/meta-schemas/user-schema.json +20 -13
- package/package.json +18 -5
- package/dist/defaults-Bh4_xVbh.d.mts.map +0 -1
- package/dist/defaults-DPq0o3iU.mjs.map +0 -1
- package/dist/meta-schemas-z4IlqbLd.mjs +0 -531
- package/dist/meta-schemas-z4IlqbLd.mjs.map +0 -1
- 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
|
-
"
|
|
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
|
}
|
package/defaults/README-flows.md
CHANGED
|
@@ -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
|
|
101
|
-
announces the re-pin beforehand, and the rewrite shows up
|
|
102
|
-
diff. Remember to update `steps[].fields[]` yourself when the
|
|
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"}
|