@anvpx/motion-themes 0.0.0-stage → 0.1.1
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/dist/dtcg.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const isTree = (node) => typeof node === "object" && node !== null && !Array.isArray(node);
|
|
2
|
+
/** Token name -> raw `$value` for every token directly inside the named group. */
|
|
3
|
+
export const readGroup = (tree, group) => {
|
|
4
|
+
const node = tree[group];
|
|
5
|
+
if (!isTree(node))
|
|
6
|
+
throw new Error(`theme token group "${group}" is missing`);
|
|
7
|
+
const entries = Object.entries(node)
|
|
8
|
+
.filter(([name]) => !name.startsWith("$"))
|
|
9
|
+
.map(([name, token]) => {
|
|
10
|
+
if (!isTree(token) || !("$value" in token)) {
|
|
11
|
+
throw new Error(`token "${group}.${name}" has no $value`);
|
|
12
|
+
}
|
|
13
|
+
return [name, token.$value];
|
|
14
|
+
});
|
|
15
|
+
return Object.fromEntries(entries);
|
|
16
|
+
};
|
|
17
|
+
const mapValues = (values, convert) => Object.fromEntries(Object.entries(values).map(([name, value]) => [name, convert(value, name)]));
|
|
18
|
+
const asString = (value, name) => {
|
|
19
|
+
if (typeof value !== "string")
|
|
20
|
+
throw new Error(`token "${name}" must be a string`);
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
const parseUnit = (value, name, unit) => {
|
|
24
|
+
const text = asString(value, name);
|
|
25
|
+
if (!text.endsWith(unit))
|
|
26
|
+
throw new Error(`token "${name}" must end in "${unit}", got "${text}"`);
|
|
27
|
+
return Number(text.slice(0, -unit.length));
|
|
28
|
+
};
|
|
29
|
+
export const toPx = (values) => mapValues(values, (value, name) => parseUnit(value, name, "px"));
|
|
30
|
+
export const toSeconds = (values) => mapValues(values, (value, name) => parseUnit(value, name, "ms") / 1000);
|
|
31
|
+
export const toNumbers = (values) => mapValues(values, (value, name) => {
|
|
32
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
33
|
+
throw new Error(`token "${name}" must be a finite number`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
});
|
|
37
|
+
const HEX_COLOUR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
38
|
+
/** Colour functions with literal arguments only: no nested function, quote or escape can load a resource. */
|
|
39
|
+
const FUNCTION_COLOUR = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(\s*[-+0-9a-z.%,/\s]*\)$/i;
|
|
40
|
+
const KEYWORD_COLOUR = /^[a-z]+$/i;
|
|
41
|
+
/** Colour tokens: hex, a literal-argument colour function or a keyword; never `url()` or another function. */
|
|
42
|
+
export const toColours = (values) => mapValues(values, (value, name) => {
|
|
43
|
+
const text = asString(value, name);
|
|
44
|
+
if (![HEX_COLOUR, FUNCTION_COLOUR, KEYWORD_COLOUR].some((grammar) => grammar.test(text))) {
|
|
45
|
+
throw new Error(`token "${name}" must be a plain colour (hex, rgb(), hsl(), hwb(), lab(), lch(), oklab(), oklch() or keyword), got "${text}"`);
|
|
46
|
+
}
|
|
47
|
+
return text;
|
|
48
|
+
});
|
|
49
|
+
const escapeQuoted = (family) => family.replace(/["\\]/g, "\\$&");
|
|
50
|
+
const quoteIfNeeded = (family) => /[\s"\\]/.test(family) ? `"${escapeQuoted(family)}"` : family;
|
|
51
|
+
export const toFontStacks = (values) => mapValues(values, (value, name) => {
|
|
52
|
+
if (!Array.isArray(value))
|
|
53
|
+
throw new Error(`token "${name}" must be a font family array`);
|
|
54
|
+
return value.map((family) => quoteIfNeeded(asString(family, name))).join(", ");
|
|
55
|
+
});
|
|
56
|
+
/** Like `readGroup`, but an absent group is an empty record (optional token groups). */
|
|
57
|
+
export const readOptionalGroup = (tree, group) => isTree(tree[group]) ? readGroup(tree, group) : {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const GRADE_NAMES: readonly ["none", "warm", "cool", "noir"];
|
|
2
|
+
export type GradeName = (typeof GRADE_NAMES)[number];
|
|
3
|
+
export declare const TONE_MAPS: readonly ["none", "hable", "reinhard", "mobius"];
|
|
4
|
+
export type ToneMap = (typeof TONE_MAPS)[number];
|
|
5
|
+
type GradeDoc = Readonly<Record<string, unknown>>;
|
|
6
|
+
declare const isGradeName: (name: string) => name is GradeName;
|
|
7
|
+
export { isGradeName };
|
|
8
|
+
/**
|
|
9
|
+
* DTCG `color.grade.*` tokens parsed with the shared colour grammar.
|
|
10
|
+
* Extra names are rejected so the enum stays small and reviewable.
|
|
11
|
+
*/
|
|
12
|
+
export declare const parseGrades: (doc: GradeDoc) => Record<GradeName, string>;
|
|
13
|
+
/** Curated grades from the shipped DTCG fragment, or a caller-supplied doc. */
|
|
14
|
+
export declare const resolveGrades: (doc?: GradeDoc) => Record<GradeName, string>;
|
|
15
|
+
/** `.cube` LUT shipped as CC0 data in the assets package luts dir; `none` needs no LUT. */
|
|
16
|
+
export declare const gradeLutFile: (name: GradeName) => string | null;
|
|
17
|
+
export declare const gradeToneMap: (name: GradeName) => ToneMap;
|
|
18
|
+
export declare const gradeFilter: (name: GradeName) => string;
|
|
19
|
+
/**
|
|
20
|
+
* Projects grade tokens through the existing CSS custom-property pattern:
|
|
21
|
+
* `--mk-color-grade-<name>` for the DTCG colour plus `--mk-grade-filter/lut/tonemap-<name>`
|
|
22
|
+
* for the runtime mapping. Throws on names or values that could break out of `<style>`.
|
|
23
|
+
*/
|
|
24
|
+
export declare const toGradeCssVars: (grades: Readonly<Record<string, string>>) => string;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readGroup, toColours } from "../dtcg.js";
|
|
2
|
+
import gradeTokens from "./grades.json" with { type: "json" };
|
|
3
|
+
export const GRADE_NAMES = ["none", "warm", "cool", "noir"];
|
|
4
|
+
export const TONE_MAPS = ["none", "hable", "reinhard", "mobius"];
|
|
5
|
+
const isGradeName = (name) => GRADE_NAMES.includes(name);
|
|
6
|
+
export { isGradeName };
|
|
7
|
+
const checkedGrade = (name) => {
|
|
8
|
+
if (!isGradeName(name))
|
|
9
|
+
throw new Error(`unknown grade "${name}"; use one of: ${GRADE_NAMES.join("|")}`);
|
|
10
|
+
return name;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* DTCG `color.grade.*` tokens parsed with the shared colour grammar.
|
|
14
|
+
* Extra names are rejected so the enum stays small and reviewable.
|
|
15
|
+
*/
|
|
16
|
+
export const parseGrades = (doc) => {
|
|
17
|
+
const raw = readGroup(doc, "grade");
|
|
18
|
+
const colours = toColours(raw);
|
|
19
|
+
for (const name of Object.keys(colours)) {
|
|
20
|
+
checkedGrade(name);
|
|
21
|
+
}
|
|
22
|
+
for (const name of GRADE_NAMES) {
|
|
23
|
+
if (colours[name] === undefined)
|
|
24
|
+
throw new Error(`grade "${name}" is missing`);
|
|
25
|
+
}
|
|
26
|
+
return Object.fromEntries(GRADE_NAMES.map((name) => [name, colours[name]]));
|
|
27
|
+
};
|
|
28
|
+
/** Curated grades from the shipped DTCG fragment, or a caller-supplied doc. */
|
|
29
|
+
export const resolveGrades = (doc = gradeTokens) => parseGrades(doc);
|
|
30
|
+
/** `.cube` LUT shipped as CC0 data in the assets package luts dir; `none` needs no LUT. */
|
|
31
|
+
export const gradeLutFile = (name) => {
|
|
32
|
+
checkedGrade(name);
|
|
33
|
+
return name === "none" ? null : `luts/${name}.cube`;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* HDR-prepare tone-map selection only (SDR output stays SDR).
|
|
37
|
+
* Mirrors kajisho5 `HDR_TO_SDR` names: hable / reinhard / mobius.
|
|
38
|
+
*/
|
|
39
|
+
const TONE_MAP_OF = {
|
|
40
|
+
none: "none",
|
|
41
|
+
warm: "hable",
|
|
42
|
+
cool: "reinhard",
|
|
43
|
+
noir: "mobius",
|
|
44
|
+
};
|
|
45
|
+
export const gradeToneMap = (name) => TONE_MAP_OF[checkedGrade(name)];
|
|
46
|
+
/** CSS preview filter; the runtime uses the `.cube` via a three.js LUT pass when available. */
|
|
47
|
+
const FILTER_OF = {
|
|
48
|
+
none: "none",
|
|
49
|
+
warm: "sepia(0.18) saturate(1.15) contrast(1.05)",
|
|
50
|
+
cool: "saturate(1.1) hue-rotate(-8deg) brightness(1.02)",
|
|
51
|
+
noir: "grayscale(1) contrast(1.15)",
|
|
52
|
+
};
|
|
53
|
+
export const gradeFilter = (name) => FILTER_OF[checkedGrade(name)];
|
|
54
|
+
const TOKEN_KEY = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
55
|
+
const UNSAFE_VALUE = /[<>{};\r\n]|\/\*/;
|
|
56
|
+
const checkedKey = (key) => {
|
|
57
|
+
if (!TOKEN_KEY.test(key))
|
|
58
|
+
throw new Error(`invalid token name "${key}"; use letters, digits, -`);
|
|
59
|
+
return key;
|
|
60
|
+
};
|
|
61
|
+
const checkedValue = (name, value) => {
|
|
62
|
+
if (UNSAFE_VALUE.test(value))
|
|
63
|
+
throw new Error(`unsafe CSS value for ${name}: "${value}"`);
|
|
64
|
+
return value;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Projects grade tokens through the existing CSS custom-property pattern:
|
|
68
|
+
* `--mk-color-grade-<name>` for the DTCG colour plus `--mk-grade-filter/lut/tonemap-<name>`
|
|
69
|
+
* for the runtime mapping. Throws on names or values that could break out of `<style>`.
|
|
70
|
+
*/
|
|
71
|
+
export const toGradeCssVars = (grades) => {
|
|
72
|
+
const lines = [];
|
|
73
|
+
for (const name of GRADE_NAMES) {
|
|
74
|
+
const colour = grades[name];
|
|
75
|
+
if (typeof colour !== "string")
|
|
76
|
+
throw new Error(`grade "${name}" is missing`);
|
|
77
|
+
const key = checkedKey(name);
|
|
78
|
+
lines.push(` --mk-color-grade-${key}: ${checkedValue(key, colour)};`);
|
|
79
|
+
lines.push(` --mk-grade-filter-${key}: ${checkedValue(`${key}-filter`, gradeFilter(name))};`);
|
|
80
|
+
const lut = gradeLutFile(name) ?? "none";
|
|
81
|
+
lines.push(` --mk-grade-lut-${key}: ${checkedValue(`${key}-lut`, lut)};`);
|
|
82
|
+
lines.push(` --mk-grade-tonemap-${key}: ${checkedValue(`${key}-tonemap`, gradeToneMap(name))};`);
|
|
83
|
+
}
|
|
84
|
+
return [":root {", ...lines, "}", ""].join("\n");
|
|
85
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$description": "motion-kit grade tokens, DTCG color.grade.* (curated cinematic looks; LUT files ship as CC0 data in the assets luts dir)",
|
|
3
|
+
"grade": {
|
|
4
|
+
"$type": "color",
|
|
5
|
+
"none": {
|
|
6
|
+
"$value": "#ffffff",
|
|
7
|
+
"$description": "Neutral: no primary shift; no LUT."
|
|
8
|
+
},
|
|
9
|
+
"warm": {
|
|
10
|
+
"$value": "#ffb46b",
|
|
11
|
+
"$description": "Warm primary correction: amber lift for skin tones and daylight."
|
|
12
|
+
},
|
|
13
|
+
"cool": {
|
|
14
|
+
"$value": "#6bc5ff",
|
|
15
|
+
"$description": "Cool primary correction: teal-blue lift for night and tech."
|
|
16
|
+
},
|
|
17
|
+
"noir": {
|
|
18
|
+
"$value": "#8a8a8a",
|
|
19
|
+
"$description": "Noir: desaturated high-contrast monochrome."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { calmAccentAtom, calmMotionAtom, calmTitleAtom, syntheticAtomFixtures, } from "./atoms/fixtures.ts";
|
|
2
|
+
export { type AtomRegistry, createAtomRegistry, listVerifiedAtoms, registryCanonicalBytes, resolveAtom, } from "./atoms/registry.ts";
|
|
3
|
+
export { type AtomKind, type AuthoringAtom, atomRef, canonicalAtomBytes, parseAuthoringAtom, } from "./atoms/types.ts";
|
|
4
|
+
export { CVD_DELTA_E_MIN, chartContrastIssues, chartCvdIssues, isStrictChart, MARK_CONTRAST_MIN, spacingIssues, type ThemeIssue, } from "./checks.ts";
|
|
5
|
+
export { contrastRatio } from "./contrast.ts";
|
|
6
|
+
export { toCssVars } from "./css.ts";
|
|
7
|
+
export { CVD_KINDS, type CvdKind, deltaE, minPairwiseDeltaE } from "./cvd.ts";
|
|
8
|
+
export { GRADE_NAMES, type GradeName, gradeFilter, gradeLutFile, gradeToneMap, isGradeName, parseGrades, resolveGrades, TONE_MAPS, type ToneMap, toGradeCssVars, } from "./grade/grades.ts";
|
|
9
|
+
export { mixHex, type OptionalTokens, resolveOptionalTokens } from "./optional.ts";
|
|
10
|
+
export { bundledThemeDocs, resolveTheme, THEME_NAMES, type ThemeName } from "./resolve.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { calmAccentAtom, calmMotionAtom, calmTitleAtom, syntheticAtomFixtures, } from "./atoms/fixtures.js";
|
|
2
|
+
export { createAtomRegistry, listVerifiedAtoms, registryCanonicalBytes, resolveAtom, } from "./atoms/registry.js";
|
|
3
|
+
export { atomRef, canonicalAtomBytes, parseAuthoringAtom, } from "./atoms/types.js";
|
|
4
|
+
export { CVD_DELTA_E_MIN, chartContrastIssues, chartCvdIssues, isStrictChart, MARK_CONTRAST_MIN, spacingIssues, } from "./checks.js";
|
|
5
|
+
export { contrastRatio } from "./contrast.js";
|
|
6
|
+
export { toCssVars } from "./css.js";
|
|
7
|
+
export { CVD_KINDS, deltaE, minPairwiseDeltaE } from "./cvd.js";
|
|
8
|
+
export { GRADE_NAMES, gradeFilter, gradeLutFile, gradeToneMap, isGradeName, parseGrades, resolveGrades, TONE_MAPS, toGradeCssVars, } from "./grade/grades.js";
|
|
9
|
+
export { mixHex, resolveOptionalTokens } from "./optional.js";
|
|
10
|
+
export { bundledThemeDocs, resolveTheme, THEME_NAMES } from "./resolve.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ResolvedTheme } from "@anvpx/motion-schema";
|
|
2
|
+
/** Optional theme tokens (docs/STORE.md 3.3); every field has a default derived from the required keys. */
|
|
3
|
+
export interface OptionalTokens {
|
|
4
|
+
/** Heading stack; defaults to the body (`sans`) stack. */
|
|
5
|
+
readonly fontDisplay: string;
|
|
6
|
+
/** Semantic colours; default to chart slots 3 (green), 7 (amber) and 5 (red). */
|
|
7
|
+
readonly status: {
|
|
8
|
+
readonly good: string;
|
|
9
|
+
readonly warn: string;
|
|
10
|
+
readonly bad: string;
|
|
11
|
+
};
|
|
12
|
+
readonly stroke: {
|
|
13
|
+
readonly card: number;
|
|
14
|
+
readonly icon: number;
|
|
15
|
+
};
|
|
16
|
+
readonly shadow: {
|
|
17
|
+
readonly mode: "none" | "soft" | "hard";
|
|
18
|
+
readonly color: string;
|
|
19
|
+
};
|
|
20
|
+
/** Static film grain opacity, 0..0.12. */
|
|
21
|
+
readonly grainOpacity: number;
|
|
22
|
+
/** Five-step ramp from `accentSoft` to `accent`. */
|
|
23
|
+
readonly chartSequential: readonly string[];
|
|
24
|
+
readonly numeric: "tabular" | "proportional";
|
|
25
|
+
}
|
|
26
|
+
type Doc = Readonly<Record<string, unknown>>;
|
|
27
|
+
/** Linear mix of two hex colours in sRGB; `ratio` 0 gives `from`, 1 gives `to`. */
|
|
28
|
+
export declare const mixHex: (from: string, to: string, ratio: number) => string;
|
|
29
|
+
/**
|
|
30
|
+
* Reads the optional token groups of a DTCG theme document (`status`, `stroke`, `shadow`, `grain`,
|
|
31
|
+
* `chartSequential`, `numeric`; `font.display` arrives through `theme.fonts`) and fills the rest from
|
|
32
|
+
* `theme`. Pure; throws on a malformed token.
|
|
33
|
+
*/
|
|
34
|
+
export declare const resolveOptionalTokens: (theme: ResolvedTheme, doc: Doc) => OptionalTokens;
|
|
35
|
+
/**
|
|
36
|
+
* Custom properties for the optional groups the document actually declares (`status`, `shadow`, `grain`,
|
|
37
|
+
* `stroke`, `numeric`); a group the theme omits yields nothing, so scenes keep their own fallback.
|
|
38
|
+
*/
|
|
39
|
+
export declare const optionalVars: (theme: ResolvedTheme, doc: Doc) => Record<string, string>;
|
|
40
|
+
export {};
|
package/dist/optional.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { channelsOf } from "./contrast.js";
|
|
2
|
+
import { readOptionalGroup, toPx } from "./dtcg.js";
|
|
3
|
+
const SHADOW_MODES = ["none", "soft", "hard"];
|
|
4
|
+
const NUMERIC_MODES = ["tabular", "proportional"];
|
|
5
|
+
const GRAIN_MAX = 0.12;
|
|
6
|
+
const RAMP_STEPS = 5;
|
|
7
|
+
const STATUS_SLOT = { good: 2, warn: 6, bad: 4 };
|
|
8
|
+
const FALLBACK_INK = "#000000";
|
|
9
|
+
const colourOf = (value, name) => {
|
|
10
|
+
if (typeof value !== "string")
|
|
11
|
+
throw new Error(`token "${name}" must be a hex colour string`);
|
|
12
|
+
channelsOf(value);
|
|
13
|
+
return value;
|
|
14
|
+
};
|
|
15
|
+
const oneOf = (value, allowed, name) => {
|
|
16
|
+
const hit = allowed.find((option) => option === value);
|
|
17
|
+
if (hit === undefined)
|
|
18
|
+
throw new Error(`token "${name}" must be one of ${allowed.join("|")}`);
|
|
19
|
+
return hit;
|
|
20
|
+
};
|
|
21
|
+
const hex = (channel) => Math.round(channel).toString(16).padStart(2, "0");
|
|
22
|
+
/** Linear mix of two hex colours in sRGB; `ratio` 0 gives `from`, 1 gives `to`. */
|
|
23
|
+
export const mixHex = (from, to, ratio) => {
|
|
24
|
+
const a = channelsOf(from);
|
|
25
|
+
const b = channelsOf(to);
|
|
26
|
+
const mixed = a.map((channel, index) => channel + ((b[index] ?? 0) - channel) * ratio);
|
|
27
|
+
return `#${mixed.map(hex).join("")}`;
|
|
28
|
+
};
|
|
29
|
+
const rampOf = (theme) => {
|
|
30
|
+
const from = theme.colors.accentSoft ?? theme.colors.bg ?? "#ffffff";
|
|
31
|
+
const to = theme.colors.accent ?? FALLBACK_INK;
|
|
32
|
+
return Array.from({ length: RAMP_STEPS }, (_, step) => mixHex(from, to, step / (RAMP_STEPS - 1)));
|
|
33
|
+
};
|
|
34
|
+
const statusColour = (group, key, theme) => {
|
|
35
|
+
const given = group[key];
|
|
36
|
+
if (given !== undefined)
|
|
37
|
+
return colourOf(given, `status.${key}`);
|
|
38
|
+
return theme.chart[STATUS_SLOT[key]] ?? theme.colors.accent ?? FALLBACK_INK;
|
|
39
|
+
};
|
|
40
|
+
const numberAt = (group, key, fallback) => {
|
|
41
|
+
const value = group[key];
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return fallback;
|
|
44
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
45
|
+
throw new Error(`token "${key}" must be a finite number`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
};
|
|
49
|
+
const strokeOf = (doc, key, fallback) => {
|
|
50
|
+
const given = readOptionalGroup(doc, "stroke")[key];
|
|
51
|
+
return given === undefined ? fallback : (toPx({ [key]: given })[key] ?? fallback);
|
|
52
|
+
};
|
|
53
|
+
const sequentialOf = (doc, theme) => {
|
|
54
|
+
const given = Object.entries(readOptionalGroup(doc, "chartSequential"))
|
|
55
|
+
.sort(([a], [b]) => Number(a) - Number(b))
|
|
56
|
+
.map(([name, value]) => colourOf(value, `chartSequential.${name}`));
|
|
57
|
+
if (given.length === 0)
|
|
58
|
+
return rampOf(theme);
|
|
59
|
+
if (given.length !== RAMP_STEPS) {
|
|
60
|
+
throw new Error(`chartSequential needs ${RAMP_STEPS} steps, got ${given.length}`);
|
|
61
|
+
}
|
|
62
|
+
return given;
|
|
63
|
+
};
|
|
64
|
+
const shadowOf = (doc, theme) => {
|
|
65
|
+
const group = readOptionalGroup(doc, "shadow");
|
|
66
|
+
return {
|
|
67
|
+
mode: oneOf(group.mode ?? "none", SHADOW_MODES, "shadow.mode"),
|
|
68
|
+
color: group.color === undefined
|
|
69
|
+
? (theme.colors.fg ?? FALLBACK_INK)
|
|
70
|
+
: colourOf(group.color, "shadow.color"),
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const grainOf = (doc) => {
|
|
74
|
+
const grain = numberAt(readOptionalGroup(doc, "grain"), "opacity", 0);
|
|
75
|
+
if (grain < 0 || grain > GRAIN_MAX) {
|
|
76
|
+
throw new Error(`token "grain.opacity" must be 0..${GRAIN_MAX}`);
|
|
77
|
+
}
|
|
78
|
+
return grain;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Reads the optional token groups of a DTCG theme document (`status`, `stroke`, `shadow`, `grain`,
|
|
82
|
+
* `chartSequential`, `numeric`; `font.display` arrives through `theme.fonts`) and fills the rest from
|
|
83
|
+
* `theme`. Pure; throws on a malformed token.
|
|
84
|
+
*/
|
|
85
|
+
export const resolveOptionalTokens = (theme, doc) => {
|
|
86
|
+
const status = readOptionalGroup(doc, "status");
|
|
87
|
+
return {
|
|
88
|
+
fontDisplay: theme.fonts.display ?? theme.fonts.sans ?? "system-ui, sans-serif",
|
|
89
|
+
status: {
|
|
90
|
+
good: statusColour(status, "good", theme),
|
|
91
|
+
warn: statusColour(status, "warn", theme),
|
|
92
|
+
bad: statusColour(status, "bad", theme),
|
|
93
|
+
},
|
|
94
|
+
stroke: { card: strokeOf(doc, "card", 0), icon: strokeOf(doc, "icon", 1.75) },
|
|
95
|
+
shadow: shadowOf(doc, theme),
|
|
96
|
+
grainOpacity: grainOf(doc),
|
|
97
|
+
chartSequential: sequentialOf(doc, theme),
|
|
98
|
+
numeric: oneOf(readOptionalGroup(doc, "numeric").mode ?? "tabular", NUMERIC_MODES, "numeric.mode"),
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
const SHADOW_SOFT = "0 24px 56px -28px";
|
|
102
|
+
const SHADOW_HARD = "8px 8px 0";
|
|
103
|
+
const SHADOW_SOFT_MIX = 40;
|
|
104
|
+
const GRAIN_TILE = 256;
|
|
105
|
+
const GRAIN_FREQUENCY = 0.8;
|
|
106
|
+
const GRAIN_SEED = 7;
|
|
107
|
+
const shadowValue = ({ mode, color }) => {
|
|
108
|
+
if (mode === "none")
|
|
109
|
+
return "none";
|
|
110
|
+
if (mode === "hard")
|
|
111
|
+
return `${SHADOW_HARD} ${color}`;
|
|
112
|
+
return `${SHADOW_SOFT} color-mix(in srgb, ${color} ${SHADOW_SOFT_MIX}%, transparent)`;
|
|
113
|
+
};
|
|
114
|
+
/** Static, seeded noise tile as a percent-encoded data URI (no network, deterministic). */
|
|
115
|
+
const grainImage = (opacity) => {
|
|
116
|
+
const svg = [
|
|
117
|
+
`<svg xmlns='http://www.w3.org/2000/svg' width='${GRAIN_TILE}' height='${GRAIN_TILE}'>`,
|
|
118
|
+
`<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='${GRAIN_FREQUENCY}' numOctaves='2' seed='${GRAIN_SEED}' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter>`,
|
|
119
|
+
`<rect width='100%' height='100%' filter='url(#n)' opacity='${opacity}'/></svg>`,
|
|
120
|
+
].join("");
|
|
121
|
+
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
|
122
|
+
};
|
|
123
|
+
/** `--mk-status-<key>` for each status colour the document declares. */
|
|
124
|
+
const statusVars = (tokens, doc) => {
|
|
125
|
+
const group = readOptionalGroup(doc, "status");
|
|
126
|
+
const keys = ["good", "warn", "bad"].filter((key) => group[key] !== undefined);
|
|
127
|
+
return Object.fromEntries(keys.map((key) => [`--mk-status-${key}`, tokens.status[key]]));
|
|
128
|
+
};
|
|
129
|
+
/**
|
|
130
|
+
* Custom properties for the optional groups the document actually declares (`status`, `shadow`, `grain`,
|
|
131
|
+
* `stroke`, `numeric`); a group the theme omits yields nothing, so scenes keep their own fallback.
|
|
132
|
+
*/
|
|
133
|
+
export const optionalVars = (theme, doc) => {
|
|
134
|
+
const tokens = resolveOptionalTokens(theme, doc);
|
|
135
|
+
const vars = {};
|
|
136
|
+
const has = (group, key) => readOptionalGroup(doc, group)[key] !== undefined;
|
|
137
|
+
if (has("shadow", "mode"))
|
|
138
|
+
vars["--mk-shadow-card"] = shadowValue(tokens.shadow);
|
|
139
|
+
if (has("grain", "opacity") && tokens.grainOpacity > 0) {
|
|
140
|
+
vars["--mk-grain-image"] = grainImage(tokens.grainOpacity);
|
|
141
|
+
}
|
|
142
|
+
Object.assign(vars, statusVars(tokens, doc));
|
|
143
|
+
if (has("stroke", "card"))
|
|
144
|
+
vars["--mk-stroke-card"] = `${tokens.stroke.card}px`;
|
|
145
|
+
if (has("stroke", "icon"))
|
|
146
|
+
vars["--mk-stroke-icon"] = `${tokens.stroke.icon}px`;
|
|
147
|
+
if (has("numeric", "mode")) {
|
|
148
|
+
vars["--mk-numeric"] = tokens.numeric === "tabular" ? "tabular-nums" : "normal";
|
|
149
|
+
}
|
|
150
|
+
return vars;
|
|
151
|
+
};
|