@zitadel/config 1.0.0-alpha.23 → 1.0.0-alpha.25
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/defaults/README-branding.md +26 -34
- package/defaults/branding/centered/login.liquid +31 -11
- package/defaults/branding/minimal/login.liquid +30 -10
- package/dist/branding-contrast.d.mts +28 -21
- package/dist/branding-contrast.d.mts.map +1 -1
- package/dist/branding-contrast.mjs +1 -1
- package/dist/branding-contrast.mjs.map +1 -1
- package/dist/branding-url.d.mts +1 -2
- package/dist/branding-url.d.mts.map +1 -1
- package/dist/branding-url.mjs.map +1 -1
- package/dist/css-color.d.mts +5 -10
- package/dist/css-color.d.mts.map +1 -1
- package/dist/css-color.mjs +1 -1
- package/dist/css-color.mjs.map +1 -1
- package/dist/{defaults-BxGCGdqS.d.mts → defaults-BtKIcWxW.d.mts} +21 -14
- package/dist/defaults-BtKIcWxW.d.mts.map +1 -0
- package/dist/{defaults-DV6hY2KT.mjs → defaults-COLBFU3S.mjs} +17 -18
- package/dist/defaults-COLBFU3S.mjs.map +1 -0
- package/dist/defaults.d.mts +1 -1
- package/dist/defaults.mjs +1 -1
- package/dist/idp/index.d.mts +2 -0
- package/dist/idp/index.mjs +2 -0
- package/dist/idp-ByzXQN85.mjs +207 -0
- package/dist/idp-ByzXQN85.mjs.map +1 -0
- package/dist/idp-connection-CHxGm1Wr.mjs +324 -0
- package/dist/idp-connection-CHxGm1Wr.mjs.map +1 -0
- package/dist/index-Cuxu-zQD.d.mts +210 -0
- package/dist/index-Cuxu-zQD.d.mts.map +1 -0
- package/dist/index.d.mts +4 -3
- package/dist/index.mjs +5 -4
- package/dist/{meta-schemas-OowgSUfU.mjs → meta-schemas-C03zgFux.mjs} +36 -333
- package/dist/meta-schemas-C03zgFux.mjs.map +1 -0
- package/dist/meta-schemas.d.mts +5 -6
- package/dist/meta-schemas.d.mts.map +1 -1
- package/dist/meta-schemas.mjs +1 -1
- package/dist/normalize.d.mts +3 -4
- package/dist/normalize.d.mts.map +1 -1
- package/dist/normalize.mjs.map +1 -1
- package/dist/{schemas-7oNU25Qz.mjs → schemas-CUU4Rf8J.mjs} +11 -4
- package/dist/schemas-CUU4Rf8J.mjs.map +1 -0
- package/dist/schemas.d.mts +361 -9
- package/dist/schemas.d.mts.map +1 -1
- package/dist/schemas.mjs +2 -2
- package/dist/template.d.mts +9 -10
- package/dist/template.d.mts.map +1 -1
- package/dist/template.mjs +1 -1
- package/dist/template.mjs.map +1 -1
- package/dist/validate.d.mts +15 -13
- package/dist/validate.d.mts.map +1 -1
- package/dist/validate.mjs +28 -18
- package/dist/validate.mjs.map +1 -1
- package/meta-schemas/branding.json +5 -5
- package/meta-schemas/flow-definition.json +81 -6
- package/meta-schemas/idp-connection.json +6 -6
- package/package.json +18 -8
- package/defaults/branding/hero/login.liquid +0 -238
- package/defaults/branding/split/login.liquid +0 -197
- package/defaults/branding/split-right/login.liquid +0 -197
- package/dist/defaults-BxGCGdqS.d.mts.map +0 -1
- package/dist/defaults-DV6hY2KT.mjs.map +0 -1
- package/dist/meta-schemas-OowgSUfU.mjs.map +0 -1
- package/dist/schemas-7oNU25Qz.mjs.map +0 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# Branding
|
|
2
2
|
|
|
3
|
-
This directory owns
|
|
4
|
-
|
|
5
|
-
`<zitadel-login>` component renders for every step.
|
|
3
|
+
This directory owns the structure of your login widget: `branding.json`
|
|
4
|
+
(asset URLs and appearance) plus `login.liquid`, the LiquidJS template the
|
|
5
|
+
`<zitadel-login>` component renders for every step. The page around the
|
|
6
|
+
widget (a split screen, a hero pane, marketing copy) belongs in your
|
|
7
|
+
application, not in this template.
|
|
6
8
|
|
|
7
9
|
## Workflow
|
|
8
10
|
|
|
9
|
-
1. Edit `login.liquid` (and `branding.json` for logo
|
|
11
|
+
1. Edit `login.liquid` (and `branding.json` for the logo URL).
|
|
10
12
|
2. `zitadel plan` — validates the template (LiquidJS parse, banned patterns,
|
|
11
13
|
the required `{% mandatory_gates %}` tag) and shows the pending revision.
|
|
12
14
|
3. `zitadel apply` — publishes an immutable branding revision. The login UI
|
|
@@ -22,31 +24,31 @@ by re-applying an earlier template.
|
|
|
22
24
|
tokens, not template CSS.
|
|
23
25
|
- User-facing copy goes through translation keys: `{{ "key" | t }}`.
|
|
24
26
|
- Keep the trailing `{% mandatory_gates %}` tag: it appends any required
|
|
25
|
-
field
|
|
26
|
-
|
|
27
|
+
field and the primary action your template left out, so the step stays
|
|
28
|
+
submittable.
|
|
27
29
|
|
|
28
30
|
## Make it yours
|
|
29
31
|
|
|
30
32
|
- Brand asset URLs go in `branding.json` and must use `https://`. Each template
|
|
31
|
-
decides which fields it renders: `centered`
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
decides which fields it renders: `centered` uses `logo_url`; `minimal` uses
|
|
34
|
+
none until you add it to `login.liquid`. Set the typeface with
|
|
35
|
+
`typography.font_family`. `typography.font_url` is loaded when the component
|
|
36
|
+
runs as `variant="page"`, which the sign-in pages `zitadel setup` generates
|
|
37
|
+
use: the component then adds that stylesheet to your page. With
|
|
38
|
+
`variant="widget"` it is not loaded, and your page loads the font.
|
|
36
39
|
|
|
37
40
|
```json
|
|
38
41
|
{
|
|
39
42
|
"$schema": "../meta/branding.json",
|
|
40
|
-
"layout": "
|
|
43
|
+
"layout": "centered",
|
|
41
44
|
"liquid_template": { "$file": "./login.liquid" },
|
|
42
|
-
"logo_url": "https://example.com/logo.svg"
|
|
43
|
-
"hero_url": "https://example.com/hero.jpg"
|
|
45
|
+
"logo_url": "https://example.com/logo.svg"
|
|
44
46
|
}
|
|
45
47
|
```
|
|
46
48
|
|
|
47
49
|
A well-formed URL that serves nothing is the one branding mistake nothing
|
|
48
50
|
else catches — it passes validation, publishes a revision, and then renders
|
|
49
|
-
as an invisible image. So `zitadel plan` probes
|
|
51
|
+
as an invisible image. So `zitadel plan` probes `logo_url` and `hero_url` and warns
|
|
50
52
|
(never fails) when it is unreachable, bodyless, or not an image, and the
|
|
51
53
|
login UI hides an asset that fails to load, restoring the shipped design's
|
|
52
54
|
no-asset content rather than leaving a gap. If the host is only
|
|
@@ -56,24 +58,15 @@ by re-applying an earlier template.
|
|
|
56
58
|
targets are left to the browser-side fallback instead of being requested by
|
|
57
59
|
the machine running `plan`.
|
|
58
60
|
|
|
59
|
-
`layout` is
|
|
60
|
-
|
|
61
|
-
`minimal`)
|
|
62
|
-
|
|
61
|
+
`layout` is **not** a design picker. The shipped template does not read it,
|
|
62
|
+
so changing it alone changes nothing on screen. Both shipped designs
|
|
63
|
+
(`centered`, `minimal`) carry `centered`. Switch designs with
|
|
64
|
+
`zitadel branding eject --design <name>`, don't edit `layout`.
|
|
63
65
|
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
starting point on token-styled `zl-hero__*` classes.
|
|
69
|
-
- On narrow viewports the brand pane collapses and the `.zl-split__compact`
|
|
70
|
-
node inside the form pane takes over (logo or brand line) — keep one so
|
|
71
|
-
your identity survives on phones.
|
|
72
|
-
- The split chrome is tunable from your template root's `style` attribute
|
|
73
|
-
(`--zl-split-columns`, `--zl-split-align`, `--zl-split-brand-mobile`), and
|
|
74
|
-
`zl-split--right` on the wrapper mirrors the panes. Knob reference:
|
|
75
|
-
[`docs/design/branding/templates.md`](https://github.com/zitadel/nextgen/blob/main/docs/design/branding/templates.md).
|
|
76
|
-
- The "Secured with Zitadel" attribution is licence-gated and on by default.
|
|
66
|
+
- The "Secured with Zitadel" attribution is always shown, and `branding.json`
|
|
67
|
+
has no setting for it. It sits at the foot of the login; to place it
|
|
68
|
+
elsewhere, put
|
|
69
|
+
`<div data-zl-attribution-anchor></div>` where you want it in `login.liquid`.
|
|
77
70
|
- Back-navigation: the engine injects a `kind: "back"` action on steps
|
|
78
71
|
that can return to their predecessor, and the browser's back gesture
|
|
79
72
|
submits it automatically — the shipped designs deliberately render **no
|
|
@@ -92,5 +85,4 @@ by re-applying an earlier template.
|
|
|
92
85
|
loops so the injected action doesn't render as a stray secondary button.
|
|
93
86
|
|
|
94
87
|
Start over anytime with `zitadel branding eject --design <name>` (designs:
|
|
95
|
-
`centered`, `
|
|
96
|
-
overwrite).
|
|
88
|
+
`centered`, `minimal`; add `--force` to overwrite).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
{% if branding.logo_url %}
|
|
3
3
|
<img slot="header" class="zl-card-logo" src="{{ branding.logo_url }}" alt="" />
|
|
4
4
|
{% endif %}
|
|
5
|
-
<zl-card>
|
|
5
|
+
<zl-card{% if step.complete %} class="zl-card--terminal"{% endif %}>
|
|
6
6
|
<h1 slot="header" class="zl-card-title">{{ step.texts.title_key | t: identity.display_name }}</h1>
|
|
7
7
|
{% if step.texts.description_key %}
|
|
8
8
|
{% assign card_subtitle = step.texts.description_key | t: identity.display_name %}
|
|
@@ -40,21 +40,13 @@
|
|
|
40
40
|
{% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
|
|
41
41
|
></zl-select>
|
|
42
42
|
{% else %}
|
|
43
|
-
{% assign autocomplete_val = '' %}
|
|
44
|
-
{% if f.name == 'email' %}
|
|
45
|
-
{% assign autocomplete_val = 'email' %}
|
|
46
|
-
{% elsif f.type == 'password' and step.name contains 'register' %}
|
|
47
|
-
{% assign autocomplete_val = 'new-password' %}
|
|
48
|
-
{% elsif f.type == 'password' %}
|
|
49
|
-
{% assign autocomplete_val = 'current-password' %}
|
|
50
|
-
{% endif %}
|
|
51
43
|
<zl-field
|
|
52
44
|
name="{{ f.name }}"
|
|
53
45
|
data-testid="zitadel-field-{{ f.name | testid }}"
|
|
54
46
|
label="{{ f.text_key | t }}"
|
|
55
47
|
type="{{ f.type }}"
|
|
56
48
|
value="{{ f.value }}"
|
|
57
|
-
{% if
|
|
49
|
+
{% if f.autocomplete %}autocomplete="{{ f.autocomplete }}"{% endif %}
|
|
58
50
|
{% if f.required %}required{% endif %}
|
|
59
51
|
{% if field_placeholder != "" %}placeholder="{{ field_placeholder }}"{% endif %}
|
|
60
52
|
{% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
|
|
@@ -79,6 +71,9 @@
|
|
|
79
71
|
{% assign alert_body = err.text_key | alertBody %}
|
|
80
72
|
<zl-alert data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
|
|
81
73
|
{% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
|
|
74
|
+
{% comment %} A provider error carries its own words and a link; show both when present. {% endcomment %}
|
|
75
|
+
{%- if err.detail %}<span slot="detail">{{ err.detail }}</span>{% endif -%}
|
|
76
|
+
{%- if err.uri %}<a slot="link" href="{{ err.uri }}" target="_blank" rel="noopener noreferrer">{{ "error.more_info" | t }}</a>{% endif -%}
|
|
82
77
|
</zl-alert>
|
|
83
78
|
{% endif %}
|
|
84
79
|
{% endfor %}
|
|
@@ -112,6 +107,21 @@
|
|
|
112
107
|
{% endif %}
|
|
113
108
|
{% endfor %}
|
|
114
109
|
|
|
110
|
+
{% comment %}
|
|
111
|
+
Identity providers the step offers, below the primary action: the
|
|
112
|
+
password path stays the default and this is the alternative, which is
|
|
113
|
+
also the order the divider reads in. The atom renders nothing when the
|
|
114
|
+
step carries no providers, so this costs a shipped flow nothing.
|
|
115
|
+
{% endcomment %}
|
|
116
|
+
{% if sso_providers and sso_providers.size > 0 %}
|
|
117
|
+
<zl-sso-providers
|
|
118
|
+
data-testid="zitadel-sso-providers"
|
|
119
|
+
providers='{{ sso_providers | json }}'
|
|
120
|
+
label-format="{{ 'sso.continue_with' | t }}"
|
|
121
|
+
{% if loading %}disabled{% endif %}
|
|
122
|
+
></zl-sso-providers>
|
|
123
|
+
{% endif %}
|
|
124
|
+
|
|
115
125
|
{% for a in actions %}
|
|
116
126
|
{% unless a.primary or a.kind == 'back' or a.name == 'passkey' or a.name == 'register' or a.name == 'sign_in' or a.name == 'recover' %}
|
|
117
127
|
<zl-button
|
|
@@ -151,7 +161,17 @@
|
|
|
151
161
|
{% assign sign_in = actions | where: "name", "sign_in" | first %}
|
|
152
162
|
{% if sign_in %}
|
|
153
163
|
<p class="zl-card-nav">
|
|
154
|
-
{
|
|
164
|
+
{% comment %}
|
|
165
|
+
The register step's row reads "Already have an account? Sign in".
|
|
166
|
+
Any other step that offers `sign_in` — the SSO conflict screen, which
|
|
167
|
+
has just told the user they have one — carries its own copy, so the
|
|
168
|
+
lead is dropped and the action's own text is used.
|
|
169
|
+
{% endcomment %}
|
|
170
|
+
{% if sign_in.text_key == 'register.action.sign_in.link' %}
|
|
171
|
+
{{ 'register.action.sign_in.lead' | t }}<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ 'register.action.sign_in.link' | t }}</a>
|
|
172
|
+
{% else %}
|
|
173
|
+
<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ sign_in.text_key | t }}</a>
|
|
174
|
+
{% endif %}
|
|
155
175
|
</p>
|
|
156
176
|
{% endif %}
|
|
157
177
|
</div>
|
|
@@ -31,21 +31,13 @@
|
|
|
31
31
|
{% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
|
|
32
32
|
></zl-select>
|
|
33
33
|
{% else %}
|
|
34
|
-
{% assign autocomplete_val = '' %}
|
|
35
|
-
{% if f.name == 'email' %}
|
|
36
|
-
{% assign autocomplete_val = 'email' %}
|
|
37
|
-
{% elsif f.type == 'password' and step.name contains 'register' %}
|
|
38
|
-
{% assign autocomplete_val = 'new-password' %}
|
|
39
|
-
{% elsif f.type == 'password' %}
|
|
40
|
-
{% assign autocomplete_val = 'current-password' %}
|
|
41
|
-
{% endif %}
|
|
42
34
|
<zl-field
|
|
43
35
|
name="{{ f.name }}"
|
|
44
36
|
data-testid="zitadel-field-{{ f.name | testid }}"
|
|
45
37
|
label="{{ f.text_key | t }}"
|
|
46
38
|
type="{{ f.type }}"
|
|
47
39
|
value="{{ f.value }}"
|
|
48
|
-
{% if
|
|
40
|
+
{% if f.autocomplete %}autocomplete="{{ f.autocomplete }}"{% endif %}
|
|
49
41
|
{% if f.required %}required{% endif %}
|
|
50
42
|
{% if field_placeholder != "" %}placeholder="{{ field_placeholder }}"{% endif %}
|
|
51
43
|
{% if field_err != "" %}invalid error="{{ field_err }}"{% endif %}
|
|
@@ -65,6 +57,9 @@
|
|
|
65
57
|
{% assign alert_body = err.text_key | alertBody %}
|
|
66
58
|
<zl-alert data-zl-step-error{% if alert_heading != "" %} heading="{{ alert_heading | t }}"{% endif %}>
|
|
67
59
|
{% if alert_body != "" %}{{ alert_body | t }}{% elsif err.text_key %}{{ err.text_key | t }}{% else %}{{ err.message }}{% endif %}
|
|
60
|
+
{% comment %} A provider error carries its own words and a link; show both when present. {% endcomment %}
|
|
61
|
+
{%- if err.detail %}<span slot="detail">{{ err.detail }}</span>{% endif -%}
|
|
62
|
+
{%- if err.uri %}<a slot="link" href="{{ err.uri }}" target="_blank" rel="noopener noreferrer">{{ "error.more_info" | t }}</a>{% endif -%}
|
|
68
63
|
</zl-alert>
|
|
69
64
|
{% endif %}
|
|
70
65
|
{% endfor %}
|
|
@@ -98,6 +93,21 @@
|
|
|
98
93
|
{% endif %}
|
|
99
94
|
{% endfor %}
|
|
100
95
|
|
|
96
|
+
{% comment %}
|
|
97
|
+
Identity providers the step offers, below the primary action: the
|
|
98
|
+
password path stays the default and this is the alternative, which is
|
|
99
|
+
also the order the divider reads in. The atom renders nothing when the
|
|
100
|
+
step carries no providers, so this costs a shipped flow nothing.
|
|
101
|
+
{% endcomment %}
|
|
102
|
+
{% if sso_providers and sso_providers.size > 0 %}
|
|
103
|
+
<zl-sso-providers
|
|
104
|
+
data-testid="zitadel-sso-providers"
|
|
105
|
+
providers='{{ sso_providers | json }}'
|
|
106
|
+
label-format="{{ 'sso.continue_with' | t }}"
|
|
107
|
+
{% if loading %}disabled{% endif %}
|
|
108
|
+
></zl-sso-providers>
|
|
109
|
+
{% endif %}
|
|
110
|
+
|
|
101
111
|
{% for a in actions %}
|
|
102
112
|
{% unless a.primary or a.kind == 'back' or a.name == 'passkey' or a.name == 'register' or a.name == 'sign_in' or a.name == 'recover' %}
|
|
103
113
|
<zl-button
|
|
@@ -136,7 +146,17 @@
|
|
|
136
146
|
{% assign sign_in = actions | where: "name", "sign_in" | first %}
|
|
137
147
|
{% if sign_in %}
|
|
138
148
|
<p class="zl-card-nav">
|
|
139
|
-
{
|
|
149
|
+
{% comment %}
|
|
150
|
+
The register step's row reads "Already have an account? Sign in".
|
|
151
|
+
Any other step that offers `sign_in` — the SSO conflict screen, which
|
|
152
|
+
has just told the user they have one — carries its own copy, so the
|
|
153
|
+
lead is dropped and the action's own text is used.
|
|
154
|
+
{% endcomment %}
|
|
155
|
+
{% if sign_in.text_key == 'register.action.sign_in.link' %}
|
|
156
|
+
{{ 'register.action.sign_in.lead' | t }}<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ 'register.action.sign_in.link' | t }}</a>
|
|
157
|
+
{% else %}
|
|
158
|
+
<a href="#" class="zl-card-nav__link" data-action="sign_in" data-testid="zitadel-action-sign_in-link">{{ sign_in.text_key | t }}</a>
|
|
159
|
+
{% endif %}
|
|
140
160
|
</p>
|
|
141
161
|
{% endif %}
|
|
142
162
|
</div>
|
|
@@ -22,20 +22,24 @@
|
|
|
22
22
|
* ours to know, so that pair stays unresolved.
|
|
23
23
|
*/
|
|
24
24
|
/** WCAG 2.2 AA: body text. */
|
|
25
|
-
declare const CONTRAST_AA_TEXT = 4.5;
|
|
25
|
+
export declare const CONTRAST_AA_TEXT = 4.5;
|
|
26
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 = {
|
|
27
|
+
export declare const CONTRAST_AA_LARGE = 3;
|
|
28
|
+
export type BrandingPaletteColors = Record<string, string | undefined>;
|
|
29
|
+
export type ContrastPair = {
|
|
30
30
|
/**
|
|
31
31
|
* Stable join key, `foreground/background`. Downstream attaches a warning to
|
|
32
32
|
* a row and counts issues per section by this id, so it must not change even
|
|
33
33
|
* if the palette keys behind it are renamed.
|
|
34
34
|
*/
|
|
35
|
-
id: string;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
id: string;
|
|
36
|
+
/** Palette key carrying the foreground colour. */
|
|
37
|
+
foreground: string;
|
|
38
|
+
/** Palette key carrying the surface it sits on. */
|
|
39
|
+
background: string;
|
|
40
|
+
/** The ratio this pair has to reach. */
|
|
41
|
+
minimum: number;
|
|
42
|
+
/** What the reader sees, for a message that names the thing rather than the key. */
|
|
39
43
|
describes: string;
|
|
40
44
|
};
|
|
41
45
|
/**
|
|
@@ -44,9 +48,10 @@ type ContrastPair = {
|
|
|
44
48
|
* surface: `text` lands on both the page and the card, and a brand can pick a
|
|
45
49
|
* background those two disagree about.
|
|
46
50
|
*/
|
|
47
|
-
declare const BRANDING_CONTRAST_PAIRS: readonly ContrastPair[];
|
|
48
|
-
type ContrastFinding = ContrastPair & {
|
|
49
|
-
status: "pass" | "fail" | "unresolved";
|
|
51
|
+
export declare const BRANDING_CONTRAST_PAIRS: readonly ContrastPair[];
|
|
52
|
+
export type ContrastFinding = ContrastPair & {
|
|
53
|
+
status: "pass" | "fail" | "unresolved";
|
|
54
|
+
/** Absent when the pair could not be measured. */
|
|
50
55
|
ratio?: number;
|
|
51
56
|
};
|
|
52
57
|
/**
|
|
@@ -54,8 +59,8 @@ type ContrastFinding = ContrastPair & {
|
|
|
54
59
|
* Both must be opaque: composite them first, since the ratio depends on what
|
|
55
60
|
* a translucent value is sitting on.
|
|
56
61
|
*/
|
|
57
|
-
declare function contrastRatio(a: string, b: string, context?: ContrastContext): number | undefined;
|
|
58
|
-
type ContrastContext = {
|
|
62
|
+
export declare function contrastRatio(a: string, b: string, context?: ContrastContext): number | undefined;
|
|
63
|
+
export type ContrastContext = {
|
|
59
64
|
currentColor?: string;
|
|
60
65
|
};
|
|
61
66
|
/**
|
|
@@ -63,7 +68,7 @@ type ContrastContext = {
|
|
|
63
68
|
* rather than reported: an omitted key takes the maintained default for that
|
|
64
69
|
* side, which the design system already holds to this bar.
|
|
65
70
|
*/
|
|
66
|
-
declare function checkPaletteContrast(palette: BrandingPaletteColors | undefined): ContrastFinding[];
|
|
71
|
+
export declare function checkPaletteContrast(palette: BrandingPaletteColors | undefined): ContrastFinding[];
|
|
67
72
|
/**
|
|
68
73
|
* The warnings a revision carries, in the shape the console renders and the
|
|
69
74
|
* API will later return: one entry per failing pair per side, and nothing for
|
|
@@ -72,7 +77,7 @@ declare function checkPaletteContrast(palette: BrandingPaletteColors | undefined
|
|
|
72
77
|
*
|
|
73
78
|
* Warning-only by decision: a failing pair never blocks saving or publishing.
|
|
74
79
|
*/
|
|
75
|
-
declare function contrastIssues(branding: {
|
|
80
|
+
export declare function contrastIssues(branding: {
|
|
76
81
|
theme?: {
|
|
77
82
|
light?: {
|
|
78
83
|
palette?: BrandingPaletteColors;
|
|
@@ -82,14 +87,17 @@ declare function contrastIssues(branding: {
|
|
|
82
87
|
};
|
|
83
88
|
};
|
|
84
89
|
}): ContrastIssue[];
|
|
85
|
-
type ContrastIssue = {
|
|
86
|
-
theme: "light" | "dark";
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
export type ContrastIssue = {
|
|
91
|
+
theme: "light" | "dark";
|
|
92
|
+
/** Stable pair id, e.g. `on_primary/primary`. */
|
|
93
|
+
pair: string;
|
|
94
|
+
/** Measured ratio, rounded to two decimals. */
|
|
95
|
+
ratio: number;
|
|
96
|
+
/** The ratio this pair had to reach. */
|
|
89
97
|
required: number;
|
|
90
98
|
};
|
|
91
99
|
/** Both sides of a revision, keyed by the side each finding belongs to. */
|
|
92
|
-
declare function checkBrandingContrast(branding: {
|
|
100
|
+
export declare function checkBrandingContrast(branding: {
|
|
93
101
|
theme?: {
|
|
94
102
|
light?: {
|
|
95
103
|
palette?: BrandingPaletteColors;
|
|
@@ -103,5 +111,4 @@ declare function checkBrandingContrast(branding: {
|
|
|
103
111
|
dark: ContrastFinding[];
|
|
104
112
|
};
|
|
105
113
|
//#endregion
|
|
106
|
-
export { BRANDING_CONTRAST_PAIRS, BrandingPaletteColors, CONTRAST_AA_LARGE, CONTRAST_AA_TEXT, ContrastContext, ContrastFinding, ContrastIssue, ContrastPair, checkBrandingContrast, checkPaletteContrast, contrastIssues, contrastRatio };
|
|
107
114
|
//# sourceMappingURL=branding-contrast.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branding-contrast.d.mts","names":[],"sources":["../src/branding-contrast.ts"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"branding-contrast.d.mts","names":[],"sources":["../src/branding-contrast.ts"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;qBAwBa;;qBAEA;YAID,wBAAwB;YAExB;;;;;;EAMV;;EAEA;;EAEA;;EAEA;;EAEA;;;;;;;;qBASW,kCAAkC;YAuFnC,kBAAkB;EAC5B;;EAEA;;;;;;;wBAQc,cAAc,WAAW,WAAW,UAAU;YAQlD;EAAoB;;;;;;;wBAehB,qBACd,SAAS,oCACR;;;;;;;;;wBA0Ea,eAAe;EAC7B;IACE;MAAU,UAAU;;IACpB;MAAS,UAAU;;;IAEnB;YAiBQ;EACV;;EAEA;;EAEA;;EAEA;;;wBAIc,sBAAsB;EACpC;IACE;MAAU,UAAU;;IACpB;MAAS,UAAU;;;;EAEjB,OAAO;EAAmB,MAAM"}
|
|
@@ -137,7 +137,7 @@ function ratioOf(a, b) {
|
|
|
137
137
|
*/
|
|
138
138
|
function checkPaletteContrast(palette) {
|
|
139
139
|
if (!palette) return [];
|
|
140
|
-
const context = { currentColor: palette
|
|
140
|
+
const context = { currentColor: palette.text };
|
|
141
141
|
const findings = [];
|
|
142
142
|
for (const pair of BRANDING_CONTRAST_PAIRS) {
|
|
143
143
|
const foreground = palette[pair.foreground];
|
|
@@ -1 +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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"}
|
|
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 {\n id: \"text/background\",\n foreground: \"text\",\n background: \"background\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"body text on the page\",\n },\n {\n id: \"text/surface\",\n foreground: \"text\",\n background: \"surface\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"body text on the card\",\n },\n {\n id: \"text_muted/surface\",\n foreground: \"text_muted\",\n background: \"surface\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"secondary text on the card\",\n },\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 {\n id: \"text/muted\",\n foreground: \"text\",\n background: \"muted\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"the secondary button's label\",\n },\n {\n id: \"text_muted/muted\",\n foreground: \"text_muted\",\n background: \"muted\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"supporting text in an alert\",\n },\n {\n id: \"on_primary/primary\",\n foreground: \"on_primary\",\n background: \"primary\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"the primary button's label\",\n },\n {\n id: \"link/surface\",\n foreground: \"link\",\n background: \"surface\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"links on the card\",\n },\n {\n id: \"error/surface\",\n foreground: \"error\",\n background: \"surface\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"error text on the card\",\n },\n {\n id: \"success/surface\",\n foreground: \"success\",\n background: \"surface\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"success text on the card\",\n },\n {\n id: \"warning/surface\",\n foreground: \"warning\",\n background: \"surface\",\n minimum: CONTRAST_AA_TEXT,\n describes: \"warning text on the card\",\n },\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 {\n id: \"border/surface\",\n foreground: \"border\",\n background: \"surface\",\n minimum: CONTRAST_AA_LARGE,\n describes: \"the outline of inputs\",\n },\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(\n palette: BrandingPaletteColors | undefined,\n): 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?: {\n light?: { palette?: BrandingPaletteColors };\n dark?: { palette?: BrandingPaletteColors };\n };\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?: {\n light?: { palette?: BrandingPaletteColors };\n dark?: { palette?: BrandingPaletteColors };\n };\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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"}
|
package/dist/branding-url.d.mts
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
* become a loopback host in one JavaScript layer while the Go save gate rejects
|
|
9
9
|
* them.
|
|
10
10
|
*/
|
|
11
|
-
declare function isCanonicalLoopbackHttpUrl(value: string): boolean;
|
|
11
|
+
export declare function isCanonicalLoopbackHttpUrl(value: string): boolean;
|
|
12
12
|
//#endregion
|
|
13
|
-
export { isCanonicalLoopbackHttpUrl };
|
|
14
13
|
//# sourceMappingURL=branding-url.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branding-url.d.mts","names":[],"sources":["../src/branding-url.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"branding-url.d.mts","names":[],"sources":["../src/branding-url.ts"],"mappings":";;;;;;;;;;wBAYgB,2BAA2B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branding-url.mjs","names":[],"sources":["../src/branding-url.ts"],"sourcesContent":["const CANONICAL_LOOPBACK_HTTP_URL =\n /^http:\\/\\/(?:localhost|127\\.(?:0|[1-9][0-9]{0,2})\\.(?:0|[1-9][0-9]{0,2})\\.(?:0|[1-9][0-9]{0,2})|\\[::1\\])(?::[0-9]+)?(?:[/?#]|$)/i;\n\n/**\n * Reports whether value is an absolute HTTP URL using one of the canonical\n * loopback spellings shared by the config and component gates.\n *\n * The raw-pattern check deliberately runs before WHATWG URL normalisation so\n * shorthand, integer, hexadecimal, leading-zero, and userinfo forms cannot\n * become a loopback host in one JavaScript layer while the Go save gate rejects\n * them.\n */\nexport function isCanonicalLoopbackHttpUrl(value: string): boolean {\n if (!CANONICAL_LOOPBACK_HTTP_URL.test(value)) return false;\n try {\n return new URL(value).protocol === \"http:\";\n } catch {\n return false;\n }\n}\n"],"mappings":";AAAA,MAAM,8BACJ;;;;;;;;;;AAWF,SAAgB,2BAA2B,OAAwB;
|
|
1
|
+
{"version":3,"file":"branding-url.mjs","names":[],"sources":["../src/branding-url.ts"],"sourcesContent":["const CANONICAL_LOOPBACK_HTTP_URL =\n /^http:\\/\\/(?:localhost|127\\.(?:0|[1-9][0-9]{0,2})\\.(?:0|[1-9][0-9]{0,2})\\.(?:0|[1-9][0-9]{0,2})|\\[::1\\])(?::[0-9]+)?(?:[/?#]|$)/i;\n\n/**\n * Reports whether value is an absolute HTTP URL using one of the canonical\n * loopback spellings shared by the config and component gates.\n *\n * The raw-pattern check deliberately runs before WHATWG URL normalisation so\n * shorthand, integer, hexadecimal, leading-zero, and userinfo forms cannot\n * become a loopback host in one JavaScript layer while the Go save gate rejects\n * them.\n */\nexport function isCanonicalLoopbackHttpUrl(value: string): boolean {\n if (!CANONICAL_LOOPBACK_HTTP_URL.test(value)) return false;\n try {\n return new URL(value).protocol === \"http:\";\n } catch {\n return false;\n }\n}\n"],"mappings":";AAAA,MAAM,8BACJ;;;;;;;;;;AAWF,SAAgB,2BAA2B,OAAwB;CACjE,IAAI,CAAC,4BAA4B,KAAK,KAAK,GAAG,OAAO;CACrD,IAAI;EACF,OAAO,IAAI,IAAI,KAAK,CAAC,CAAC,aAAa;CACrC,QAAQ;EACN,OAAO;CACT;AACF"}
|
package/dist/css-color.d.mts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
//#region src/css-color.d.ts
|
|
2
2
|
/** An sRGB colour, channels 0-255, alpha 0-1. */
|
|
3
|
-
type Rgba = {
|
|
3
|
+
export type Rgba = {
|
|
4
4
|
r: number;
|
|
5
5
|
g: number;
|
|
6
6
|
b: number;
|
|
7
7
|
a: number;
|
|
8
8
|
};
|
|
9
|
-
type ResolveContext = {
|
|
9
|
+
export type ResolveContext = {
|
|
10
10
|
/**
|
|
11
11
|
* What `currentColor` inherits at the point this value is painted. Left
|
|
12
12
|
* unset, `currentColor` stays unresolved rather than being guessed at.
|
|
@@ -18,19 +18,14 @@ type ResolveContext = {
|
|
|
18
18
|
* that is malformed, or for `currentColor` with no context to resolve it
|
|
19
19
|
* against.
|
|
20
20
|
*/
|
|
21
|
-
declare function parseCssColor(input: string, context?: ResolveContext): Rgba | undefined;
|
|
21
|
+
export declare function parseCssColor(input: string, context?: ResolveContext): Rgba | undefined;
|
|
22
22
|
/**
|
|
23
23
|
* Composite a colour over an opaque backdrop. A translucent foreground is what
|
|
24
24
|
* the reader sees blended with what is behind it, so a ratio measured against
|
|
25
25
|
* the raw value would describe a colour nobody sees.
|
|
26
26
|
*/
|
|
27
|
-
declare function compositeOver(color: Rgba, backdrop: Rgba): Rgba;
|
|
27
|
+
export declare function compositeOver(color: Rgba, backdrop: Rgba): Rgba;
|
|
28
28
|
/** WCAG 2.2 relative luminance of an opaque sRGB colour. */
|
|
29
|
-
declare function relativeLuminance({
|
|
30
|
-
r,
|
|
31
|
-
g,
|
|
32
|
-
b
|
|
33
|
-
}: Rgba): number;
|
|
29
|
+
export declare function relativeLuminance({ r, g, b }: Rgba): number;
|
|
34
30
|
//#endregion
|
|
35
|
-
export { ResolveContext, Rgba, compositeOver, parseCssColor, relativeLuminance };
|
|
36
31
|
//# sourceMappingURL=css-color.d.mts.map
|
package/dist/css-color.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"css-color.d.mts","names":[],"sources":["../src/css-color.ts"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"css-color.d.mts","names":[],"sources":["../src/css-color.ts"],"mappings":";;YA8BY;EAAS;EAAW;EAAW;EAAW;;YAE1C;;;;;EAKV;;;;;;;wBA0Cc,cAAc,eAAe,UAAS,iBAAsB;;;;;;wBAiL5D,cAAc,OAAO,MAAM,UAAU,OAAO;;wBAY5C,oBAAoB,GAAG,GAAG,KAAK"}
|
package/dist/css-color.mjs
CHANGED
package/dist/css-color.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"css-color.mjs","names":[],"sources":["../src/css-color.ts"],"sourcesContent":["/**\n * Resolves the colour forms a branding palette accepts to sRGB, so a contrast\n * ratio can be measured without a rendering engine.\n *\n * `branding-css.ts` says which forms are *storable*; this says what they\n * *are*. Culori covers the plain forms and `color()`. Two forms it cannot\n * take alone are handled here, because both need something only this codebase\n * knows:\n *\n * - `color-mix()` is a recipe rather than a colour. Culori has the colour\n * spaces and the interpolation; the mixing rules (percentage normalisation,\n * the alpha carried by a pair summing under 100%, the hue strategy) come\n * from CSS Color 5 and live here.\n * - `currentColor` means the inherited text colour at the point of use. In\n * general CSS that is unknowable without rendering. In a branding palette it\n * is not: we own the surface, so the caller says which colour the token\n * would inherit — for anything drawn on the card, the side's own `text`.\n */\nimport {\n converter,\n fixupHueDecreasing,\n fixupHueIncreasing,\n fixupHueLonger,\n fixupHueShorter,\n interpolateWithPremultipliedAlpha,\n parse,\n type Color,\n} from \"culori\";\n\n/** An sRGB colour, channels 0-255, alpha 0-1. */\nexport type Rgba = { r: number; g: number; b: number; a: number };\n\nexport type ResolveContext = {\n /**\n * What `currentColor` inherits at the point this value is painted. Left\n * unset, `currentColor` stays unresolved rather than being guessed at.\n */\n currentColor?: string;\n};\n\nconst toRgb = converter(\"rgb\");\n\n/** Interpolation spaces CSS names, mapped to culori's modes. */\nconst MIX_SPACES: Record<string, string> = {\n srgb: \"rgb\",\n \"srgb-linear\": \"lrgb\",\n hsl: \"hsl\",\n hwb: \"hwb\",\n lab: \"lab\",\n lch: \"lch\",\n oklab: \"oklab\",\n oklch: \"oklch\",\n xyz: \"xyz65\",\n \"xyz-d50\": \"xyz50\",\n \"xyz-d65\": \"xyz65\",\n \"display-p3\": \"p3\",\n \"a98-rgb\": \"a98\",\n \"prophoto-rgb\": \"prophoto\",\n rec2020: \"rec2020\",\n};\n\n/** Only a polar space has a hue to interpolate, so only these take a strategy. */\nconst POLAR_SPACES = new Set([\"hsl\", \"hwb\", \"lch\", \"oklch\"]);\n\nconst HUE_FIXUPS: Record<string, (hues: number[]) => number[]> = {\n shorter: fixupHueShorter,\n longer: fixupHueLonger,\n increasing: fixupHueIncreasing,\n decreasing: fixupHueDecreasing,\n};\n\n/** Depth guard: the storage gate bounds nesting at four, so this is slack. */\nconst MAX_DEPTH = 6;\n\n/**\n * Parse any colour form a palette accepts. Returns `undefined` for a value\n * that is malformed, or for `currentColor` with no context to resolve it\n * against.\n */\nexport function parseCssColor(input: string, context: ResolveContext = {}): Rgba | undefined {\n return resolve(input, context, 0);\n}\n\nfunction resolve(input: string, context: ResolveContext, depth: number): Rgba | undefined {\n if (depth > MAX_DEPTH) return undefined;\n const value = input.trim();\n if (value === \"\") return undefined;\n\n if (value.toLowerCase() === \"currentcolor\") {\n if (!context.currentColor) return undefined;\n // The inherited value can itself be any accepted form, but it cannot be\n // `currentColor` again: that would be a colour defined as itself.\n if (context.currentColor.trim().toLowerCase() === \"currentcolor\") return undefined;\n return resolve(context.currentColor, context, depth + 1);\n }\n\n if (value.toLowerCase().startsWith(\"color-mix(\")) {\n return resolveMix(value.slice(\"color-mix(\".length, -1), context, depth);\n }\n\n const parsed = parse(value);\n if (!parsed) return undefined;\n return fromCulori(parsed);\n}\n\n/**\n * `in <space> [<hue> hue], <colour> [<percent>], <colour> [<percent>]`.\n *\n * Percentages follow CSS Color 5: both omitted is an even mix; one given\n * makes the other its complement; a pair that sums to less than 100% mixes in\n * that proportion and multiplies the result's alpha by the sum, which is what\n * makes `color-mix(in srgb, red 20%, blue 20%)` translucent rather than an\n * even mix.\n */\nfunction resolveMix(body: string, context: ResolveContext, depth: number): Rgba | undefined {\n const parts = splitTopLevel(body);\n if (parts.length !== 3) return undefined;\n const [spec, first, second] = parts as [string, string, string];\n\n const specTokens = spec.trim().toLowerCase().split(/\\s+/);\n if (specTokens[0] !== \"in\") return undefined;\n const space = specTokens[1] ?? \"\";\n const mode = MIX_SPACES[space];\n if (!mode) return undefined;\n\n let fixup = fixupHueShorter;\n if (specTokens.length > 2) {\n // `in oklch longer hue` — the trailing `hue` keyword is required with a\n // method, and a rectangular space has no hue to interpolate, so CSS\n // rejects the method there and so do we.\n if (!POLAR_SPACES.has(space)) return undefined;\n if (specTokens.length !== 4 || specTokens[3] !== \"hue\") return undefined;\n const named = HUE_FIXUPS[specTokens[2] as string];\n if (!named) return undefined;\n fixup = named;\n }\n\n const a = splitColorAndPercent(first);\n const b = splitColorAndPercent(second);\n if (!a || !b) return undefined;\n\n const [p1, p2] = normalisePercentages(a.percent, b.percent);\n const sum = p1 + p2;\n if (sum <= 0) return undefined;\n const alphaScale = sum < 100 ? sum / 100 : 1;\n const weight = p2 / sum;\n\n const c1 = resolve(a.color, context, depth + 1);\n const c2 = resolve(b.color, context, depth + 1);\n if (!c1 || !c2) return undefined;\n\n // CSS mixes in premultiplied alpha: mixing red with transparent has to keep\n // red's hue and lose opacity, not slide towards transparent black.\n const mixed = interpolateWithPremultipliedAlpha([toCulori(c1), toCulori(c2)], mode as never, {\n h: { fixup },\n } as never)(weight);\n const rgba = fromCulori(mixed);\n if (!rgba) return undefined;\n return { ...rgba, a: clamp(rgba.a * alphaScale, 0, 1) };\n}\n\nfunction normalisePercentages(first: number | undefined, second: number | undefined): [number, number] {\n if (first === undefined && second === undefined) return [50, 50];\n if (first === undefined) return [100 - (second as number), second as number];\n if (second === undefined) return [first, 100 - first];\n return [first, second];\n}\n\n/** Split on commas that are not inside parentheses. */\nfunction splitTopLevel(input: string): string[] {\n const parts: string[] = [];\n let depth = 0;\n let start = 0;\n for (let i = 0; i < input.length; i += 1) {\n const char = input[i];\n if (char === \"(\") depth += 1;\n else if (char === \")\") depth -= 1;\n else if (char === \",\" && depth === 0) {\n parts.push(input.slice(start, i));\n start = i + 1;\n }\n }\n parts.push(input.slice(start));\n return parts;\n}\n\n/**\n * `#fff 30%` or `30% #fff` — CSS allows the percentage on either side of the\n * colour. Only a percentage outside any parentheses is the mix stop: the `100%`\n * in `hsl(0 100% 50%)` belongs to the colour, not to the mix.\n */\nfunction splitColorAndPercent(input: string): { color: string; percent?: number } | undefined {\n const trimmed = input.trim();\n if (trimmed === \"\") return undefined;\n\n let depth = 0;\n for (let i = 0; i < trimmed.length; i += 1) {\n const char = trimmed[i];\n if (char === \"(\") depth += 1;\n else if (char === \")\") depth -= 1;\n else if (char === \"%\" && depth === 0) {\n const start = startOfNumber(trimmed, i);\n if (start === undefined) return undefined;\n const percent = Number.parseFloat(trimmed.slice(start, i));\n // CSS Color 5 bounds a mix stop to 0-100%: beyond that a mix would be an\n // extrapolation, which culori would happily compute and a browser refuses.\n if (!Number.isFinite(percent) || percent < 0 || percent > 100) return undefined;\n const color = (trimmed.slice(0, start) + trimmed.slice(i + 1)).trim();\n if (color === \"\") return undefined;\n return { color, percent };\n }\n }\n return { color: trimmed };\n}\n\n/** Walk back over the number preceding a `%`, returning where it starts. */\nfunction startOfNumber(input: string, percentIndex: number): number | undefined {\n let i = percentIndex - 1;\n while (i >= 0 && /[\\d.]/.test(input[i] as string)) i -= 1;\n if (i >= 0 && input[i] === \"-\") i -= 1;\n const start = i + 1;\n if (start === percentIndex) return undefined;\n // A stop is its own token: `50%` is one, the `0%` of `hsl(0 0% 50%)` is not\n // reachable here because that sits inside parentheses.\n if (start > 0 && !/\\s/.test(input[start - 1] as string)) return undefined;\n return start;\n}\n\nfunction fromCulori(color: Color): Rgba | undefined {\n const rgb = toRgb(color);\n if (!rgb) return undefined;\n const channel = (value: number | undefined): number => clamp((value ?? 0) * 255, 0, 255);\n return { r: channel(rgb.r), g: channel(rgb.g), b: channel(rgb.b), a: clamp(rgb.alpha ?? 1, 0, 1) };\n}\n\nfunction toCulori({ r, g, b, a }: Rgba): Color {\n return { mode: \"rgb\", r: r / 255, g: g / 255, b: b / 255, alpha: a };\n}\n\n/**\n * Composite a colour over an opaque backdrop. A translucent foreground is what\n * the reader sees blended with what is behind it, so a ratio measured against\n * the raw value would describe a colour nobody sees.\n */\nexport function compositeOver(color: Rgba, backdrop: Rgba): Rgba {\n if (color.a >= 1) return color;\n const a = color.a;\n return {\n r: color.r * a + backdrop.r * (1 - a),\n g: color.g * a + backdrop.g * (1 - a),\n b: color.b * a + backdrop.b * (1 - a),\n a: 1,\n };\n}\n\n/** WCAG 2.2 relative luminance of an opaque sRGB colour. */\nexport function relativeLuminance({ r, g, b }: Rgba): number {\n return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n}\n\nfunction toLinear(channel: number): number {\n const c = channel / 255;\n return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, value));\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"css-color.mjs","names":[],"sources":["../src/css-color.ts"],"sourcesContent":["/**\n * Resolves the colour forms a branding palette accepts to sRGB, so a contrast\n * ratio can be measured without a rendering engine.\n *\n * `branding-css.ts` says which forms are *storable*; this says what they\n * *are*. Culori covers the plain forms and `color()`. Two forms it cannot\n * take alone are handled here, because both need something only this codebase\n * knows:\n *\n * - `color-mix()` is a recipe rather than a colour. Culori has the colour\n * spaces and the interpolation; the mixing rules (percentage normalisation,\n * the alpha carried by a pair summing under 100%, the hue strategy) come\n * from CSS Color 5 and live here.\n * - `currentColor` means the inherited text colour at the point of use. In\n * general CSS that is unknowable without rendering. In a branding palette it\n * is not: we own the surface, so the caller says which colour the token\n * would inherit — for anything drawn on the card, the side's own `text`.\n */\nimport {\n converter,\n fixupHueDecreasing,\n fixupHueIncreasing,\n fixupHueLonger,\n fixupHueShorter,\n interpolateWithPremultipliedAlpha,\n parse,\n type Color,\n} from \"culori\";\n\n/** An sRGB colour, channels 0-255, alpha 0-1. */\nexport type Rgba = { r: number; g: number; b: number; a: number };\n\nexport type ResolveContext = {\n /**\n * What `currentColor` inherits at the point this value is painted. Left\n * unset, `currentColor` stays unresolved rather than being guessed at.\n */\n currentColor?: string;\n};\n\nconst toRgb = converter(\"rgb\");\n\n/** Interpolation spaces CSS names, mapped to culori's modes. */\nconst MIX_SPACES: Record<string, string> = {\n srgb: \"rgb\",\n \"srgb-linear\": \"lrgb\",\n hsl: \"hsl\",\n hwb: \"hwb\",\n lab: \"lab\",\n lch: \"lch\",\n oklab: \"oklab\",\n oklch: \"oklch\",\n xyz: \"xyz65\",\n \"xyz-d50\": \"xyz50\",\n \"xyz-d65\": \"xyz65\",\n \"display-p3\": \"p3\",\n \"a98-rgb\": \"a98\",\n \"prophoto-rgb\": \"prophoto\",\n rec2020: \"rec2020\",\n};\n\n/** Only a polar space has a hue to interpolate, so only these take a strategy. */\nconst POLAR_SPACES = new Set([\"hsl\", \"hwb\", \"lch\", \"oklch\"]);\n\nconst HUE_FIXUPS: Record<string, (hues: number[]) => number[]> = {\n shorter: fixupHueShorter,\n longer: fixupHueLonger,\n increasing: fixupHueIncreasing,\n decreasing: fixupHueDecreasing,\n};\n\n/** Depth guard: the storage gate bounds nesting at four, so this is slack. */\nconst MAX_DEPTH = 6;\n\n/**\n * Parse any colour form a palette accepts. Returns `undefined` for a value\n * that is malformed, or for `currentColor` with no context to resolve it\n * against.\n */\nexport function parseCssColor(input: string, context: ResolveContext = {}): Rgba | undefined {\n return resolve(input, context, 0);\n}\n\nfunction resolve(input: string, context: ResolveContext, depth: number): Rgba | undefined {\n if (depth > MAX_DEPTH) return undefined;\n const value = input.trim();\n if (value === \"\") return undefined;\n\n if (value.toLowerCase() === \"currentcolor\") {\n if (!context.currentColor) return undefined;\n // The inherited value can itself be any accepted form, but it cannot be\n // `currentColor` again: that would be a colour defined as itself.\n if (context.currentColor.trim().toLowerCase() === \"currentcolor\") return undefined;\n return resolve(context.currentColor, context, depth + 1);\n }\n\n if (value.toLowerCase().startsWith(\"color-mix(\")) {\n return resolveMix(value.slice(\"color-mix(\".length, -1), context, depth);\n }\n\n const parsed = parse(value);\n if (!parsed) return undefined;\n return fromCulori(parsed);\n}\n\n/**\n * `in <space> [<hue> hue], <colour> [<percent>], <colour> [<percent>]`.\n *\n * Percentages follow CSS Color 5: both omitted is an even mix; one given\n * makes the other its complement; a pair that sums to less than 100% mixes in\n * that proportion and multiplies the result's alpha by the sum, which is what\n * makes `color-mix(in srgb, red 20%, blue 20%)` translucent rather than an\n * even mix.\n */\nfunction resolveMix(body: string, context: ResolveContext, depth: number): Rgba | undefined {\n const parts = splitTopLevel(body);\n if (parts.length !== 3) return undefined;\n const [spec, first, second] = parts as [string, string, string];\n\n const specTokens = spec.trim().toLowerCase().split(/\\s+/);\n if (specTokens[0] !== \"in\") return undefined;\n const space = specTokens[1] ?? \"\";\n const mode = MIX_SPACES[space];\n if (!mode) return undefined;\n\n let fixup = fixupHueShorter;\n if (specTokens.length > 2) {\n // `in oklch longer hue` — the trailing `hue` keyword is required with a\n // method, and a rectangular space has no hue to interpolate, so CSS\n // rejects the method there and so do we.\n if (!POLAR_SPACES.has(space)) return undefined;\n if (specTokens.length !== 4 || specTokens[3] !== \"hue\") return undefined;\n const named = HUE_FIXUPS[specTokens[2] as string];\n if (!named) return undefined;\n fixup = named;\n }\n\n const a = splitColorAndPercent(first);\n const b = splitColorAndPercent(second);\n if (!a || !b) return undefined;\n\n const [p1, p2] = normalisePercentages(a.percent, b.percent);\n const sum = p1 + p2;\n if (sum <= 0) return undefined;\n const alphaScale = sum < 100 ? sum / 100 : 1;\n const weight = p2 / sum;\n\n const c1 = resolve(a.color, context, depth + 1);\n const c2 = resolve(b.color, context, depth + 1);\n if (!c1 || !c2) return undefined;\n\n // CSS mixes in premultiplied alpha: mixing red with transparent has to keep\n // red's hue and lose opacity, not slide towards transparent black.\n const mixed = interpolateWithPremultipliedAlpha(\n [toCulori(c1), toCulori(c2)],\n mode as never,\n {\n h: { fixup },\n } as never,\n )(weight);\n const rgba = fromCulori(mixed);\n if (!rgba) return undefined;\n return { ...rgba, a: clamp(rgba.a * alphaScale, 0, 1) };\n}\n\nfunction normalisePercentages(\n first: number | undefined,\n second: number | undefined,\n): [number, number] {\n if (first === undefined && second === undefined) return [50, 50];\n if (first === undefined) return [100 - (second as number), second as number];\n if (second === undefined) return [first, 100 - first];\n return [first, second];\n}\n\n/** Split on commas that are not inside parentheses. */\nfunction splitTopLevel(input: string): string[] {\n const parts: string[] = [];\n let depth = 0;\n let start = 0;\n for (let i = 0; i < input.length; i += 1) {\n const char = input[i];\n if (char === \"(\") depth += 1;\n else if (char === \")\") depth -= 1;\n else if (char === \",\" && depth === 0) {\n parts.push(input.slice(start, i));\n start = i + 1;\n }\n }\n parts.push(input.slice(start));\n return parts;\n}\n\n/**\n * `#fff 30%` or `30% #fff` — CSS allows the percentage on either side of the\n * colour. Only a percentage outside any parentheses is the mix stop: the `100%`\n * in `hsl(0 100% 50%)` belongs to the colour, not to the mix.\n */\nfunction splitColorAndPercent(input: string): { color: string; percent?: number } | undefined {\n const trimmed = input.trim();\n if (trimmed === \"\") return undefined;\n\n let depth = 0;\n for (let i = 0; i < trimmed.length; i += 1) {\n const char = trimmed[i];\n if (char === \"(\") depth += 1;\n else if (char === \")\") depth -= 1;\n else if (char === \"%\" && depth === 0) {\n const start = startOfNumber(trimmed, i);\n if (start === undefined) return undefined;\n const percent = Number.parseFloat(trimmed.slice(start, i));\n // CSS Color 5 bounds a mix stop to 0-100%: beyond that a mix would be an\n // extrapolation, which culori would happily compute and a browser refuses.\n if (!Number.isFinite(percent) || percent < 0 || percent > 100) return undefined;\n const color = (trimmed.slice(0, start) + trimmed.slice(i + 1)).trim();\n if (color === \"\") return undefined;\n return { color, percent };\n }\n }\n return { color: trimmed };\n}\n\n/** Walk back over the number preceding a `%`, returning where it starts. */\nfunction startOfNumber(input: string, percentIndex: number): number | undefined {\n let i = percentIndex - 1;\n while (i >= 0 && /[\\d.]/.test(input[i] as string)) i -= 1;\n if (i >= 0 && input[i] === \"-\") i -= 1;\n const start = i + 1;\n if (start === percentIndex) return undefined;\n // A stop is its own token: `50%` is one, the `0%` of `hsl(0 0% 50%)` is not\n // reachable here because that sits inside parentheses.\n if (start > 0 && !/\\s/.test(input[start - 1] as string)) return undefined;\n return start;\n}\n\nfunction fromCulori(color: Color): Rgba | undefined {\n const rgb = toRgb(color);\n if (!rgb) return undefined;\n const channel = (value: number | undefined): number => clamp((value ?? 0) * 255, 0, 255);\n return {\n r: channel(rgb.r),\n g: channel(rgb.g),\n b: channel(rgb.b),\n a: clamp(rgb.alpha ?? 1, 0, 1),\n };\n}\n\nfunction toCulori({ r, g, b, a }: Rgba): Color {\n return { mode: \"rgb\", r: r / 255, g: g / 255, b: b / 255, alpha: a };\n}\n\n/**\n * Composite a colour over an opaque backdrop. A translucent foreground is what\n * the reader sees blended with what is behind it, so a ratio measured against\n * the raw value would describe a colour nobody sees.\n */\nexport function compositeOver(color: Rgba, backdrop: Rgba): Rgba {\n if (color.a >= 1) return color;\n const a = color.a;\n return {\n r: color.r * a + backdrop.r * (1 - a),\n g: color.g * a + backdrop.g * (1 - a),\n b: color.b * a + backdrop.b * (1 - a),\n a: 1,\n };\n}\n\n/** WCAG 2.2 relative luminance of an opaque sRGB colour. */\nexport function relativeLuminance({ r, g, b }: Rgba): number {\n return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\n}\n\nfunction toLinear(channel: number): number {\n const c = channel / 255;\n return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, value));\n}\n"],"mappings":"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"}
|