made tiny updates

This commit is contained in:
SinachPat
2026-05-05 22:06:31 +01:00
parent 19b8f29523
commit fdf64ee72c
39 changed files with 5499 additions and 381 deletions
+2
View File
@@ -11,9 +11,11 @@
"test": "vitest run --coverage"
},
"dependencies": {
"culori": "^3.3.0",
"zod": "^3.0.0"
},
"devDependencies": {
"@types/culori": "^4.0.1",
"@vitest/coverage-v8": "^2.1.9",
"typescript": "^5.5.0",
"vitest": "^2.1.9"
+2
View File
@@ -1,3 +1,5 @@
export * from './schema.js';
export * from './validator.js';
export * from './tokens.js';
export * from './parser.js';
export * from './resolver.js';
+303
View File
@@ -0,0 +1,303 @@
/**
* parser.ts — Phase 6
*
* Parses design token files into the normalised DesignToken[] format used by
* the canvas store and token resolver.
*
* Supported input formats (auto-detected from the top-level structure):
* 1. W3C DTCG — uses $value / $type fields (https://design-tokens.github.io/community-group/format/)
* 2. Style Dictionary — nested groups, each leaf has { value, ... }
* 3. Flat CSS variable map — { "--color-primary": "#FF0066", ... }
*
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.2 "Design Language System"
*/
// ── DesignToken type (mirrors canvas.types.ts in the app package) ─────────────
// Duplicated here to avoid a cross-package import at runtime.
export type TokenType =
| 'color'
| 'spacing'
| 'sizing'
| 'borderRadius'
| 'borderWidth'
| 'fontFamily'
| 'fontSize'
| 'fontWeight'
| 'lineHeight'
| 'letterSpacing'
| 'shadow'
| 'opacity'
| 'other';
export interface DesignToken {
/** CSS custom property name: "--color-primary" */
key: string;
/** Human label: "Color / Primary" */
name: string;
/** Top-level group: "color", "spacing", etc. */
group: string;
/** Resolved CSS value: "#0066FF" */
rawValue: string;
type: TokenType;
description?: string;
/** If resolved from an alias chain, lists each intermediate key. */
aliasChain?: string[];
}
// ── Format detection ──────────────────────────────────────────────────────────
type TokenFileFormat = 'dtcg' | 'style-dictionary' | 'flat-css-vars';
function detectFormat(raw: Record<string, unknown>): TokenFileFormat {
// Flat CSS var map: all keys start with "--"
const keys = Object.keys(raw);
if (keys.length > 0 && keys.every((k) => k.startsWith('--'))) {
return 'flat-css-vars';
}
// DTCG: any value node uses $value / $type
const hasW3cNodes = keys.some((k) => {
const v = raw[k];
return v && typeof v === 'object' && ('$value' in (v as object) || '$type' in (v as object));
});
if (hasW3cNodes) return 'dtcg';
// Assume Style Dictionary for everything else
return 'style-dictionary';
}
// ── Flat CSS var format ───────────────────────────────────────────────────────
function parseFlatCssVars(raw: Record<string, unknown>): DesignToken[] {
const tokens: DesignToken[] = [];
for (const [key, val] of Object.entries(raw)) {
if (!key.startsWith('--')) continue;
const rawValue = String(val ?? '').trim();
if (!rawValue) continue;
// Derive group from the property name: "--color-primary-500" → "color"
const withoutDashes = key.replace(/^--/, '');
const parts = withoutDashes.split('-');
const group = parts[0] ?? 'other';
const name = parts.slice(1).map(capitalise).join(' ') || withoutDashes;
tokens.push({
key,
name: `${capitalise(group)} / ${name}`,
group,
rawValue,
type: inferTokenType(group, rawValue),
});
}
return tokens;
}
// ── W3C DTCG format ───────────────────────────────────────────────────────────
function parseDtcg(raw: Record<string, unknown>, pathParts: string[] = []): DesignToken[] {
const tokens: DesignToken[] = [];
for (const [key, val] of Object.entries(raw)) {
if (key.startsWith('$')) continue; // skip $metadata, $description etc.
if (!val || typeof val !== 'object') continue;
const node = val as Record<string, unknown>;
if ('$value' in node) {
// Leaf token
const rawValue = resolveAlias(String(node['$value'] ?? '').trim(), raw);
if (!rawValue) continue;
const fullPath = [...pathParts, key];
const group = fullPath[0] ?? 'other';
// §9.2: each segment passes through toKebabCase before joining so that
// camelCase group names (e.g. "borderRadius") produce correct CSS names.
const cssKey = `--${fullPath.map(toKebabCase).join('-').replace(/\s+/g, '-')}`;
tokens.push({
key: cssKey,
name: fullPath.map(capitalise).join(' / '),
group,
rawValue,
type: inferTokenTypeFromDtcg(String(node['$type'] ?? ''), rawValue),
...(typeof node['$description'] === 'string' && { description: node['$description'] }),
});
} else {
// Group node — recurse
tokens.push(...parseDtcg(node, [...pathParts, key]));
}
}
return tokens;
}
// ── Style Dictionary format ───────────────────────────────────────────────────
function parseStyleDictionary(raw: Record<string, unknown>, pathParts: string[] = []): DesignToken[] {
const tokens: DesignToken[] = [];
for (const [key, val] of Object.entries(raw)) {
if (!val || typeof val !== 'object') continue;
const node = val as Record<string, unknown>;
if ('value' in node && typeof node['value'] !== 'object') {
// Leaf token
const rawValue = String(node['value'] ?? '').trim();
if (!rawValue) continue;
const fullPath = [...pathParts, key];
const group = fullPath[0] ?? 'other';
// §9.2: each segment passes through toKebabCase before joining.
const cssKey = `--${fullPath.map(toKebabCase).join('-').replace(/\s+/g, '-')}`;
tokens.push({
key: cssKey,
name: fullPath.map(capitalise).join(' / '),
group,
rawValue,
type: inferTokenType(group, rawValue),
...(typeof node['comment'] === 'string' && { description: node['comment'] }),
});
} else {
// Group node — recurse
tokens.push(...parseStyleDictionary(node, [...pathParts, key]));
}
}
return tokens;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Parse a raw token file (parsed JSON/JS object) into a flat DesignToken[].
* Auto-detects the format from the object structure.
*
* @param raw Parsed token file contents (not a JSON string — call JSON.parse first)
* @returns Flat array of normalised design tokens
* @throws If the input is not a plain object or if parsing fails
*/
export function parseTokenFile(raw: unknown): DesignToken[] {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('Token file must be a plain JSON object');
}
const obj = raw as Record<string, unknown>;
const format = detectFormat(obj);
switch (format) {
case 'flat-css-vars':
return parseFlatCssVars(obj);
case 'dtcg':
return parseDtcg(obj);
case 'style-dictionary':
return parseStyleDictionary(obj);
}
}
/**
* Parse a JSON string token file.
*/
export function parseTokenFileJson(jsonString: string): DesignToken[] {
let raw: unknown;
try {
raw = JSON.parse(jsonString);
} catch (err) {
throw new Error(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
}
return parseTokenFile(raw);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function capitalise(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Convert a camelCase or PascalCase path segment to kebab-case.
* Used when building CSS custom property names from token path segments so that
* e.g. `{ "borderRadius": { "sm": ... } }` → `--border-radius-sm` instead of
* `--borderradius-sm`.
*
* spec: SOURCE-AWARE-CANVAS §9.2 "camelCase segments are converted to kebab-case"
*
* Examples:
* "borderRadius" → "border-radius"
* "fontSize" → "font-size"
* "Color" → "color" (leading-hyphen guard prevents "---color-primary")
* "BoxShadow" → "box-shadow"
*/
export function toKebabCase(s: string): string {
return s
.replace(/([A-Z])/g, '-$1')
.toLowerCase()
.replace(/^-/, ''); // strip leading hyphen produced by an initial uppercase letter
}
function inferTokenType(group: string, value: string): TokenType {
const g = group.toLowerCase();
if (g === 'color' || g === 'colors' || g === 'colour') return 'color';
if (g === 'spacing' || g === 'space') return 'spacing';
if (g === 'sizing' || g === 'size') return 'sizing';
if (g === 'radius' || g === 'border-radius' || g === 'borderradius') return 'borderRadius';
if (g === 'border-width' || g === 'borderwidth') return 'borderWidth';
if (g === 'font-family' || g === 'fontfamily') return 'fontFamily';
if (g === 'font-size' || g === 'fontsize') return 'fontSize';
if (g === 'font-weight' || g === 'fontweight') return 'fontWeight';
if (g === 'line-height' || g === 'lineheight') return 'lineHeight';
if (g === 'letter-spacing' || g === 'letterspacing') return 'letterSpacing';
if (g === 'shadow' || g === 'box-shadow') return 'shadow';
if (g === 'opacity') return 'opacity';
// Fallback: infer from value
return inferTokenTypeFromValue(value);
}
function inferTokenTypeFromDtcg(dtcgType: string, value: string): TokenType {
switch (dtcgType.toLowerCase()) {
case 'color': return 'color';
case 'dimension':
case 'spacing': return 'spacing';
case 'font-family': return 'fontFamily';
case 'font-size': return 'fontSize';
case 'font-weight': return 'fontWeight';
case 'line-height': return 'lineHeight';
case 'letter-spacing':return 'letterSpacing';
case 'shadow': return 'shadow';
case 'opacity': return 'opacity';
case 'border-radius': return 'borderRadius';
default: return inferTokenTypeFromValue(value);
}
}
function inferTokenTypeFromValue(value: string): TokenType {
const v = value.toLowerCase().trim();
if (v.startsWith('#') || v.startsWith('rgb') || v.startsWith('hsl') || v.startsWith('oklch')) return 'color';
if (v.endsWith('px') || v.endsWith('rem') || v.endsWith('em')) return 'spacing';
if (v.includes('shadow') || v.includes('blur')) return 'shadow';
return 'other';
}
/**
* Resolve a DTCG alias like "{color.primary.500}" to a flat CSS value.
* Returns the alias string unchanged if it cannot be resolved.
*/
function resolveAlias(value: string, root: Record<string, unknown>): string {
const aliasMatch = value.match(/^\{(.+)\}$/);
if (!aliasMatch?.[1]) return value;
const path = aliasMatch[1].split('.');
let cursor: unknown = root;
for (const segment of path) {
if (!cursor || typeof cursor !== 'object') return value;
cursor = (cursor as Record<string, unknown>)[segment];
}
if (cursor && typeof cursor === 'object' && '$value' in (cursor as object)) {
return String((cursor as Record<string, unknown>)['$value'] ?? value);
}
if (typeof cursor === 'string') return cursor;
return value;
}
+206
View File
@@ -0,0 +1,206 @@
/**
* resolver.ts — Phase 6
*
* Resolves a live CSS value (computed from the selected element) to the closest
* matching design token, using OKLCH perceptual color distance for colors and
* exact/near-match for numeric values.
*
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.3 "Token Resolver"
*/
import { formatHex, oklch, parse as culoriParse, differenceCiede2000 } from 'culori';
import type { DesignToken } from './parser.js';
export interface TokenMatch {
token: DesignToken;
/** Value matches token exactly (distance === 0). */
exact: boolean;
/** 0 = exact; higher = further from a match. Max useful threshold ≈ 10. */
distance: number;
}
// ── Color resolution ──────────────────────────────────────────────────────────
const colorDifference = differenceCiede2000();
/**
* Compute the perceptual color distance between two CSS color strings.
* Uses CIEDE2000 via culori. Returns Infinity if either value cannot be parsed.
*/
function colorDistance(a: string, b: string): number {
try {
const ca = culoriParse(a);
const cb = culoriParse(b);
if (!ca || !cb) return Infinity;
return colorDifference(ca, cb);
} catch {
return Infinity;
}
}
// ── Numeric resolution ────────────────────────────────────────────────────────
function parseNumericPx(value: string): number | null {
const v = value.trim();
if (v.endsWith('px')) {
const n = parseFloat(v);
return isNaN(n) ? null : n;
}
if (v.endsWith('rem')) {
const n = parseFloat(v);
return isNaN(n) ? null : n * 16; // normalise with standard 16px base
}
const n = parseFloat(v);
if (!isNaN(n) && v === String(n)) return n;
return null;
}
function numericDistance(a: string, b: string): number {
const na = parseNumericPx(a);
const nb = parseNumericPx(b);
if (na === null || nb === null) return Infinity;
return Math.abs(na - nb);
}
// ── Public API ────────────────────────────────────────────────────────────────
const COLOR_DISTANCE_THRESHOLD = 10; // CIEDE2000 units (perceptible but close)
const NUMERIC_DISTANCE_THRESHOLD = 2; // px
/**
* Find the best matching design token for a given CSS value.
*
* @param cssValue A computed CSS value string (e.g. "rgb(0, 102, 255)", "16px")
* @param tokens The loaded token array from the canvas store
* @param rootFontSizePx Optional root font size for rem → px normalisation (default 16)
* @returns TokenMatch or null if no match is within the acceptable threshold
*/
export function resolveValueToToken(
cssValue: string,
tokens: DesignToken[],
rootFontSizePx = 16,
): TokenMatch | null {
if (!tokens.length || !cssValue.trim()) return null;
// Normalise the input value to a canonical form
const normalised = normaliseCssValue(cssValue.trim(), rootFontSizePx);
let bestMatch: TokenMatch | null = null;
let bestDistance = Infinity;
for (const token of tokens) {
const tokenNorm = normaliseCssValue(token.rawValue, rootFontSizePx);
let distance: number;
if (token.type === 'color') {
distance = colorDistance(normalised, tokenNorm);
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
} else {
distance = numericDistance(normalised, tokenNorm);
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
}
if (distance < bestDistance) {
bestDistance = distance;
bestMatch = { token, exact: distance === 0, distance };
}
}
return bestMatch;
}
/**
* Find ALL tokens that closely match a value, sorted by distance (closest first).
* Useful for the TokenPicker dropdown.
*
* @param limit Maximum number of matches to return (default 5)
*/
export function resolveValueToTokens(
cssValue: string,
tokens: DesignToken[],
rootFontSizePx = 16,
limit = 5,
): TokenMatch[] {
if (!tokens.length || !cssValue.trim()) return [];
const normalised = normaliseCssValue(cssValue.trim(), rootFontSizePx);
const matches: TokenMatch[] = [];
for (const token of tokens) {
const tokenNorm = normaliseCssValue(token.rawValue, rootFontSizePx);
let distance: number;
if (token.type === 'color') {
distance = colorDistance(normalised, tokenNorm);
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
} else {
distance = numericDistance(normalised, tokenNorm);
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
}
matches.push({ token, exact: distance === 0, distance });
}
return matches
.sort((a, b) => a.distance - b.distance)
.slice(0, limit);
}
/**
* Given a token key (CSS custom property), look it up in the token array.
*/
export function findTokenByKey(key: string, tokens: DesignToken[]): DesignToken | undefined {
return tokens.find((t) => t.key === key);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Normalise a CSS value to a canonical form for comparison.
* - Colors: convert to hex via culori
* - rem values: convert to px using the root font size
* - Everything else: lowercase trim
*/
function normaliseCssValue(value: string, rootFontSizePx: number): string {
const v = value.trim().toLowerCase();
// Try color parsing
try {
const parsed = culoriParse(v);
if (parsed) {
return formatHex(parsed) ?? v;
}
} catch { /* not a color */ }
// rem → px
if (v.endsWith('rem')) {
const n = parseFloat(v);
if (!isNaN(n)) return `${n * rootFontSizePx}px`;
}
return v;
}
// ── OKLCH color info (for UI display) ────────────────────────────────────────
export interface OklchInfo {
l: number; // lightness 01
c: number; // chroma
h: number; // hue 0360
}
/**
* Parse a CSS color string into OKLCH components.
* Returns null if the color cannot be parsed.
*/
export function parseOklch(color: string): OklchInfo | null {
try {
const parsed = culoriParse(color);
if (!parsed) return null;
const ok = oklch(parsed);
if (!ok) return null;
return { l: ok.l ?? 0, c: ok.c ?? 0, h: ok.h ?? 0 };
} catch {
return null;
}
}