@valancex/style 0.0.0-stage → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +33 -2
  3. package/contract/style-contract-v1.json +207 -0
  4. package/dist/check.d.ts +31 -0
  5. package/dist/check.js +250 -0
  6. package/dist/color.d.ts +17 -0
  7. package/dist/color.js +82 -0
  8. package/dist/contrast.d.ts +5 -0
  9. package/dist/contrast.js +13 -0
  10. package/dist/coverage.d.ts +21 -0
  11. package/dist/coverage.js +60 -0
  12. package/dist/css.d.ts +9 -0
  13. package/dist/css.js +201 -0
  14. package/dist/desktop.d.ts +33 -0
  15. package/dist/desktop.js +112 -0
  16. package/dist/index.d.ts +17 -0
  17. package/dist/index.js +11 -0
  18. package/dist/load.d.ts +5 -0
  19. package/dist/load.js +8 -0
  20. package/dist/primitives.d.ts +2 -0
  21. package/dist/primitives.js +364 -0
  22. package/dist/suggest.d.ts +2 -0
  23. package/dist/suggest.js +22 -0
  24. package/dist/theme.d.ts +5 -0
  25. package/dist/theme.js +129 -0
  26. package/dist/tokens.d.ts +21 -0
  27. package/dist/tokens.js +257 -0
  28. package/dist/types.d.ts +114 -0
  29. package/dist/types.js +3 -0
  30. package/package.json +64 -3
  31. package/schema/dtcg-2025.10/NOTICE.md +6 -0
  32. package/schema/dtcg-2025.10/format/group.json +54 -0
  33. package/schema/dtcg-2025.10/format/groupOrToken.json +14 -0
  34. package/schema/dtcg-2025.10/format/token.json +450 -0
  35. package/schema/dtcg-2025.10/format/tokenType.json +22 -0
  36. package/schema/dtcg-2025.10/format/values/border.json +44 -0
  37. package/schema/dtcg-2025.10/format/values/color.json +570 -0
  38. package/schema/dtcg-2025.10/format/values/cubicBezier.json +56 -0
  39. package/schema/dtcg-2025.10/format/values/dimension.json +34 -0
  40. package/schema/dtcg-2025.10/format/values/duration.json +34 -0
  41. package/schema/dtcg-2025.10/format/values/fontFamily.json +33 -0
  42. package/schema/dtcg-2025.10/format/values/fontWeight.json +38 -0
  43. package/schema/dtcg-2025.10/format/values/gradient.json +52 -0
  44. package/schema/dtcg-2025.10/format/values/number.json +7 -0
  45. package/schema/dtcg-2025.10/format/values/shadow.json +102 -0
  46. package/schema/dtcg-2025.10/format/values/strokeStyle.json +63 -0
  47. package/schema/dtcg-2025.10/format/values/transition.json +44 -0
  48. package/schema/dtcg-2025.10/format/values/typography.json +72 -0
  49. package/schema/dtcg-2025.10/format.json +105 -0
  50. package/schema/recipe-v1.schema.json +43 -0
  51. package/schema/style-contract-v1.schema.json +171 -0
  52. package/schema/theme-v1.schema.json +78 -0
  53. package/themes/default/recipes/button.json +87 -0
  54. package/themes/default/recipes/checkbox.json +26 -0
  55. package/themes/default/recipes/column.json +10 -0
  56. package/themes/default/recipes/heading.json +11 -0
  57. package/themes/default/recipes/row.json +10 -0
  58. package/themes/default/recipes/text.json +12 -0
  59. package/themes/default/recipes/textfield.json +37 -0
  60. package/themes/default/theme.json +61 -0
  61. package/themes/default/tokens/base.tokens.json +193 -0
  62. package/themes/default/tokens/contrast.tokens.json +127 -0
  63. package/themes/default/tokens/dark.tokens.json +127 -0
  64. package/themes/default/tokens/light.tokens.json +127 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Miller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # @valancex/style
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The style contract of VALANCE (Bet 6; decisions S1 to S8): what a theme may depend on, as data, and what is generated or checked from it. You rarely import this package: `vlx` uses it. A project gets the default theme with nothing written.
4
+
5
+ ## What a theme depends on
6
+
7
+ Five things, and nothing else (not an element, a class, or the order of wrappers):
8
+
9
+ | Thing | Meaning |
10
+ |---|---|
11
+ | **Token** | A named design decision (a color role, a space step, a type role, a radius), in the [Design Tokens](https://www.designtokens.org/) format (2025.10), one document per **mode** (`light`, `dark`, `contrast`) over a base document |
12
+ | **Part** | A named region of a primitive. Only `root` exists (S1) |
13
+ | **State** | `hovered`, `pressed`, `focus-visible` (every interactive primitive) and the semantic states a primitive's specification declares (`checked`, `invalid`, `disabled`) |
14
+ | **Variant** | A closed set a template chooses from: `intent` (`neutral`, `primary`, `danger`) and `size` (`sm`, `md`, `lg`) |
15
+ | **Condition** | `viewport` (compact, medium, expanded), `scheme`, `contrast`, `forced-colors`, `reduced-motion`, `target` |
16
+
17
+ A **recipe** (`recipes/<primitive>.json`) maps a part, a variant, a state and a condition to declarations from a closed set of properties: `background`, `text`, `border`, `radius`, `padding`, `gap`, `min-size`, `type`, `elevation`, `opacity`, `outline`, `align`. A value is a token path (or a keyword, or a number for `opacity`).
18
+
19
+ ## The three tiers
20
+
21
+ 1. **Tier 1, nothing to write.** The default theme styles the seven primitives of the portable core on the web and on the desktop.
22
+ 2. **Tier 2, opt in.** A `theme/` directory with a `theme.json` replaces the default, whole: your token documents and recipes. `vlx theme check` says what is wrong and what each target applies; `vlx check` and `vlx build` refuse a theme with a mistake in it. `intent` and `size` on a `button` choose a variant.
23
+ 3. **Tier 3, the marked way out.** A recipe's `escape.web.css` holds CSS for a property outside the contract, for the web only; a project's own stylesheet, in no layer, wins over every layer.
24
+
25
+ ## What is generated
26
+
27
+ - **Web:** one static stylesheet, the same on the server and in the browser, in cascade layers (`vlx-library`, `vlx-theme`, `vlx-app`, later layers winning). It reaches a primitive only by its hook `[data-vlx="button"]`, a variant's attribute, a state's selector and a media query.
28
+ - **Desktop:** the theme resolved (colors as hex, lengths in points, only what the window applies), given to the window once with `appearance`.
29
+
30
+ ## What is checked
31
+
32
+ The tokens against the Design Tokens Community Group's own schemas and test suite (vendored unchanged in `schema/dtcg-2025.10/` and `test/fixtures/dtcg-2025.10/`) plus a graph pass (`$extends`, aliases, JSON Pointer references, cycles, the type of the target); every recipe against the contract and the primitives' specifications; the contrast of the pairs a theme declares, as WCAG 2.2 defines it; the compiled stylesheet with stylelint; and the default theme in Chromium against its tokens.
33
+
34
+ Private data in `contract/` and `schema/`; the code is `src/`.
@@ -0,0 +1,207 @@
1
+ {
2
+ "version": 1,
3
+ "note": "The style contract (Bet 6; decisions S1 to S8, P3): the five things a theme may depend on. Everything else (the element, its class, the order of wrappers) is not part of it.",
4
+ "parts": {
5
+ "root": "The primitive itself: the one element or widget that is the primitive. The only part until a realization shows it needs another (S1)."
6
+ },
7
+ "interactionStates": {
8
+ "hovered": ":hover",
9
+ "pressed": ":active",
10
+ "focus-visible": ":focus-visible"
11
+ },
12
+ "interactionMedia": {
13
+ "hovered": "(any-hover: hover)"
14
+ },
15
+ "semanticStates": [
16
+ "checked",
17
+ "invalid",
18
+ "disabled",
19
+ "busy",
20
+ "selected"
21
+ ],
22
+ "variants": {
23
+ "intent": {
24
+ "values": [
25
+ "neutral",
26
+ "primary",
27
+ "danger"
28
+ ],
29
+ "default": "neutral"
30
+ },
31
+ "size": {
32
+ "values": [
33
+ "sm",
34
+ "md",
35
+ "lg"
36
+ ],
37
+ "default": "md"
38
+ }
39
+ },
40
+ "conditions": {
41
+ "viewport": {
42
+ "values": [
43
+ "compact",
44
+ "medium",
45
+ "expanded"
46
+ ],
47
+ "web": {
48
+ "compact": "(width < 600px)",
49
+ "medium": "(600px <= width < 840px)",
50
+ "expanded": "(width >= 840px)"
51
+ },
52
+ "desktop": "supported"
53
+ },
54
+ "scheme": {
55
+ "values": [
56
+ "light",
57
+ "dark"
58
+ ],
59
+ "web": {
60
+ "light": "(prefers-color-scheme: light)",
61
+ "dark": "(prefers-color-scheme: dark)"
62
+ },
63
+ "desktop": "supported"
64
+ },
65
+ "contrast": {
66
+ "values": [
67
+ "more"
68
+ ],
69
+ "web": {
70
+ "more": "(prefers-contrast: more)"
71
+ },
72
+ "desktop": "unsupported"
73
+ },
74
+ "forced-colors": {
75
+ "values": [
76
+ "active"
77
+ ],
78
+ "web": {
79
+ "active": "(forced-colors: active)"
80
+ },
81
+ "desktop": "unsupported"
82
+ },
83
+ "reduced-motion": {
84
+ "values": [
85
+ "reduce"
86
+ ],
87
+ "web": {
88
+ "reduce": "(prefers-reduced-motion: reduce)"
89
+ },
90
+ "desktop": "unsupported"
91
+ },
92
+ "target": {
93
+ "values": [
94
+ "web",
95
+ "desktop"
96
+ ],
97
+ "desktop": "supported"
98
+ }
99
+ },
100
+ "properties": {
101
+ "background": {
102
+ "accepts": "color",
103
+ "web": [
104
+ "background-color"
105
+ ],
106
+ "desktop": "supported"
107
+ },
108
+ "text": {
109
+ "accepts": "color",
110
+ "web": [
111
+ "color"
112
+ ],
113
+ "desktop": "supported"
114
+ },
115
+ "border": {
116
+ "accepts": "stroke",
117
+ "web": [
118
+ "border"
119
+ ],
120
+ "desktop": "supported"
121
+ },
122
+ "radius": {
123
+ "accepts": "dimension",
124
+ "web": [
125
+ "border-radius"
126
+ ],
127
+ "desktop": "supported"
128
+ },
129
+ "padding": {
130
+ "accepts": "space-list",
131
+ "web": [
132
+ "padding"
133
+ ],
134
+ "desktop": "supported"
135
+ },
136
+ "gap": {
137
+ "accepts": "dimension",
138
+ "web": [
139
+ "gap"
140
+ ],
141
+ "desktop": "supported"
142
+ },
143
+ "min-size": {
144
+ "accepts": "dimension",
145
+ "web": [
146
+ "min-height",
147
+ "min-width"
148
+ ],
149
+ "desktop": "supported"
150
+ },
151
+ "type": {
152
+ "accepts": "typography",
153
+ "web": [
154
+ "font"
155
+ ],
156
+ "desktop": "partial"
157
+ },
158
+ "elevation": {
159
+ "accepts": "shadow",
160
+ "web": [
161
+ "box-shadow"
162
+ ],
163
+ "desktop": "unsupported"
164
+ },
165
+ "opacity": {
166
+ "accepts": "opacity",
167
+ "web": [
168
+ "opacity"
169
+ ],
170
+ "desktop": "unsupported"
171
+ },
172
+ "outline": {
173
+ "accepts": "stroke",
174
+ "web": [
175
+ "outline",
176
+ "outline-offset"
177
+ ],
178
+ "desktop": "partial"
179
+ },
180
+ "align": {
181
+ "accepts": "alignment",
182
+ "web": [
183
+ "align-items"
184
+ ],
185
+ "desktop": "unsupported"
186
+ }
187
+ },
188
+ "desktopNotes": {
189
+ "type": "The size of the type role; the family and weight are the toolkit's.",
190
+ "outline": "The focus ring's color and width; the toolkit draws its own ring.",
191
+ "states": "`hovered` and `pressed` are applied to a button (the pointer over it, and held down; the properties the Desktop honors). The toolkit draws its own focus ring, so `focus-visible` is web only. `invalid` and `disabled` are applied."
192
+ },
193
+ "modeConditions": {
194
+ "dark": "scheme:dark",
195
+ "contrast": "contrast:more"
196
+ },
197
+ "desktopStates": {
198
+ "hovered": "supported",
199
+ "pressed": "supported",
200
+ "focus-visible": "unsupported",
201
+ "invalid": "supported",
202
+ "disabled": "supported",
203
+ "checked": "unsupported",
204
+ "busy": "unsupported",
205
+ "selected": "unsupported"
206
+ }
207
+ }
@@ -0,0 +1,31 @@
1
+ import type { Contract, LoadedTheme, PrimitiveInfo, Problem, Recipe } from "./types.js";
2
+ export interface Inputs {
3
+ readonly contract: Contract;
4
+ readonly primitives: Readonly<Record<string, PrimitiveInfo>>;
5
+ }
6
+ export declare const defaultInputs: () => Inputs;
7
+ /** The selector that reaches a primitive's root on the web: its hook attribute, which names the primitive and nothing about its element. Undefined where the web has no hook for it. */
8
+ export declare const hookOf: (inputs: Inputs, primitive: string) => string | undefined;
9
+ /** The states a recipe for `primitive` may style: the interaction states if a person acts on it, and the semantic states its specification declares. */
10
+ export declare const statesOf: (inputs: Inputs, primitive: string) => ReadonlyArray<string>;
11
+ /** The problems in one recipe. */
12
+ export declare const checkRecipe: (file: string, recipe: Recipe, modes: LoadedTheme["modes"], inputs: Inputs) => Problem[];
13
+ /** The contrast of every pair a theme declares, in each mode it applies to. `ratio` is what WCAG 2.2 computes (see contrast.ts). */
14
+ export interface ContrastResult {
15
+ readonly mode: string;
16
+ readonly text: string;
17
+ readonly on: string;
18
+ readonly min: number;
19
+ readonly ratio: number;
20
+ readonly passes: boolean;
21
+ }
22
+ export declare const contrastOf: (loaded: LoadedTheme) => {
23
+ readonly results: ReadonlyArray<ContrastResult>;
24
+ readonly problems: ReadonlyArray<Problem>;
25
+ };
26
+ export interface Checked {
27
+ readonly problems: ReadonlyArray<Problem>;
28
+ readonly contrast: ReadonlyArray<ContrastResult>;
29
+ }
30
+ /** Everything about a loaded theme that can be wrong: what loading found, each recipe, and the contrast. */
31
+ export declare const checkTheme: (loaded: LoadedTheme, inputs?: Inputs) => Checked;
package/dist/check.js ADDED
@@ -0,0 +1,250 @@
1
+ // The checks of a loaded theme that need the primitives' specifications: a recipe names a part, state, variant, property and token that exist, a value has the type the property takes, a focus indicator
2
+ // is not removed, and the contrast of the declared pairs. Each is an error with a code, the file and a path into it; a suggestion where a near name exists.
3
+ import { toRgb } from "./color.js";
4
+ import { composite, ratio } from "./contrast.js";
5
+ import { loadContract } from "./load.js";
6
+ import { primitives } from "./primitives.js";
7
+ import { didYouMean } from "./suggest.js";
8
+ export const defaultInputs = () => ({ contract: loadContract(), primitives });
9
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ /** The selector that reaches a primitive's root on the web: its hook attribute, which names the primitive and nothing about its element. Undefined where the web has no hook for it. */
11
+ export const hookOf = (inputs, primitive) => {
12
+ const hook = inputs.primitives[primitive]?.hook;
13
+ return hook === undefined ? undefined : `[data-vlx="${hook}"]`;
14
+ };
15
+ /** The states a recipe for `primitive` may style: the interaction states if a person acts on it, and the semantic states its specification declares. */
16
+ export const statesOf = (inputs, primitive) => {
17
+ const info = inputs.primitives[primitive];
18
+ if (info === undefined) {
19
+ return [];
20
+ }
21
+ return [...(info.interactive ? Object.keys(inputs.contract.interactionStates) : []), ...info.states.filter((state) => inputs.contract.semanticStates.includes(state))];
22
+ };
23
+ /** Whether `path` is a token of type `type` in every mode; the message of the first mode that is not. */
24
+ const tokenProblem = (modes, path, types) => {
25
+ for (const [mode, tokens] of Object.entries(modes)) {
26
+ const token = tokens.get(path);
27
+ if (token === undefined) {
28
+ const near = didYouMean(path, [...tokens.keys()].filter((each) => types.includes(tokens.get(each).type)));
29
+ return `${path} is not a token${Object.keys(modes).length > 1 ? ` in the ${mode} mode` : ""}${near}`;
30
+ }
31
+ if (!types.includes(token.type)) {
32
+ return `${path} is a ${token.type} token, and ${types.length === 1 ? `a ${types[0]}` : `one of ${types.join(", ")}`} is needed here`;
33
+ }
34
+ }
35
+ return undefined;
36
+ };
37
+ const ALIGN = ["start", "center", "end", "stretch"];
38
+ /** What is wrong with `value` as the value of a property that takes `accepts`, or undefined. */
39
+ const valueProblem = (accepts, value, property, modes) => {
40
+ switch (accepts) {
41
+ case "color":
42
+ return typeof value === "string" ? tokenProblem(modes, value, ["color"]) : `${property} takes a color token`;
43
+ case "dimension":
44
+ return typeof value === "string" ? (value === "0" ? undefined : tokenProblem(modes, value, ["dimension"])) : `${property} takes a dimension token`;
45
+ case "space-list": {
46
+ const list = typeof value === "string" ? [value] : Array.isArray(value) ? value : undefined;
47
+ if (list === undefined) {
48
+ return `${property} takes a dimension token, or one to four of them`;
49
+ }
50
+ for (const each of list) {
51
+ const problem = each === "0" ? undefined : tokenProblem(modes, each, ["dimension"]);
52
+ if (problem !== undefined) {
53
+ return problem;
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ case "stroke": {
59
+ if (value === "none") {
60
+ return undefined;
61
+ }
62
+ if (!isObject(value) || typeof value["width"] !== "string" || typeof value["color"] !== "string") {
63
+ return `${property} takes "none" or { width, color } (a dimension token and a color token)`;
64
+ }
65
+ return (tokenProblem(modes, value["width"], ["dimension"]) ??
66
+ tokenProblem(modes, value["color"], ["color"]) ??
67
+ (typeof value["offset"] === "string" ? tokenProblem(modes, value["offset"], ["dimension"]) : undefined));
68
+ }
69
+ case "typography":
70
+ return typeof value === "string" ? tokenProblem(modes, value, ["typography"]) : `${property} takes a typography token`;
71
+ case "shadow":
72
+ return value === "none" ? undefined : typeof value === "string" ? tokenProblem(modes, value, ["shadow"]) : `${property} takes "none" or a shadow token`;
73
+ case "opacity":
74
+ return typeof value === "number" && value >= 0 && value <= 1 ? undefined : `${property} takes a number from 0 to 1`;
75
+ case "alignment":
76
+ return typeof value === "string" && ALIGN.includes(value)
77
+ ? undefined
78
+ : `${property} takes one of ${ALIGN.join(", ")}${typeof value === "string" ? didYouMean(value, ALIGN) : ""}`;
79
+ }
80
+ };
81
+ const declarationProblems = (file, where, declarations, inputs, modes) => {
82
+ const problems = [];
83
+ for (const [property, value] of Object.entries(declarations)) {
84
+ const definition = inputs.contract.properties[property];
85
+ if (definition === undefined) {
86
+ problems.push({
87
+ file,
88
+ path: `${where}/${property}`,
89
+ code: "unknown-property",
90
+ message: `${property} is not a property of the style contract${didYouMean(property, Object.keys(inputs.contract.properties))}; a property outside it goes in \`escape\`, for one target`,
91
+ });
92
+ continue;
93
+ }
94
+ const problem = valueProblem(definition.accepts, value, property, modes);
95
+ if (problem !== undefined) {
96
+ problems.push({ file, path: `${where}/${property}`, code: problem.includes("is not a token") ? "unresolved-token" : "bad-value", message: problem });
97
+ }
98
+ }
99
+ return problems;
100
+ };
101
+ const removesFocus = (declarations) => declarations !== undefined && declarations["outline"] === "none";
102
+ /** The problems in one recipe. */
103
+ export const checkRecipe = (file, recipe, modes, inputs) => {
104
+ const problems = [];
105
+ const { primitive, part } = recipe.target;
106
+ const info = inputs.primitives[primitive];
107
+ if (info === undefined) {
108
+ return [
109
+ { file, path: "/target/primitive", code: "unknown-primitive", message: `there is no primitive "${primitive}"${didYouMean(primitive, Object.keys(inputs.primitives))}` },
110
+ ];
111
+ }
112
+ if (inputs.contract.parts[part] === undefined) {
113
+ problems.push({
114
+ file,
115
+ path: "/target/part",
116
+ code: "unknown-part",
117
+ message: `"${part}" is not a part${didYouMean(part, Object.keys(inputs.contract.parts))}; the parts are ${Object.keys(inputs.contract.parts).join(", ")}`,
118
+ });
119
+ }
120
+ if (info.status === "desktop-only") {
121
+ problems.push({ file, path: "/target/primitive", code: "not-on-the-web", message: `${primitive} is desktop-only; the web has no element for it` });
122
+ }
123
+ problems.push(...declarationProblems(file, "/base", recipe.base ?? {}, inputs, modes));
124
+ for (const [variant, values] of Object.entries(recipe.variants ?? {})) {
125
+ const definition = inputs.contract.variants[variant];
126
+ if (definition === undefined || !info.variants.some((each) => each.name === variant)) {
127
+ const known = info.variants.map((each) => each.name).filter((each) => inputs.contract.variants[each] !== undefined);
128
+ problems.push({
129
+ file,
130
+ path: `/variants/${variant}`,
131
+ code: "unknown-variant",
132
+ message: `${primitive} has no variant "${variant}"${didYouMean(variant, known)}${known.length === 0 ? "; it takes none" : `; it takes ${known.join(", ")}`}`,
133
+ });
134
+ continue;
135
+ }
136
+ for (const [value, declarations] of Object.entries(values)) {
137
+ if (!definition.values.includes(value)) {
138
+ problems.push({
139
+ file,
140
+ path: `/variants/${variant}/${value}`,
141
+ code: "unknown-variant-value",
142
+ message: `${variant} has no value "${value}"${didYouMean(value, definition.values)}; the values are ${definition.values.join(", ")}`,
143
+ });
144
+ }
145
+ problems.push(...declarationProblems(file, `/variants/${variant}/${value}`, declarations, inputs, modes));
146
+ }
147
+ }
148
+ const allowed = statesOf(inputs, primitive);
149
+ for (const [state, declarations] of Object.entries(recipe.states ?? {})) {
150
+ if (!allowed.includes(state)) {
151
+ problems.push({
152
+ file,
153
+ path: `/states/${state}`,
154
+ code: "unknown-state",
155
+ message: `${primitive} has no state "${state}"${didYouMean(state, allowed)}; its states are ${allowed.length === 0 ? "none" : allowed.join(", ")}`,
156
+ });
157
+ }
158
+ else if (!(state in inputs.contract.interactionStates) && info.stateSelectors[state] === undefined) {
159
+ problems.push({ file, path: `/states/${state}`, code: "state-unreachable", message: `the web has no selector for the state "${state}" of ${primitive}` });
160
+ }
161
+ problems.push(...declarationProblems(file, `/states/${state}`, declarations, inputs, modes));
162
+ }
163
+ for (const [condition, declarations] of Object.entries(recipe.when ?? {})) {
164
+ const [name, value] = condition.split(":");
165
+ const definition = inputs.contract.conditions[name];
166
+ if (definition === undefined) {
167
+ problems.push({
168
+ file,
169
+ path: `/when/${condition}`,
170
+ code: "unknown-condition",
171
+ message: `"${name}" is not a condition${didYouMean(name, Object.keys(inputs.contract.conditions))}; the conditions are ${Object.keys(inputs.contract.conditions).join(", ")}`,
172
+ });
173
+ }
174
+ else if (!definition.values.includes(value)) {
175
+ problems.push({
176
+ file,
177
+ path: `/when/${condition}`,
178
+ code: "unknown-condition-value",
179
+ message: `${name} has no value "${value}"${didYouMean(value, definition.values)}; the values are ${definition.values.join(", ")}`,
180
+ });
181
+ }
182
+ problems.push(...declarationProblems(file, `/when/${condition}`, declarations, inputs, modes));
183
+ }
184
+ // A recipe that removes the focus indicator must put another in its place: it is detectable in static data.
185
+ const focus = recipe.states?.["focus-visible"]?.["outline"];
186
+ const replaced = isObject(focus);
187
+ const removed = removesFocus(recipe.base) ||
188
+ Object.values(recipe.variants ?? {}).some((values) => Object.values(values).some(removesFocus)) ||
189
+ removesFocus(recipe.states?.["focus-visible"]) ||
190
+ Object.values(recipe.when ?? {}).some(removesFocus) ||
191
+ /outline(-style)?\s*:\s*(none|0)\b/.test(recipe.escape?.web?.css ?? "");
192
+ if (removed && !replaced) {
193
+ problems.push({
194
+ file,
195
+ path: "/states/focus-visible",
196
+ code: "focus-removed",
197
+ message: `${primitive}'s recipe removes the focus indicator and does not give another: set states.focus-visible.outline to a width and a color`,
198
+ });
199
+ }
200
+ return problems;
201
+ };
202
+ export const contrastOf = (loaded) => {
203
+ const results = [];
204
+ const problems = [];
205
+ const file = "theme.json";
206
+ for (const pair of loaded.theme.contrast ?? []) {
207
+ for (const [mode, tokens] of Object.entries(loaded.modes)) {
208
+ if (pair.modes !== undefined && !pair.modes.includes(mode)) {
209
+ continue;
210
+ }
211
+ const text = tokens.get(pair.text);
212
+ const on = tokens.get(pair.on);
213
+ if (text?.type !== "color" || on?.type !== "color") {
214
+ problems.push({
215
+ file,
216
+ path: `/contrast`,
217
+ code: "contrast-unresolved",
218
+ message: `${text?.type !== "color" ? pair.text : pair.on} is not a color token in the ${mode} mode`,
219
+ });
220
+ continue;
221
+ }
222
+ try {
223
+ const surface = toRgb(on.value);
224
+ const value = ratio(composite(toRgb(text.value), surface), surface);
225
+ const passes = value + 1e-9 >= pair.min;
226
+ results.push({ mode, text: pair.text, on: pair.on, min: pair.min, ratio: value, passes });
227
+ if (!passes) {
228
+ problems.push({
229
+ file,
230
+ path: "/contrast",
231
+ code: "contrast-below-minimum",
232
+ message: `${pair.text} on ${pair.on} is ${value.toFixed(2)}:1 in the ${mode} mode, below the ${pair.min}:1 the theme declares`,
233
+ });
234
+ }
235
+ }
236
+ catch (error) {
237
+ problems.push({ file, path: "/contrast", code: "contrast-unsupported", message: error instanceof Error ? error.message : String(error) });
238
+ }
239
+ }
240
+ }
241
+ return { results, problems };
242
+ };
243
+ /** Everything about a loaded theme that can be wrong: what loading found, each recipe, and the contrast. */
244
+ export const checkTheme = (loaded, inputs = defaultInputs()) => {
245
+ const contrast = contrastOf(loaded);
246
+ return {
247
+ problems: [...loaded.problems, ...loaded.recipes.flatMap(({ file, recipe }) => checkRecipe(file, recipe, loaded.modes, inputs)), ...contrast.problems],
248
+ contrast: contrast.results,
249
+ };
250
+ };
@@ -0,0 +1,17 @@
1
+ export interface Rgb {
2
+ /** 0 to 1, gamma-encoded sRGB. */
3
+ readonly r: number;
4
+ readonly g: number;
5
+ readonly b: number;
6
+ readonly a: number;
7
+ }
8
+ export interface DtcgColor {
9
+ readonly colorSpace: string;
10
+ readonly components: ReadonlyArray<number | "none">;
11
+ readonly alpha?: number;
12
+ readonly hex?: string;
13
+ }
14
+ /** The color as gamma-encoded sRGB, clipped to the gamut. Throws for a color space this package does not convert. */
15
+ export declare const toRgb: (color: DtcgColor) => Rgb;
16
+ /** The color as CSS: `#rrggbb` (or `#rrggbbaa`) for sRGB, and the space's own function for the others, so a wide-gamut color is not narrowed on the web. */
17
+ export declare const toCss: (color: DtcgColor) => string;
package/dist/color.js ADDED
@@ -0,0 +1,82 @@
1
+ const clamp = (value) => Math.min(1, Math.max(0, value));
2
+ const component = (value) => (value === "none" ? 0 : value);
3
+ const encode = (linear) => (Math.abs(linear) <= 0.0031308 ? 12.92 * linear : Math.sign(linear) * (1.055 * Math.abs(linear) ** (1 / 2.4) - 0.055));
4
+ const decode = (encoded) => (Math.abs(encoded) <= 0.04045 ? encoded / 12.92 : Math.sign(encoded) * ((Math.abs(encoded) + 0.055) / 1.055) ** 2.4);
5
+ const fromOklab = (lightness, a, b) => {
6
+ const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
7
+ const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
8
+ const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
9
+ return [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
10
+ };
11
+ const fromHsl = (hue, saturation, lightness) => {
12
+ const s = saturation / 100;
13
+ const l = lightness / 100;
14
+ const k = (n) => (n + hue / 30) % 12;
15
+ const a = s * Math.min(l, 1 - l);
16
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
17
+ return [f(0), f(8), f(4)];
18
+ };
19
+ /** Display P3 (gamma-encoded) to linear sRGB, through XYZ (D65). */
20
+ const fromDisplayP3 = (r, g, b) => {
21
+ const [lr, lg, lb] = [decode(r), decode(g), decode(b)];
22
+ const x = 0.4865709486482162 * lr + 0.26566769316909306 * lg + 0.1982172852343625 * lb;
23
+ const y = 0.2289745640697488 * lr + 0.6917385218365064 * lg + 0.079286914093745 * lb;
24
+ const z = 0.04511338185890264 * lg + 1.043944368900976 * lb;
25
+ return [
26
+ 3.2409699419045226 * x - 1.537383177570094 * y - 0.4986107602930034 * z,
27
+ -0.9692436362808796 * x + 1.8759675015077202 * y + 0.04155505740717559 * z,
28
+ 0.05563007969699366 * x - 0.20397695888897652 * y + 1.0569715142428786 * z,
29
+ ];
30
+ };
31
+ /** The color as gamma-encoded sRGB, clipped to the gamut. Throws for a color space this package does not convert. */
32
+ export const toRgb = (color) => {
33
+ const c = color.components.map(component);
34
+ const a = color.alpha ?? 1;
35
+ const [c0 = 0, c1 = 0, c2 = 0] = c;
36
+ switch (color.colorSpace) {
37
+ case "srgb":
38
+ return { r: clamp(c0), g: clamp(c1), b: clamp(c2), a };
39
+ case "srgb-linear":
40
+ return { r: clamp(encode(c0)), g: clamp(encode(c1)), b: clamp(encode(c2)), a };
41
+ case "hsl": {
42
+ const [r, g, b] = fromHsl(c0, c1, c2);
43
+ return { r: clamp(r), g: clamp(g), b: clamp(b), a };
44
+ }
45
+ case "oklab":
46
+ case "oklch": {
47
+ const [lr, lg, lb] = color.colorSpace === "oklab" ? fromOklab(c0, c1, c2) : fromOklab(c0, c1 * Math.cos((c2 * Math.PI) / 180), c1 * Math.sin((c2 * Math.PI) / 180));
48
+ return { r: clamp(encode(lr)), g: clamp(encode(lg)), b: clamp(encode(lb)), a };
49
+ }
50
+ case "display-p3": {
51
+ const [lr, lg, lb] = fromDisplayP3(c0, c1, c2);
52
+ return { r: clamp(encode(lr)), g: clamp(encode(lg)), b: clamp(encode(lb)), a };
53
+ }
54
+ default:
55
+ throw new Error(`the color space "${color.colorSpace}" is not one this package converts (srgb, srgb-linear, hsl, oklab, oklch, display-p3)`);
56
+ }
57
+ };
58
+ const byte = (value) => Math.round(clamp(value) * 255)
59
+ .toString(16)
60
+ .padStart(2, "0");
61
+ const number = (value) => String(Math.round(value * 10000) / 10000);
62
+ /** The color as CSS: `#rrggbb` (or `#rrggbbaa`) for sRGB, and the space's own function for the others, so a wide-gamut color is not narrowed on the web. */
63
+ export const toCss = (color) => {
64
+ const c = color.components.map((each) => (each === "none" ? "none" : number(each)));
65
+ const alpha = color.alpha === undefined || color.alpha === 1 ? "" : ` / ${number(color.alpha)}`;
66
+ switch (color.colorSpace) {
67
+ case "srgb": {
68
+ const rgb = toRgb(color);
69
+ return `#${byte(rgb.r)}${byte(rgb.g)}${byte(rgb.b)}${rgb.a === 1 ? "" : byte(rgb.a)}`;
70
+ }
71
+ case "oklch":
72
+ case "oklab":
73
+ return `${color.colorSpace}(${c.join(" ")}${alpha})`;
74
+ case "hsl":
75
+ return `hsl(${c[0]} ${c[1]}% ${c[2]}%${alpha})`;
76
+ case "display-p3":
77
+ case "srgb-linear":
78
+ return `color(${color.colorSpace} ${c.join(" ")}${alpha})`;
79
+ default:
80
+ throw new Error(`the color space "${color.colorSpace}" is not one this package converts`);
81
+ }
82
+ };
@@ -0,0 +1,5 @@
1
+ import type { Rgb } from "./color.js";
2
+ export declare const luminance: ({ r, g, b }: Rgb) => number;
3
+ /** `over` composited on `under` (alpha compositing in gamma-encoded sRGB, as browsers do). */
4
+ export declare const composite: (over: Rgb, under: Rgb) => Rgb;
5
+ export declare const ratio: (a: Rgb, b: Rgb) => number;
@@ -0,0 +1,13 @@
1
+ const linear = (channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
2
+ export const luminance = ({ r, g, b }) => 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
3
+ /** `over` composited on `under` (alpha compositing in gamma-encoded sRGB, as browsers do). */
4
+ export const composite = (over, under) => ({
5
+ r: over.r * over.a + under.r * (1 - over.a),
6
+ g: over.g * over.a + under.g * (1 - over.a),
7
+ b: over.b * over.a + under.b * (1 - over.a),
8
+ a: 1,
9
+ });
10
+ export const ratio = (a, b) => {
11
+ const [light, dark] = [luminance(a), luminance(b)].sort((x, y) => y - x);
12
+ return (light + 0.05) / (dark + 0.05);
13
+ };