@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.
Files changed (62) hide show
  1. package/defaults/README-branding.md +26 -34
  2. package/defaults/branding/centered/login.liquid +31 -11
  3. package/defaults/branding/minimal/login.liquid +30 -10
  4. package/dist/branding-contrast.d.mts +28 -21
  5. package/dist/branding-contrast.d.mts.map +1 -1
  6. package/dist/branding-contrast.mjs +1 -1
  7. package/dist/branding-contrast.mjs.map +1 -1
  8. package/dist/branding-url.d.mts +1 -2
  9. package/dist/branding-url.d.mts.map +1 -1
  10. package/dist/branding-url.mjs.map +1 -1
  11. package/dist/css-color.d.mts +5 -10
  12. package/dist/css-color.d.mts.map +1 -1
  13. package/dist/css-color.mjs +1 -1
  14. package/dist/css-color.mjs.map +1 -1
  15. package/dist/{defaults-BxGCGdqS.d.mts → defaults-BtKIcWxW.d.mts} +21 -14
  16. package/dist/defaults-BtKIcWxW.d.mts.map +1 -0
  17. package/dist/{defaults-DV6hY2KT.mjs → defaults-COLBFU3S.mjs} +17 -18
  18. package/dist/defaults-COLBFU3S.mjs.map +1 -0
  19. package/dist/defaults.d.mts +1 -1
  20. package/dist/defaults.mjs +1 -1
  21. package/dist/idp/index.d.mts +2 -0
  22. package/dist/idp/index.mjs +2 -0
  23. package/dist/idp-ByzXQN85.mjs +207 -0
  24. package/dist/idp-ByzXQN85.mjs.map +1 -0
  25. package/dist/idp-connection-CHxGm1Wr.mjs +324 -0
  26. package/dist/idp-connection-CHxGm1Wr.mjs.map +1 -0
  27. package/dist/index-Cuxu-zQD.d.mts +210 -0
  28. package/dist/index-Cuxu-zQD.d.mts.map +1 -0
  29. package/dist/index.d.mts +4 -3
  30. package/dist/index.mjs +5 -4
  31. package/dist/{meta-schemas-OowgSUfU.mjs → meta-schemas-C03zgFux.mjs} +36 -333
  32. package/dist/meta-schemas-C03zgFux.mjs.map +1 -0
  33. package/dist/meta-schemas.d.mts +5 -6
  34. package/dist/meta-schemas.d.mts.map +1 -1
  35. package/dist/meta-schemas.mjs +1 -1
  36. package/dist/normalize.d.mts +3 -4
  37. package/dist/normalize.d.mts.map +1 -1
  38. package/dist/normalize.mjs.map +1 -1
  39. package/dist/{schemas-7oNU25Qz.mjs → schemas-CUU4Rf8J.mjs} +11 -4
  40. package/dist/schemas-CUU4Rf8J.mjs.map +1 -0
  41. package/dist/schemas.d.mts +361 -9
  42. package/dist/schemas.d.mts.map +1 -1
  43. package/dist/schemas.mjs +2 -2
  44. package/dist/template.d.mts +9 -10
  45. package/dist/template.d.mts.map +1 -1
  46. package/dist/template.mjs +1 -1
  47. package/dist/template.mjs.map +1 -1
  48. package/dist/validate.d.mts +15 -13
  49. package/dist/validate.d.mts.map +1 -1
  50. package/dist/validate.mjs +28 -18
  51. package/dist/validate.mjs.map +1 -1
  52. package/meta-schemas/branding.json +5 -5
  53. package/meta-schemas/flow-definition.json +81 -6
  54. package/meta-schemas/idp-connection.json +6 -6
  55. package/package.json +18 -8
  56. package/defaults/branding/hero/login.liquid +0 -238
  57. package/defaults/branding/split/login.liquid +0 -197
  58. package/defaults/branding/split-right/login.liquid +0 -197
  59. package/dist/defaults-BxGCGdqS.d.mts.map +0 -1
  60. package/dist/defaults-DV6hY2KT.mjs.map +0 -1
  61. package/dist/meta-schemas-OowgSUfU.mjs.map +0 -1
  62. package/dist/schemas-7oNU25Qz.mjs.map +0 -1
@@ -1,12 +1,14 @@
1
1
  # Branding
2
2
 
3
- This directory owns how your login UI looks: `branding.json` (layout preset
4
- and asset URLs) plus `login.liquid`, the LiquidJS template the
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/hero URLs).
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, gate, or submit action your template forgot, so a broken template
26
- still yields a submittable step.
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` and `hero` use `logo_url`; `split`
32
- and `split-right` use both `logo_url` and `hero_url`; `minimal` uses neither
33
- until you add them to `login.liquid`. The `hero` design's brand pane is
34
- editable markup rather than a `hero_url` background. Custom fonts are not
35
- configurable here yet; load them from the embedding page.
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": "split",
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 each asset URL and warns
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 the degrade preset (`centered` or `split`), **not** the complete
60
- design catalog. All named designs (`centered`, `split`, `split-right`, `hero`,
61
- `minimal`) are delivered as templates and map onto those two values. Switch
62
- designs with `zitadel branding eject --design <name>`, don't edit `layout`.
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
- - In the split-family designs (`split`, `split-right`, `hero`) the
65
- `.zl-split__brand` pane is yours: structural HTML plus inline `style=""`
66
- attributes are allowed; `button`, `input`, and `form` tags are stripped —
67
- use `<a>` for landing CTAs. The `hero` design ships a full landing-page
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`, `split`, `split-right`, `hero`, `minimal`; add `--force` to
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 autocomplete_val != '' %}autocomplete="{{ autocomplete_val }}"{% endif %}
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
- {{ '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>
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 autocomplete_val != '' %}autocomplete="{{ autocomplete_val }}"{% endif %}
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
- {{ '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>
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; /** 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. */
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"; /** Absent when the pair could not be measured. */
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"; /** 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. */
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":";;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"}
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["text"] };
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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"}
@@ -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":";;AAYA;;;;;;;;iBAAgB,0BAAA,CAA2B,KAAA"}
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;AACjE,KAAI,CAAC,4BAA4B,KAAK,MAAM,CAAE,QAAO;AACrD,KAAI;AACF,SAAO,IAAI,IAAI,MAAM,CAAC,aAAa;SAC7B;AACN,SAAO"}
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"}
@@ -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
@@ -1 +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"}
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"}
@@ -38,7 +38,7 @@ const MIX_SPACES = {
38
38
  rec2020: "rec2020"
39
39
  };
40
40
  /** Only a polar space has a hue to interpolate, so only these take a strategy. */
41
- const POLAR_SPACES = new Set([
41
+ const POLAR_SPACES = /* @__PURE__ */ new Set([
42
42
  "hsl",
43
43
  "hwb",
44
44
  "lch",
@@ -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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"}