made tiny updates
This commit is contained in:
@@ -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