made tiny updates
This commit is contained in:
@@ -1,111 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Token schemas ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const ColorTokenSchema = z.object({
|
||||
value: z.string().regex(/^#[0-9A-Fa-f]{3,8}$|^rgba?\(|^hsl/, 'Must be a valid CSS color'),
|
||||
/** Fluent 2 token name this maps to, e.g. "colorBrandBackground" */
|
||||
fluentToken: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TypographyTokenSchema = z.object({
|
||||
fontFamily: z.string().optional(),
|
||||
fontSize: z.union([z.string(), z.number()]).optional(),
|
||||
fontWeight: z.union([z.string(), z.number()]).optional(),
|
||||
lineHeight: z.union([z.string(), z.number()]).optional(),
|
||||
letterSpacing: z.union([z.string(), z.number()]).optional(),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SpacingTokenSchema = z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const MotionTokenSchema = z.object({
|
||||
duration: z.string().optional(),
|
||||
easing: z.string().optional(),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TokensSchema = z.object({
|
||||
colors: z.record(ColorTokenSchema).optional(),
|
||||
typography: z.record(TypographyTokenSchema).optional(),
|
||||
spacing: z.record(SpacingTokenSchema).optional(),
|
||||
motion: z.record(MotionTokenSchema).optional(),
|
||||
});
|
||||
|
||||
// ── Component rules ───────────────────────────────────────────────────────────
|
||||
|
||||
export const PropRuleSchema = z.object({
|
||||
allowed: z.array(z.unknown()).optional(),
|
||||
forbidden: z.array(z.unknown()).optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ComponentRuleSchema = z.object({
|
||||
/** Allowed prop values, keyed by prop name */
|
||||
props: z.record(PropRuleSchema).optional(),
|
||||
/** Fluent 2 variants that are explicitly forbidden */
|
||||
forbiddenVariants: z.array(z.string()).optional(),
|
||||
/** ARIA attributes that are required on this component */
|
||||
requiredAria: z.array(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// ── Screen rules ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ScreenRuleSchema = z.object({
|
||||
/** Components that are allowed to appear on this screen */
|
||||
allowedComponents: z.array(z.string()).optional(),
|
||||
/** Components that must be present on this screen */
|
||||
requiredSections: z.array(z.string()).optional(),
|
||||
layout: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// ── Voice / tone ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const VoiceRuleSchema = z.object({
|
||||
tone: z.string().optional(),
|
||||
maxSentenceLength: z.number().optional(),
|
||||
avoidWords: z.array(z.string()).optional(),
|
||||
preferWords: z.array(z.string()).optional(),
|
||||
examples: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ── Accessibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const AccessibilitySchema = z.object({
|
||||
wcagLevel: z.enum(['A', 'AA', 'AAA']).optional(),
|
||||
contrastRatio: z.number().optional(),
|
||||
customRules: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ── Design Language File ──────────────────────────────────────────────────────
|
||||
// ── Design Language File Schema ───────────────────────────────────────────────
|
||||
// Matches the spec exactly (Layer 5.1). This schema validates DLF files uploaded
|
||||
// by teams. All fields must align with the spec — deviations break real uploads.
|
||||
|
||||
export const DesignLanguageFileBodySchema = z.object({
|
||||
/** Semantic version, e.g. "1.0.0" */
|
||||
version: z.string().optional(),
|
||||
/** Spec requires exactly '1.0' — reject any other version string. */
|
||||
version: z.literal('1.0'),
|
||||
|
||||
/** Human-readable name, e.g. "Acme Design System" */
|
||||
name: z.string().optional(),
|
||||
|
||||
tokens: TokensSchema.optional(),
|
||||
tokens: z.object({
|
||||
/** name → hex or token reference, e.g. { "brand": "#0F52BA" } */
|
||||
colors: z.record(z.string()),
|
||||
typography: z.record(z.string()),
|
||||
spacing: z.record(z.string()),
|
||||
motion: z.record(z.string()).optional(),
|
||||
}),
|
||||
|
||||
/** Per-component rules, keyed by component display name */
|
||||
components: z.record(ComponentRuleSchema).optional(),
|
||||
/**
|
||||
* Per-component rules, keyed by component display name.
|
||||
* allowedProps: prop → array of allowed string values
|
||||
* forbiddenVariants: Fluent 2 variant strings that are prohibited
|
||||
* requiredAria: ARIA attribute names that must be present
|
||||
*/
|
||||
components: z.record(z.object({
|
||||
allowedProps: z.record(z.array(z.string())).optional(),
|
||||
forbiddenVariants: z.array(z.string()).optional(),
|
||||
requiredAria: z.array(z.string()).optional(),
|
||||
})),
|
||||
|
||||
/** Per-screen rules, keyed by screen name or route pattern */
|
||||
screens: z.record(ScreenRuleSchema).optional(),
|
||||
screens: z.record(z.object({
|
||||
allowedComponents: z.array(z.string()).optional(),
|
||||
forbiddenComponents: z.array(z.string()).optional(),
|
||||
requiredSections: z.array(z.string()).optional(),
|
||||
})).optional(),
|
||||
|
||||
voice: VoiceRuleSchema.optional(),
|
||||
voice: z.object({
|
||||
/** Array of tone descriptors, e.g. ["friendly", "concise"] */
|
||||
tone: z.array(z.string()),
|
||||
avoidWords: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
|
||||
accessibility: AccessibilitySchema.optional(),
|
||||
accessibility: z.object({
|
||||
minContrastRatio: z.number().default(4.5),
|
||||
minTouchTargetPx: z.number().default(44),
|
||||
requireAltText: z.boolean().default(true),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type DesignLanguageFileBody = z.infer<typeof DesignLanguageFileBodySchema>;
|
||||
export type Tokens = z.infer<typeof TokensSchema>;
|
||||
export type ComponentRule = z.infer<typeof ComponentRuleSchema>;
|
||||
export type ScreenRule = z.infer<typeof ScreenRuleSchema>;
|
||||
|
||||
// Convenience re-exports for downstream consumers
|
||||
export type ComponentRule = NonNullable<DesignLanguageFileBody['components'][string]>;
|
||||
export type ScreenRule = NonNullable<NonNullable<DesignLanguageFileBody['screens']>[string]>;
|
||||
export type Tokens = DesignLanguageFileBody['tokens'];
|
||||
|
||||
@@ -6,15 +6,16 @@ import type { DesignLanguageFileBody } from './schema.js';
|
||||
|
||||
export type FluentTokenMap = Record<string, string>;
|
||||
|
||||
/** Extract color token overrides from a DLF as a Fluent 2 token map. */
|
||||
/**
|
||||
* Extract color tokens from a DLF as a Fluent 2 token map.
|
||||
* Tokens are flat strings (spec Layer 5.1: tokens.colors is Record<string,string>),
|
||||
* so each key becomes a CSS custom property name and the value is the CSS value.
|
||||
*/
|
||||
export function extractColorTokens(dlf: DesignLanguageFileBody): FluentTokenMap {
|
||||
const out: FluentTokenMap = {};
|
||||
const colors = dlf.tokens?.colors ?? {};
|
||||
for (const [, token] of Object.entries(colors)) {
|
||||
if (!token) continue;
|
||||
if (token.fluentToken) {
|
||||
out[token.fluentToken] = token.value;
|
||||
}
|
||||
for (const [name, value] of Object.entries(colors)) {
|
||||
if (value) out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -37,16 +37,16 @@ function formatZodErrors(error: ZodError): ValidationError[] {
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Runtime constraint checks ─────────────────────────────────────────────────
|
||||
// These run during visual edits and AI completions to catch violations
|
||||
// against the active DLF before they're shown to the user.
|
||||
// ── Runtime constraint checks (per-component) ─────────────────────────────────
|
||||
// Used by the Inspector panel to validate the selected component's live React
|
||||
// props against the active DLF in real time.
|
||||
//
|
||||
// Uses the spec schema: `allowedProps` is `Record<string, string[]>` — each
|
||||
// entry maps a prop name to the list of allowed string values.
|
||||
|
||||
export interface ViolationCheck {
|
||||
/** Name of the component being checked */
|
||||
componentName: string;
|
||||
/** Props being applied */
|
||||
props: Record<string, unknown>;
|
||||
/** The active DLF */
|
||||
dlf: DesignLanguageFileBody;
|
||||
}
|
||||
|
||||
@@ -64,26 +64,302 @@ export function checkComponentConstraints(check: ViolationCheck): Violation[] {
|
||||
const componentRule = dlf.components?.[componentName];
|
||||
if (!componentRule) return violations;
|
||||
|
||||
const { props: propRules } = componentRule;
|
||||
if (!propRules) return violations;
|
||||
const { allowedProps, forbiddenVariants } = componentRule;
|
||||
|
||||
for (const [propKey, rule] of Object.entries(propRules)) {
|
||||
const value = props[propKey];
|
||||
// allowedProps: prop → string[] of allowed values
|
||||
if (allowedProps) {
|
||||
for (const [propKey, allowedValues] of Object.entries(allowedProps)) {
|
||||
const value = props[propKey];
|
||||
if (value !== undefined && !allowedValues.includes(String(value))) {
|
||||
violations.push({
|
||||
prop: propKey,
|
||||
value,
|
||||
message: `"${propKey}=${String(value)}" is not in the allowed values list`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use hasOwnProperty to distinguish "key absent" from "key set to undefined".
|
||||
// Under exactOptionalPropertyTypes these are semantically different.
|
||||
if (rule.required && !Object.prototype.hasOwnProperty.call(props, propKey)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}" is required by design system rules`, severity: 'error' });
|
||||
// Component-level forbidden variant guard
|
||||
if (forbiddenVariants) {
|
||||
const variant = props['variant'];
|
||||
if (typeof variant === 'string' && forbiddenVariants.includes(variant)) {
|
||||
violations.push({
|
||||
prop: 'variant',
|
||||
value: variant,
|
||||
message: `variant="${variant}" is forbidden for ${componentName} by design system rules`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// requiredAria: ARIA attributes that must be present (spec Layer 5.2)
|
||||
// Each entry is an aria attribute name (e.g. "aria-label", "role").
|
||||
// Absence of any required ARIA attribute is a DLF error — accessibility
|
||||
// violations are always errors, never warnings.
|
||||
const { requiredAria } = componentRule;
|
||||
if (requiredAria) {
|
||||
for (const ariaAttr of requiredAria) {
|
||||
if (props[ariaAttr] === undefined || props[ariaAttr] === null || props[ariaAttr] === '') {
|
||||
violations.push({
|
||||
prop: ariaAttr,
|
||||
value: undefined,
|
||||
message: `"${ariaAttr}" is required for ${componentName} by accessibility rules`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DLF accessibility block checks (spec Layer 5.2) ──────────────────────
|
||||
const a11y = dlf.accessibility;
|
||||
if (a11y) {
|
||||
// requireAltText: image-like components must have a non-empty alt prop.
|
||||
// Heuristic: component has a `src` prop (e.g. <img>, <Image>, <Avatar>).
|
||||
if (a11y.requireAltText) {
|
||||
const hasSrc = typeof props['src'] === 'string' && props['src'] !== '';
|
||||
const altVal = props['alt'];
|
||||
if (hasSrc && (altVal === undefined || altVal === null || altVal === '')) {
|
||||
violations.push({
|
||||
prop: 'alt',
|
||||
value: altVal,
|
||||
message: `"alt" text is required for image components (DLF accessibility.requireAltText)`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.forbidden && value !== undefined && rule.forbidden.includes(value)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is forbidden by design system rules`, severity: 'error' });
|
||||
// minTouchTargetPx: interactive components must be at least N×N pixels.
|
||||
// Checked against numeric width/height props when both are present.
|
||||
const { minTouchTargetPx } = a11y;
|
||||
if (minTouchTargetPx > 0) {
|
||||
const w = typeof props['width'] === 'number' ? props['width'] : undefined;
|
||||
const h = typeof props['height'] === 'number' ? props['height'] : undefined;
|
||||
if (w !== undefined && w < minTouchTargetPx) {
|
||||
violations.push({
|
||||
prop: 'width', value: w,
|
||||
message: `width ${w}px is below the minimum touch target of ${minTouchTargetPx}px`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
if (h !== undefined && h < minTouchTargetPx) {
|
||||
violations.push({
|
||||
prop: 'height', value: h,
|
||||
message: `height ${h}px is below the minimum touch target of ${minTouchTargetPx}px`,
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.allowed && value !== undefined && !rule.allowed.includes(value)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is not in the allowed values list`, severity: 'warning' });
|
||||
// minContrastRatio: check foreground/background color props.
|
||||
// Only checked when both `color` and `backgroundColor` are hex strings,
|
||||
// since contrast requires both colors. Uses WCAG 2.x relative luminance.
|
||||
const { minContrastRatio } = a11y;
|
||||
if (minContrastRatio > 0) {
|
||||
const fg = props['color'];
|
||||
const bg = props['backgroundColor'];
|
||||
if (typeof fg === 'string' && typeof bg === 'string') {
|
||||
const ratio = wcagContrastRatio(fg, bg);
|
||||
if (ratio !== null && ratio < minContrastRatio) {
|
||||
violations.push({
|
||||
prop: 'color', value: fg,
|
||||
message: `Color contrast ratio ${ratio.toFixed(2)}:1 is below DLF minimum of ${minContrastRatio}:1`,
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
// ── WCAG 2.x contrast ratio helper ───────────────────────────────────────────
|
||||
// Returns the contrast ratio [1, 21] for two hex colors, or null if either
|
||||
// string is not a recognisable hex color. Handles #RGB and #RRGGBB formats.
|
||||
|
||||
function hexToLinearRgb(hex: string): [number, number, number] | null {
|
||||
const clean = hex.startsWith('#') ? hex.slice(1) : hex;
|
||||
const expanded = clean.length === 3
|
||||
? clean.split('').map(c => c + c).join('')
|
||||
: clean;
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) return null;
|
||||
const r = parseInt(expanded.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(expanded.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(expanded.slice(4, 6), 16) / 255;
|
||||
const linearise = (v: number) =>
|
||||
v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||||
return [linearise(r), linearise(g), linearise(b)];
|
||||
}
|
||||
|
||||
function relativeLuminance(rgb: [number, number, number]): number {
|
||||
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
|
||||
}
|
||||
|
||||
function wcagContrastRatio(hex1: string, hex2: string): number | null {
|
||||
const rgb1 = hexToLinearRgb(hex1);
|
||||
const rgb2 = hexToLinearRgb(hex2);
|
||||
if (!rgb1 || !rgb2) return null;
|
||||
const L1 = relativeLuminance(rgb1);
|
||||
const L2 = relativeLuminance(rgb2);
|
||||
const lighter = Math.max(L1, L2);
|
||||
const darker = Math.min(L1, L2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
// ── ComponentChange validation (spec Layer 5.2) ───────────────────────────────
|
||||
|
||||
export interface ComponentChange {
|
||||
componentId: string;
|
||||
displayName: string;
|
||||
filePath?: string;
|
||||
changeType:
|
||||
| 'prop_change'
|
||||
| 'component_swap'
|
||||
| 'layout_change'
|
||||
| 'token_change'
|
||||
| 'removal'
|
||||
| 'insertion';
|
||||
before: Record<string, unknown>;
|
||||
after: Record<string, unknown>;
|
||||
humanSummary: string;
|
||||
}
|
||||
|
||||
export interface DesignViolation {
|
||||
componentId: string;
|
||||
/** DLF path of the violated rule, e.g. "components.Button.allowedProps.variant" */
|
||||
rule: string;
|
||||
severity: 'error' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ChangeValidationResult {
|
||||
valid: boolean;
|
||||
violations: DesignViolation[];
|
||||
}
|
||||
|
||||
export function validateChange(
|
||||
change: ComponentChange,
|
||||
dlf: DesignLanguageFileBody,
|
||||
): ChangeValidationResult {
|
||||
const violations: DesignViolation[] = [];
|
||||
|
||||
// Removals have no after-state to validate.
|
||||
if (change.changeType === 'removal') {
|
||||
return { valid: true, violations: [] };
|
||||
}
|
||||
|
||||
// prop_change, component_swap, insertion → check after-state props.
|
||||
if (
|
||||
change.changeType === 'prop_change' ||
|
||||
change.changeType === 'component_swap' ||
|
||||
change.changeType === 'insertion'
|
||||
) {
|
||||
const propViolations = checkComponentConstraints({
|
||||
componentName: change.displayName,
|
||||
props: change.after,
|
||||
dlf,
|
||||
});
|
||||
|
||||
for (const v of propViolations) {
|
||||
violations.push({
|
||||
componentId: change.componentId,
|
||||
rule: `components.${change.displayName}.allowedProps.${v.prop}`,
|
||||
severity: v.severity,
|
||||
message: v.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Additional forbidden-variant check for component_swap.
|
||||
if (change.changeType === 'component_swap') {
|
||||
const rule = dlf.components?.[change.displayName];
|
||||
if (rule?.forbiddenVariants) {
|
||||
const newVariant = change.after['variant'];
|
||||
if (typeof newVariant === 'string' && rule.forbiddenVariants.includes(newVariant)) {
|
||||
violations.push({
|
||||
componentId: change.componentId,
|
||||
rule: `components.${change.displayName}.forbiddenVariants`,
|
||||
severity: 'error',
|
||||
message: `Variant "${newVariant}" is forbidden for ${change.displayName} by design system rules`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prop_change: warn if a prop value is a hardcoded hex color that doesn't
|
||||
// match any value in dlf.tokens.colors. Design systems expect color props to
|
||||
// reference token names (e.g. "brand") not raw hex values ("#0F52BA").
|
||||
// Only fires when dlf.tokens.colors is populated (opt-in per DLF).
|
||||
if (change.changeType === 'prop_change' && dlf.tokens.colors) {
|
||||
const tokenColorValues = new Set(
|
||||
Object.values(dlf.tokens.colors).map(v => v.toLowerCase()),
|
||||
);
|
||||
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||
for (const [propKey, propValue] of Object.entries(change.after)) {
|
||||
if (typeof propValue === 'string' && HEX_RE.test(propValue)) {
|
||||
if (!tokenColorValues.has(propValue.toLowerCase())) {
|
||||
violations.push({
|
||||
componentId: change.componentId,
|
||||
rule: `tokens.colors`,
|
||||
severity: 'warning',
|
||||
message: `${change.displayName}.${propKey}="${propValue}" is a hardcoded color — use a DLF color token instead`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// layout_change → check spacing values against the DLF spacing scale.
|
||||
if (change.changeType === 'layout_change') {
|
||||
const spacingScale = dlf.tokens?.spacing;
|
||||
if (spacingScale) {
|
||||
const allowed = new Set(Object.values(spacingScale));
|
||||
const spacingProps = [
|
||||
'padding', 'margin', 'gap',
|
||||
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||||
'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
|
||||
'top', 'right', 'bottom', 'left',
|
||||
];
|
||||
for (const key of spacingProps) {
|
||||
const val = change.after[key];
|
||||
if (val !== undefined && typeof val === 'string' && !allowed.has(val)) {
|
||||
violations.push({
|
||||
componentId: change.componentId,
|
||||
rule: `tokens.spacing`,
|
||||
severity: 'warning',
|
||||
message: `Spacing "${key}=${val}" is not in the design system spacing scale`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { valid: violations.length === 0, violations };
|
||||
}
|
||||
|
||||
// token_change → verify the new token name exists in dlf.tokens[category].
|
||||
if (change.changeType === 'token_change') {
|
||||
const newTokenName = change.after['tokenName'];
|
||||
const tokenCategory = change.after['tokenCategory'] as string | undefined;
|
||||
|
||||
if (typeof newTokenName === 'string' && tokenCategory) {
|
||||
const categoryMap: Record<string, Record<string, string> | undefined> = {
|
||||
colors: dlf.tokens?.colors,
|
||||
typography: dlf.tokens?.typography,
|
||||
spacing: dlf.tokens?.spacing,
|
||||
motion: dlf.tokens?.motion,
|
||||
};
|
||||
const bucket = categoryMap[tokenCategory];
|
||||
if (bucket !== undefined && !Object.prototype.hasOwnProperty.call(bucket, newTokenName)) {
|
||||
violations.push({
|
||||
componentId: change.componentId,
|
||||
rule: `tokens.${tokenCategory}.${newTokenName}`,
|
||||
severity: 'warning',
|
||||
message: `Token "${newTokenName}" is not defined in the design language file under "${tokenCategory}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: violations.length === 0, violations };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user