made tiny updates

This commit is contained in:
SinachPat
2026-05-04 12:30:56 +01:00
parent 3f029e15c2
commit 5b2d918c13
47 changed files with 2597 additions and 521 deletions
+44 -96
View File
@@ -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'];
+7 -6
View File
@@ -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;
}
+294 -18
View File
@@ -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 };
}