@anvpx/motion-themes 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/dist/atoms/catalog.d.ts +18 -0
- package/dist/atoms/catalog.js +35 -0
- package/dist/atoms/data/calm-accent.json +14 -0
- package/dist/atoms/data/calm-entrance.json +15 -0
- package/dist/atoms/data/calm-title.json +14 -0
- package/dist/atoms/fixtures.d.ts +9 -0
- package/dist/atoms/fixtures.js +41 -0
- package/dist/atoms/registry.d.ts +12 -0
- package/dist/atoms/registry.js +47 -0
- package/dist/atoms/types.d.ts +29 -0
- package/dist/atoms/types.js +141 -0
- package/dist/checks.d.ts +16 -0
- package/dist/checks.js +46 -0
- package/dist/contrast.d.ts +5 -0
- package/dist/contrast.js +28 -0
- package/dist/css.d.ts +3 -0
- package/dist/css.js +37 -0
- package/dist/cvd.d.ts +6 -0
- package/dist/cvd.js +64 -0
- package/dist/dtcg.d.ts +12 -0
- package/dist/dtcg.js +57 -0
- package/dist/grade/grades.d.ts +24 -0
- package/dist/grade/grades.js +85 -0
- package/dist/grade/grades.json +22 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/optional.d.ts +40 -0
- package/dist/optional.js +151 -0
- package/dist/resolve.d.ts +412 -0
- package/dist/resolve.js +71 -0
- package/dist/tokens/calm-dark.json +362 -0
- package/dist/tokens/dark.json +319 -0
- package/dist/tokens/default.json +319 -0
- package/dist/tokens/tech-dark.json +360 -0
- package/package.json +31 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 anvpx
|
|
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.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type StylePreset } from "@anvpx/motion-schema";
|
|
2
|
+
import type { AuthoringAtom } from "./types.ts";
|
|
3
|
+
/** All seed atoms, ref-sorted clones. Every seed is verified original synthetic data. */
|
|
4
|
+
export declare const listSeedAtoms: () => AuthoringAtom[];
|
|
5
|
+
/** Verified-only index rows: draft, deprecated, and unverified rows stay out. Ref-sorted clones. */
|
|
6
|
+
export declare const listCatalogAtoms: () => AuthoringAtom[];
|
|
7
|
+
/**
|
|
8
|
+
* Expand a seed atom into a theme context. Pure and deterministic: repeated
|
|
9
|
+
* calls return deeply equal clones. The preset theme is set to the requested
|
|
10
|
+
* context so `default` and `dark` both validate as current StylePresets.
|
|
11
|
+
*/
|
|
12
|
+
export declare const expandSeedAtom: (id: string, version: string, theme: string) => StylePreset;
|
|
13
|
+
/** Stated byte budget for the canonical index payload. */
|
|
14
|
+
export declare const SEED_CATALOG_BUDGET_BYTES = 8192;
|
|
15
|
+
/** Canonical bytes of the verified index: ref-sorted, key-sorted, newline-terminated. */
|
|
16
|
+
export declare const seedCatalogCanonicalBytes: () => string;
|
|
17
|
+
/** The generated index is authoring documentation only; runtime props belong to `mk catalog`. */
|
|
18
|
+
export declare const CATALOG_RUNTIME_NOTE = "Runtime scene props live in `mk catalog`; this index is authoring documentation only \u2014 expand atoms at authoring time, never persist atom refs.";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { canonicalJson, parseStylePreset } from "@anvpx/motion-schema";
|
|
2
|
+
import { THEME_NAMES } from "../resolve.js";
|
|
3
|
+
import calmAccentDoc from "./data/calm-accent.json" with { type: "json" };
|
|
4
|
+
import calmEntranceDoc from "./data/calm-entrance.json" with { type: "json" };
|
|
5
|
+
import calmTitleDoc from "./data/calm-title.json" with { type: "json" };
|
|
6
|
+
import { createAtomRegistry, listVerifiedAtoms, resolveAtom } from "./registry.js";
|
|
7
|
+
const seedRegistry = createAtomRegistry([calmTitleDoc, calmEntranceDoc, calmAccentDoc]);
|
|
8
|
+
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
9
|
+
/** All seed atoms, ref-sorted clones. Every seed is verified original synthetic data. */
|
|
10
|
+
export const listSeedAtoms = () => seedRegistry.atoms.map((atom) => clone(atom));
|
|
11
|
+
/** Verified-only index rows: draft, deprecated, and unverified rows stay out. Ref-sorted clones. */
|
|
12
|
+
export const listCatalogAtoms = () => listVerifiedAtoms(seedRegistry);
|
|
13
|
+
/**
|
|
14
|
+
* Expand a seed atom into a theme context. Pure and deterministic: repeated
|
|
15
|
+
* calls return deeply equal clones. The preset theme is set to the requested
|
|
16
|
+
* context so `default` and `dark` both validate as current StylePresets.
|
|
17
|
+
*/
|
|
18
|
+
export const expandSeedAtom = (id, version, theme) => {
|
|
19
|
+
if (!THEME_NAMES.includes(theme)) {
|
|
20
|
+
throw new Error(`unknown theme "${theme}"; use one of: ${THEME_NAMES.join("|")}`);
|
|
21
|
+
}
|
|
22
|
+
const atom = resolveAtom(seedRegistry, id, version);
|
|
23
|
+
const preset = { ...clone(atom.preset), theme };
|
|
24
|
+
const parsed = parseStylePreset(preset);
|
|
25
|
+
if (!parsed.ok) {
|
|
26
|
+
throw new Error(`seed atom "${id}@${version}" does not expand to a current StylePreset for theme "${theme}"`);
|
|
27
|
+
}
|
|
28
|
+
return clone(parsed.value);
|
|
29
|
+
};
|
|
30
|
+
/** Stated byte budget for the canonical index payload. */
|
|
31
|
+
export const SEED_CATALOG_BUDGET_BYTES = 8192;
|
|
32
|
+
/** Canonical bytes of the verified index: ref-sorted, key-sorted, newline-terminated. */
|
|
33
|
+
export const seedCatalogCanonicalBytes = () => canonicalJson(listCatalogAtoms());
|
|
34
|
+
/** The generated index is authoring documentation only; runtime props belong to `mk catalog`. */
|
|
35
|
+
export const CATALOG_RUNTIME_NOTE = "Runtime scene props live in `mk catalog`; this index is authoring documentation only — expand atoms at authoring time, never persist atom refs.";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "calm-accent",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"kind": "accent",
|
|
6
|
+
"summary": "Calm accent pulse for explainer callouts.",
|
|
7
|
+
"tags": ["calm", "accent", "explainer"],
|
|
8
|
+
"status": "verified",
|
|
9
|
+
"preset": {
|
|
10
|
+
"id": "calm-accent",
|
|
11
|
+
"theme": "default",
|
|
12
|
+
"frame": { "width": 1920, "height": 1080, "fps": 30 }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "calm-entrance",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"kind": "motion-cue",
|
|
6
|
+
"summary": "Gentle push-in entrance for calm titles.",
|
|
7
|
+
"tags": ["calm", "entrance", "motion"],
|
|
8
|
+
"status": "verified",
|
|
9
|
+
"preset": {
|
|
10
|
+
"id": "calm-entrance",
|
|
11
|
+
"theme": "default",
|
|
12
|
+
"frame": { "width": 1920, "height": 1080, "fps": 30 },
|
|
13
|
+
"direction": { "move": "push-in", "ease": "out" }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "calm-title",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"kind": "theme-preset",
|
|
6
|
+
"summary": "Calm title preset for explainer videos.",
|
|
7
|
+
"tags": ["calm", "title", "explainer"],
|
|
8
|
+
"status": "verified",
|
|
9
|
+
"preset": {
|
|
10
|
+
"id": "calm",
|
|
11
|
+
"theme": "default",
|
|
12
|
+
"frame": { "width": 1920, "height": 1080, "fps": 30 }
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AuthoringAtom } from "./types.ts";
|
|
2
|
+
/** Original synthetic theme preset: calm title treatment for explainer videos. */
|
|
3
|
+
export declare const calmTitleAtom: AuthoringAtom;
|
|
4
|
+
/** Original synthetic accent atom in the same calm family. */
|
|
5
|
+
export declare const calmAccentAtom: AuthoringAtom;
|
|
6
|
+
/** Original synthetic motion cue: gentle push-in for title entrances. */
|
|
7
|
+
export declare const calmMotionAtom: AuthoringAtom;
|
|
8
|
+
/** Fresh clones on every call so fixtures never share mutable state. */
|
|
9
|
+
export declare const syntheticAtomFixtures: () => AuthoringAtom[];
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const frame = { width: 1920, height: 1080, fps: 30 };
|
|
2
|
+
/** Original synthetic theme preset: calm title treatment for explainer videos. */
|
|
3
|
+
export const calmTitleAtom = {
|
|
4
|
+
schemaVersion: 1,
|
|
5
|
+
id: "calm-title",
|
|
6
|
+
version: "1.0.0",
|
|
7
|
+
kind: "theme-preset",
|
|
8
|
+
summary: "Calm title preset for explainer videos.",
|
|
9
|
+
tags: ["calm", "title", "explainer"],
|
|
10
|
+
status: "verified",
|
|
11
|
+
preset: { id: "calm", theme: "default", frame: { ...frame } },
|
|
12
|
+
};
|
|
13
|
+
/** Original synthetic accent atom in the same calm family. */
|
|
14
|
+
export const calmAccentAtom = {
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
id: "calm-accent",
|
|
17
|
+
version: "1.0.0",
|
|
18
|
+
kind: "accent",
|
|
19
|
+
summary: "Calm accent pulse for explainer callouts.",
|
|
20
|
+
tags: ["calm", "accent", "explainer"],
|
|
21
|
+
status: "verified",
|
|
22
|
+
preset: { id: "calm-accent", theme: "default", frame: { ...frame } },
|
|
23
|
+
};
|
|
24
|
+
/** Original synthetic motion cue: gentle push-in for title entrances. */
|
|
25
|
+
export const calmMotionAtom = {
|
|
26
|
+
schemaVersion: 1,
|
|
27
|
+
id: "calm-entrance",
|
|
28
|
+
version: "1.0.0",
|
|
29
|
+
kind: "motion-cue",
|
|
30
|
+
summary: "Gentle push-in entrance for calm titles.",
|
|
31
|
+
tags: ["calm", "entrance", "motion"],
|
|
32
|
+
status: "verified",
|
|
33
|
+
preset: {
|
|
34
|
+
id: "calm-entrance",
|
|
35
|
+
theme: "default",
|
|
36
|
+
frame: { ...frame },
|
|
37
|
+
direction: { move: "push-in", ease: "out" },
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
/** Fresh clones on every call so fixtures never share mutable state. */
|
|
41
|
+
export const syntheticAtomFixtures = () => JSON.parse(JSON.stringify([calmTitleAtom, calmAccentAtom, calmMotionAtom]));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type AuthoringAtom } from "./types.ts";
|
|
2
|
+
export interface AtomRegistry {
|
|
3
|
+
readonly atoms: readonly AuthoringAtom[];
|
|
4
|
+
}
|
|
5
|
+
/** Pure creation: parses, rejects duplicates, clones. No shared mutable state. Throws with pointer errors. */
|
|
6
|
+
export declare const createAtomRegistry: (docs: readonly unknown[]) => AtomRegistry;
|
|
7
|
+
/** Exact-version resolve: returns a clone. Never substitutes versions. */
|
|
8
|
+
export declare const resolveAtom: (registry: AtomRegistry, id: string, version: string) => AuthoringAtom;
|
|
9
|
+
/** Verified index only: status verified. Sorted, cloned. */
|
|
10
|
+
export declare const listVerifiedAtoms: (registry: AtomRegistry) => AuthoringAtom[];
|
|
11
|
+
/** Canonical bytes of the whole registry: ref-sorted, key-sorted, newline-terminated. */
|
|
12
|
+
export declare const registryCanonicalBytes: (registry: AtomRegistry) => string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { canonicalJson } from "@anvpx/motion-schema";
|
|
2
|
+
import { atomRef, parseAuthoringAtom } from "./types.js";
|
|
3
|
+
const deepClone = (value) => JSON.parse(JSON.stringify(value));
|
|
4
|
+
const formatErrors = (index, errors) => `invalid atom at index ${index}: ${errors.map((e) => `${e.path === "" ? "/" : e.path} ${e.message}`).join("; ")}`;
|
|
5
|
+
/** Pure creation: parses, rejects duplicates, clones. No shared mutable state. Throws with pointer errors. */
|
|
6
|
+
export const createAtomRegistry = (docs) => {
|
|
7
|
+
const byRef = new Map();
|
|
8
|
+
docs.forEach((doc, index) => {
|
|
9
|
+
const parsed = parseAuthoringAtom(doc);
|
|
10
|
+
if (!parsed.ok) {
|
|
11
|
+
throw new Error(formatErrors(index, parsed.errors));
|
|
12
|
+
}
|
|
13
|
+
const ref = atomRef(parsed.value);
|
|
14
|
+
if (byRef.has(ref)) {
|
|
15
|
+
throw new Error(`duplicate atom ref "${ref}"`);
|
|
16
|
+
}
|
|
17
|
+
byRef.set(ref, deepClone(parsed.value));
|
|
18
|
+
});
|
|
19
|
+
const atoms = [...byRef.values()].sort((a, b) => atomRef(a) < atomRef(b) ? -1 : atomRef(a) > atomRef(b) ? 1 : 0);
|
|
20
|
+
return { atoms };
|
|
21
|
+
};
|
|
22
|
+
const byId = (registry, id) => registry.atoms.filter((atom) => atom.id === id);
|
|
23
|
+
/** Exact-version resolve: returns a clone. Never substitutes versions. */
|
|
24
|
+
export const resolveAtom = (registry, id, version) => {
|
|
25
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
26
|
+
throw new Error("resolveAtom needs a non-empty id");
|
|
27
|
+
}
|
|
28
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
29
|
+
throw new Error(`resolveAtom("${id}") needs an exact version: pass id and version`);
|
|
30
|
+
}
|
|
31
|
+
const matches = byId(registry, id);
|
|
32
|
+
if (matches.length === 0) {
|
|
33
|
+
throw new Error(`unknown atom id "${id}"`);
|
|
34
|
+
}
|
|
35
|
+
const found = matches.find((atom) => atom.version === version);
|
|
36
|
+
if (found === undefined) {
|
|
37
|
+
throw new Error(`unknown version "${version}" for atom "${id}": no silent substitution`);
|
|
38
|
+
}
|
|
39
|
+
return deepClone(found);
|
|
40
|
+
};
|
|
41
|
+
/** Verified index only: status verified. Sorted, cloned. */
|
|
42
|
+
export const listVerifiedAtoms = (registry) => registry.atoms
|
|
43
|
+
.filter((atom) => atom.status === "verified")
|
|
44
|
+
.sort((a, b) => (atomRef(a) < atomRef(b) ? -1 : atomRef(a) > atomRef(b) ? 1 : 0))
|
|
45
|
+
.map((atom) => deepClone(atom));
|
|
46
|
+
/** Canonical bytes of the whole registry: ref-sorted, key-sorted, newline-terminated. */
|
|
47
|
+
export const registryCanonicalBytes = (registry) => canonicalJson([...registry.atoms].sort((a, b) => atomRef(a) < atomRef(b) ? -1 : atomRef(a) > atomRef(b) ? 1 : 0));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { StylePreset } from "@anvpx/motion-schema";
|
|
2
|
+
import { type ValidationError } from "@anvpx/motion-schema";
|
|
3
|
+
export type AtomKind = "theme-preset" | "motion-cue" | "accent";
|
|
4
|
+
export interface AuthoringAtom {
|
|
5
|
+
readonly schemaVersion: 1;
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly version: string;
|
|
8
|
+
readonly kind: AtomKind;
|
|
9
|
+
readonly summary: string;
|
|
10
|
+
readonly tags: readonly string[];
|
|
11
|
+
readonly status: "draft" | "verified" | "deprecated";
|
|
12
|
+
readonly preset: StylePreset;
|
|
13
|
+
readonly replaces?: `${string}@${string}`;
|
|
14
|
+
}
|
|
15
|
+
type ParseOk = {
|
|
16
|
+
ok: true;
|
|
17
|
+
value: AuthoringAtom;
|
|
18
|
+
};
|
|
19
|
+
type ParseFail = {
|
|
20
|
+
ok: false;
|
|
21
|
+
errors: readonly ValidationError[];
|
|
22
|
+
};
|
|
23
|
+
/** Pure parse: no clock, network, or module-level mutable state. Returns a deep clone on success. */
|
|
24
|
+
export declare const parseAuthoringAtom: (input: unknown) => ParseOk | ParseFail;
|
|
25
|
+
/** Immutable `id@version` ref. */
|
|
26
|
+
export declare const atomRef: (atom: AuthoringAtom) => `${string}@${string}`;
|
|
27
|
+
/** Canonical bytes: keys sorted by UTF-16 code unit at every depth, 2-space indent, final newline. */
|
|
28
|
+
export declare const canonicalAtomBytes: (atom: AuthoringAtom) => string;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { canonicalJson, forbiddenKeys, parseStylePreset, toJsonPointer, } from "@anvpx/motion-schema";
|
|
2
|
+
const ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
3
|
+
const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
|
|
4
|
+
const REPLACES_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*@\d+\.\d+\.\d+$/;
|
|
5
|
+
const TOP_LEVEL_KEYS = new Set([
|
|
6
|
+
"schemaVersion",
|
|
7
|
+
"id",
|
|
8
|
+
"version",
|
|
9
|
+
"kind",
|
|
10
|
+
"summary",
|
|
11
|
+
"tags",
|
|
12
|
+
"status",
|
|
13
|
+
"preset",
|
|
14
|
+
"replaces",
|
|
15
|
+
]);
|
|
16
|
+
const DYNAMIC_TOKENS = [
|
|
17
|
+
"Date.now",
|
|
18
|
+
"requestAnimationFrame",
|
|
19
|
+
"Math.random",
|
|
20
|
+
"crypto.getRandomValues",
|
|
21
|
+
"crypto.random",
|
|
22
|
+
];
|
|
23
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
24
|
+
const deepClone = (value) => JSON.parse(JSON.stringify(value));
|
|
25
|
+
const err = (path, message, suggestion) => suggestion === undefined ? { path, message } : { path, message, suggestion };
|
|
26
|
+
const scanDynamicTokens = (value, path) => {
|
|
27
|
+
if (typeof value === "string") {
|
|
28
|
+
const hit = DYNAMIC_TOKENS.find((token) => value.includes(token));
|
|
29
|
+
return hit === undefined
|
|
30
|
+
? []
|
|
31
|
+
: [
|
|
32
|
+
err(toJsonPointer(path), `dynamic execution token "${hit}" is forbidden: atoms are static data`, `remove "${hit}"`),
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
return value.flatMap((item, index) => scanDynamicTokens(item, [...path, index]));
|
|
37
|
+
}
|
|
38
|
+
if (isPlainObject(value)) {
|
|
39
|
+
return Object.entries(value).flatMap(([key, child]) => scanDynamicTokens(child, [...path, key]));
|
|
40
|
+
}
|
|
41
|
+
return [];
|
|
42
|
+
};
|
|
43
|
+
const preCheckForbidden = (input, into) => {
|
|
44
|
+
for (const { path, message, suggestion } of forbiddenKeys(input, [])) {
|
|
45
|
+
into.push(suggestion === undefined
|
|
46
|
+
? { path: toJsonPointer(path), message }
|
|
47
|
+
: { path: toJsonPointer(path), message, suggestion });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const preCheckUnknownTopLevel = (input, into) => {
|
|
51
|
+
for (const key of Object.keys(input)) {
|
|
52
|
+
if (!TOP_LEVEL_KEYS.has(key)) {
|
|
53
|
+
into.push(err(`/${key}`, `unknown key(s): ${key}`, `remove ${key} (check spelling against the catalog)`));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const checkIdentity = (doc, into) => {
|
|
58
|
+
if (doc.schemaVersion !== 1) {
|
|
59
|
+
into.push(err("/schemaVersion", "must be 1", 'use "schemaVersion": 1'));
|
|
60
|
+
}
|
|
61
|
+
if (typeof doc.id !== "string" || !ID_PATTERN.test(doc.id)) {
|
|
62
|
+
into.push(err("/id", "must be a lowercase slug (a-z, 0-9, hyphens)", 'use e.g. "calm-title"'));
|
|
63
|
+
}
|
|
64
|
+
if (typeof doc.version !== "string" || !SEMVER_PATTERN.test(doc.version)) {
|
|
65
|
+
into.push(err("/version", "must be semver x.y.z", 'use e.g. "1.0.0"'));
|
|
66
|
+
}
|
|
67
|
+
if (doc.kind !== "theme-preset" && doc.kind !== "motion-cue" && doc.kind !== "accent") {
|
|
68
|
+
into.push(err("/kind", "theme-preset|motion-cue|accent invalid", "use one of: theme-preset|motion-cue|accent"));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const checkMeta = (doc, into) => {
|
|
72
|
+
if (typeof doc.summary !== "string" || doc.summary.length === 0) {
|
|
73
|
+
into.push(err("/summary", "must be a non-empty string", "describe what this atom is for"));
|
|
74
|
+
}
|
|
75
|
+
const tags = doc.tags;
|
|
76
|
+
const tagsOk = Array.isArray(tags) &&
|
|
77
|
+
tags.length > 0 &&
|
|
78
|
+
tags.every((tag) => typeof tag === "string" && ID_PATTERN.test(tag));
|
|
79
|
+
if (!tagsOk) {
|
|
80
|
+
into.push(err("/tags", "must be a non-empty array of slugs", 'use e.g. ["calm", "title"]'));
|
|
81
|
+
}
|
|
82
|
+
if (doc.status !== "draft" && doc.status !== "verified" && doc.status !== "deprecated") {
|
|
83
|
+
into.push(err("/status", "draft|verified|deprecated invalid", "use one of: draft|verified|deprecated"));
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const checkScalars = (doc, into) => {
|
|
87
|
+
checkIdentity(doc, into);
|
|
88
|
+
checkMeta(doc, into);
|
|
89
|
+
};
|
|
90
|
+
const checkPreset = (doc, into) => {
|
|
91
|
+
if (doc.preset === undefined) {
|
|
92
|
+
into.push(err("/preset", "is required", "set preset to a StylePreset document"));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const presetResult = parseStylePreset(doc.preset);
|
|
96
|
+
if (presetResult.ok) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
for (const issue of presetResult.errors) {
|
|
100
|
+
const pointer = issue.path === "" ? "/preset" : `/preset${issue.path}`;
|
|
101
|
+
into.push(issue.suggestion === undefined
|
|
102
|
+
? { path: pointer, message: issue.message }
|
|
103
|
+
: { path: pointer, message: issue.message, suggestion: issue.suggestion });
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
const checkReplaces = (doc, into) => {
|
|
107
|
+
if (doc.replaces === undefined) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (typeof doc.replaces !== "string" || !REPLACES_PATTERN.test(doc.replaces)) {
|
|
111
|
+
into.push(err("/replaces", "must look like id@x.y.z", 'use e.g. "calm-title@1.0.0"'));
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
/** Pure parse: no clock, network, or module-level mutable state. Returns a deep clone on success. */
|
|
115
|
+
export const parseAuthoringAtom = (input) => {
|
|
116
|
+
if (!isPlainObject(input)) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
errors: [err("", "must be an object", "pass an AuthoringAtom document")],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const early = [];
|
|
123
|
+
preCheckForbidden(input, early);
|
|
124
|
+
early.push(...scanDynamicTokens(input, []));
|
|
125
|
+
preCheckUnknownTopLevel(input, early);
|
|
126
|
+
if (early.length > 0) {
|
|
127
|
+
return { ok: false, errors: early };
|
|
128
|
+
}
|
|
129
|
+
const errors = [];
|
|
130
|
+
checkScalars(input, errors);
|
|
131
|
+
checkPreset(input, errors);
|
|
132
|
+
checkReplaces(input, errors);
|
|
133
|
+
if (errors.length > 0) {
|
|
134
|
+
return { ok: false, errors };
|
|
135
|
+
}
|
|
136
|
+
return { ok: true, value: deepClone(input) };
|
|
137
|
+
};
|
|
138
|
+
/** Immutable `id@version` ref. */
|
|
139
|
+
export const atomRef = (atom) => `${atom.id}@${atom.version}`;
|
|
140
|
+
/** Canonical bytes: keys sorted by UTF-16 code unit at every depth, 2-space indent, final newline. */
|
|
141
|
+
export const canonicalAtomBytes = (atom) => canonicalJson(atom);
|
package/dist/checks.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ResolvedTheme } from "@anvpx/motion-schema";
|
|
2
|
+
/** WCAG 1.4.11 non-text contrast. */
|
|
3
|
+
export declare const MARK_CONTRAST_MIN = 3;
|
|
4
|
+
/** Minimum pairwise CIE76 delta-E between chart colours, plain and under simulated CVD. */
|
|
5
|
+
export declare const CVD_DELTA_E_MIN = 20;
|
|
6
|
+
export interface ThemeIssue {
|
|
7
|
+
readonly message: string;
|
|
8
|
+
}
|
|
9
|
+
/** Spacing steps that are not whole multiples of 4 px. */
|
|
10
|
+
export declare const spacingIssues: (theme: ResolvedTheme) => ThemeIssue[];
|
|
11
|
+
/** Chart colours below 3:1 against the background colour `bg`. */
|
|
12
|
+
export declare const chartContrastIssues: (theme: ResolvedTheme) => ThemeIssue[];
|
|
13
|
+
/** Palettes whose closest pair falls under delta-E 20 for normal vision, deuteranopia or protanopia. */
|
|
14
|
+
export declare const chartCvdIssues: (theme: ResolvedTheme) => ThemeIssue[];
|
|
15
|
+
/** `$extensions.motion-kit.strictChart: true` makes chart contrast and CVD findings errors (data-first themes). */
|
|
16
|
+
export declare const isStrictChart: (doc: unknown) => boolean;
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { contrastRatio } from "./contrast.js";
|
|
2
|
+
import { CVD_KINDS, minPairwiseDeltaE } from "./cvd.js";
|
|
3
|
+
/** WCAG 1.4.11 non-text contrast. */
|
|
4
|
+
export const MARK_CONTRAST_MIN = 3;
|
|
5
|
+
/** Minimum pairwise CIE76 delta-E between chart colours, plain and under simulated CVD. */
|
|
6
|
+
export const CVD_DELTA_E_MIN = 20;
|
|
7
|
+
const SPACING_GRID = 4;
|
|
8
|
+
/** Spacing steps that are not whole multiples of 4 px. */
|
|
9
|
+
export const spacingIssues = (theme) => Object.entries(theme.spacing)
|
|
10
|
+
.filter(([, px]) => px % SPACING_GRID !== 0)
|
|
11
|
+
.map(([step, px]) => ({
|
|
12
|
+
message: `spacing.${step} is ${px}px, use a multiple of ${SPACING_GRID}`,
|
|
13
|
+
}));
|
|
14
|
+
/** Chart colours below 3:1 against the background colour `bg`. */
|
|
15
|
+
export const chartContrastIssues = (theme) => {
|
|
16
|
+
const bg = theme.colors.bg;
|
|
17
|
+
if (bg === undefined)
|
|
18
|
+
return [];
|
|
19
|
+
return theme.chart.flatMap((colour, index) => {
|
|
20
|
+
const ratio = contrastRatio(colour, bg);
|
|
21
|
+
return ratio >= MARK_CONTRAST_MIN
|
|
22
|
+
? []
|
|
23
|
+
: [
|
|
24
|
+
{
|
|
25
|
+
message: `chart ${index + 1} on bg is ${ratio.toFixed(2)}:1, marks need ${MARK_CONTRAST_MIN}:1`,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
/** Palettes whose closest pair falls under delta-E 20 for normal vision, deuteranopia or protanopia. */
|
|
31
|
+
export const chartCvdIssues = (theme) => [undefined, ...CVD_KINDS].flatMap((kind) => {
|
|
32
|
+
const closest = minPairwiseDeltaE(theme.chart, kind);
|
|
33
|
+
return closest >= CVD_DELTA_E_MIN
|
|
34
|
+
? []
|
|
35
|
+
: [
|
|
36
|
+
{
|
|
37
|
+
message: `chart palette closest pair is dE ${closest.toFixed(1)} under ${kind ?? "normal vision"}, needs ${CVD_DELTA_E_MIN}`,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
});
|
|
41
|
+
/** `$extensions.motion-kit.strictChart: true` makes chart contrast and CVD findings errors (data-first themes). */
|
|
42
|
+
export const isStrictChart = (doc) => {
|
|
43
|
+
const extensions = doc?.$extensions;
|
|
44
|
+
const own = extensions?.["motion-kit"];
|
|
45
|
+
return own?.strictChart === true;
|
|
46
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** `#rgb`, `#rrggbb` or `#rrggbbaa` to three channel bytes; alpha is ignored. */
|
|
2
|
+
export declare const channelsOf: (hex: string) => [number, number, number];
|
|
3
|
+
export declare const toLinearChannel: (channel: number) => number;
|
|
4
|
+
/** WCAG 2.x contrast ratio, 1 (identical) to 21 (black on white). */
|
|
5
|
+
export declare const contrastRatio: (foreground: string, background: string) => number;
|
package/dist/contrast.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const HEX_COLOUR = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
2
|
+
/** `#rgb`, `#rrggbb` or `#rrggbbaa` to three channel bytes; alpha is ignored. */
|
|
3
|
+
export const channelsOf = (hex) => {
|
|
4
|
+
if (!HEX_COLOUR.test(hex))
|
|
5
|
+
throw new Error(`expected a 3-, 6- or 8-digit hex colour like #1a2b3c, got "${hex}"`);
|
|
6
|
+
const digits = hex.slice(1);
|
|
7
|
+
const pairs = digits.length === 3
|
|
8
|
+
? [...digits].map((digit) => digit + digit)
|
|
9
|
+
: [0, 2, 4].map((at) => digits.slice(at, at + 2));
|
|
10
|
+
const [red = 0, green = 0, blue = 0] = pairs.map((pair) => Number.parseInt(pair, 16));
|
|
11
|
+
return [red, green, blue];
|
|
12
|
+
};
|
|
13
|
+
export const toLinearChannel = (channel) => {
|
|
14
|
+
const srgb = channel / 255;
|
|
15
|
+
return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
|
|
16
|
+
};
|
|
17
|
+
const relativeLuminance = (hex) => {
|
|
18
|
+
const [red, green, blue] = channelsOf(hex);
|
|
19
|
+
return (0.2126 * toLinearChannel(red) + 0.7152 * toLinearChannel(green) + 0.0722 * toLinearChannel(blue));
|
|
20
|
+
};
|
|
21
|
+
/** WCAG 2.x contrast ratio, 1 (identical) to 21 (black on white). */
|
|
22
|
+
export const contrastRatio = (foreground, background) => {
|
|
23
|
+
const [lighter = 0, darker = 0] = [
|
|
24
|
+
relativeLuminance(foreground),
|
|
25
|
+
relativeLuminance(background),
|
|
26
|
+
].sort((a, b) => b - a);
|
|
27
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
28
|
+
};
|
package/dist/css.d.ts
ADDED
package/dist/css.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Token keys become custom-property names; camelCase keys (e.g. `bgEnd`) are in use. */
|
|
2
|
+
const TOKEN_KEY = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
3
|
+
/** Anything that could end the declaration, the rule, a comment or the `<style>` element. */
|
|
4
|
+
const UNSAFE_VALUE = /[<>{};\r\n]|\/\*/;
|
|
5
|
+
const checkedKey = (key) => {
|
|
6
|
+
if (!TOKEN_KEY.test(key))
|
|
7
|
+
throw new Error(`invalid token name "${key}"; use letters, digits, -`);
|
|
8
|
+
return key;
|
|
9
|
+
};
|
|
10
|
+
const checkedValue = (name, value) => {
|
|
11
|
+
if (UNSAFE_VALUE.test(value))
|
|
12
|
+
throw new Error(`unsafe CSS value for ${name}: "${value}"`);
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
const checkedVarName = (name) => {
|
|
16
|
+
if (!/^--mk-[a-z][a-z0-9-]*$/.test(name))
|
|
17
|
+
throw new Error(`invalid custom property "${name}"`);
|
|
18
|
+
return name;
|
|
19
|
+
};
|
|
20
|
+
const declaration = (name, value) => [name, checkedValue(name, value)];
|
|
21
|
+
const prefixed = (prefix, values, format) => Object.entries(values).map(([key, value]) => declaration(`--mk-${prefix}-${checkedKey(key)}`, format(value)));
|
|
22
|
+
const declarationsOf = (theme) => [
|
|
23
|
+
...prefixed("color", theme.colors, String),
|
|
24
|
+
...theme.chart.map((color, index) => declaration(`--mk-chart-${index + 1}`, color)),
|
|
25
|
+
...prefixed("font", theme.fonts, String),
|
|
26
|
+
...prefixed("font-size", theme.fontSizes, (px) => `${px}px`),
|
|
27
|
+
...prefixed("space", theme.spacing, (px) => `${px}px`),
|
|
28
|
+
...prefixed("radius", theme.radii, (px) => `${px}px`),
|
|
29
|
+
...prefixed("opacity", theme.opacities, String),
|
|
30
|
+
...prefixed("duration", theme.durations, (seconds) => `${seconds}s`),
|
|
31
|
+
...Object.entries(theme.vars ?? {}).map(([name, value]) => declaration(checkedVarName(name), value)),
|
|
32
|
+
];
|
|
33
|
+
/** Throws on token names or values that could inject CSS or close the `<style>` element. */
|
|
34
|
+
export const toCssVars = (theme) => {
|
|
35
|
+
const lines = declarationsOf(theme).map(([name, value]) => ` ${name}: ${value};`);
|
|
36
|
+
return [":root {", ...lines, "}", ""].join("\n");
|
|
37
|
+
};
|
package/dist/cvd.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const CVD_KINDS: readonly ["deuteranopia", "protanopia"];
|
|
2
|
+
export type CvdKind = (typeof CVD_KINDS)[number];
|
|
3
|
+
/** CIE76 colour difference (Euclidean distance in L*a*b*); 0 identical, 100 black vs white. */
|
|
4
|
+
export declare const deltaE: (a: string, b: string, kind?: CvdKind) => number;
|
|
5
|
+
/** Smallest pairwise delta-E of a palette as seen under `kind`; `Infinity` for fewer than two colours. */
|
|
6
|
+
export declare const minPairwiseDeltaE: (colours: readonly string[], kind?: CvdKind) => number;
|
package/dist/cvd.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { channelsOf, toLinearChannel } from "./contrast.js";
|
|
2
|
+
export const CVD_KINDS = ["deuteranopia", "protanopia"];
|
|
3
|
+
/** Machado, Oliveira and Fernandes 2009, severity 1.0, applied to linear sRGB. */
|
|
4
|
+
const SIMULATION = {
|
|
5
|
+
protanopia: [
|
|
6
|
+
[0.152286, 1.052583, -0.204868],
|
|
7
|
+
[0.114503, 0.786281, 0.099216],
|
|
8
|
+
[-0.003882, -0.048116, 1.051998],
|
|
9
|
+
],
|
|
10
|
+
deuteranopia: [
|
|
11
|
+
[0.367322, 0.860646, -0.227968],
|
|
12
|
+
[0.280085, 0.672501, 0.047413],
|
|
13
|
+
[-0.01182, 0.04294, 0.968881],
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
const multiply = (matrix, [r, g, b]) => [
|
|
17
|
+
matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b,
|
|
18
|
+
matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b,
|
|
19
|
+
matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b,
|
|
20
|
+
];
|
|
21
|
+
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
22
|
+
const SRGB_TO_XYZ = [
|
|
23
|
+
[0.4124564, 0.3575761, 0.1804375],
|
|
24
|
+
[0.2126729, 0.7151522, 0.072175],
|
|
25
|
+
[0.0193339, 0.119192, 0.9503041],
|
|
26
|
+
];
|
|
27
|
+
const WHITE = [0.95047, 1, 1.08883];
|
|
28
|
+
const EPSILON = 216 / 24389;
|
|
29
|
+
const KAPPA = 24389 / 27;
|
|
30
|
+
const labCurve = (ratio) => ratio > EPSILON ? Math.cbrt(ratio) : (KAPPA * ratio + 16) / 116;
|
|
31
|
+
const toLab = (linear) => {
|
|
32
|
+
const [x, y, z] = multiply(SRGB_TO_XYZ, linear);
|
|
33
|
+
const fx = labCurve(x / WHITE[0]);
|
|
34
|
+
const fy = labCurve(y / WHITE[1]);
|
|
35
|
+
const fz = labCurve(z / WHITE[2]);
|
|
36
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
37
|
+
};
|
|
38
|
+
const linearOf = (hex) => {
|
|
39
|
+
const [r, g, b] = channelsOf(hex);
|
|
40
|
+
return [toLinearChannel(r), toLinearChannel(g), toLinearChannel(b)];
|
|
41
|
+
};
|
|
42
|
+
const labOf = (hex, kind) => {
|
|
43
|
+
const linear = linearOf(hex);
|
|
44
|
+
if (kind === undefined)
|
|
45
|
+
return toLab(linear);
|
|
46
|
+
const [r, g, b] = multiply(SIMULATION[kind], linear);
|
|
47
|
+
return toLab([clamp01(r), clamp01(g), clamp01(b)]);
|
|
48
|
+
};
|
|
49
|
+
/** CIE76 colour difference (Euclidean distance in L*a*b*); 0 identical, 100 black vs white. */
|
|
50
|
+
export const deltaE = (a, b, kind) => {
|
|
51
|
+
const [l1, a1, b1] = labOf(a, kind);
|
|
52
|
+
const [l2, a2, b2] = labOf(b, kind);
|
|
53
|
+
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
54
|
+
};
|
|
55
|
+
/** Smallest pairwise delta-E of a palette as seen under `kind`; `Infinity` for fewer than two colours. */
|
|
56
|
+
export const minPairwiseDeltaE = (colours, kind) => {
|
|
57
|
+
let smallest = Number.POSITIVE_INFINITY;
|
|
58
|
+
colours.forEach((first, index) => {
|
|
59
|
+
for (const second of colours.slice(index + 1)) {
|
|
60
|
+
smallest = Math.min(smallest, deltaE(first, second, kind));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return smallest;
|
|
64
|
+
};
|
package/dist/dtcg.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type TokenTree = Readonly<Record<string, unknown>>;
|
|
2
|
+
/** Token name -> raw `$value` for every token directly inside the named group. */
|
|
3
|
+
export declare const readGroup: (tree: TokenTree, group: string) => Record<string, unknown>;
|
|
4
|
+
export declare const toPx: (values: Record<string, unknown>) => Record<string, number>;
|
|
5
|
+
export declare const toSeconds: (values: Record<string, unknown>) => Record<string, number>;
|
|
6
|
+
export declare const toNumbers: (values: Record<string, unknown>) => Record<string, number>;
|
|
7
|
+
/** Colour tokens: hex, a literal-argument colour function or a keyword; never `url()` or another function. */
|
|
8
|
+
export declare const toColours: (values: Record<string, unknown>) => Record<string, string>;
|
|
9
|
+
export declare const toFontStacks: (values: Record<string, unknown>) => Record<string, string>;
|
|
10
|
+
/** Like `readGroup`, but an absent group is an empty record (optional token groups). */
|
|
11
|
+
export declare const readOptionalGroup: (tree: TokenTree, group: string) => Record<string, unknown>;
|
|
12
|
+
export {};
|