From 83e56ba8dd4ee9551c973607eec09538395033b3 Mon Sep 17 00:00:00 2001 From: Mike Long <120058955+mlnexla@users.noreply.github.com> Date: Thu, 8 Jan 2026 16:22:01 -0800 Subject: [PATCH] feat: add color checking capability - Add allowedColors to contract schema and types - Implement color extraction from CSS files and components - Add color validation in compliance checks - Update CLI to report color violations - Fix parseColorValue to only accept known CSS named colors - Add color tests to test suites - Update test fixtures with allowedColors --- .../dist/commands/validate.d.ts | 14 + .../dist/commands/validate.d.ts.map | 1 + .../dist/commands/validate.js | 418 ++++++++++++++ .../dist/descriptors/static-analysis.d.ts | 20 + .../dist/descriptors/static-analysis.d.ts.map | 1 + .../dist/descriptors/static-analysis.js | 521 ++++++++++++++++++ packages/interfacectl-cli/dist/index.d.ts | 3 + packages/interfacectl-cli/dist/index.d.ts.map | 1 + packages/interfacectl-cli/dist/index.js | 57 ++ .../interfacectl-cli/src/commands/validate.ts | 8 + .../src/descriptors/static-analysis.ts | 200 +++++++ .../contracts/ui.contract.json | 5 + .../test/static-analysis.test.mjs | 25 +- .../interfacectl-validator/dist/index.d.ts | 2 +- .../dist/index.d.ts.map | 2 +- packages/interfacectl-validator/dist/index.js | 15 + .../schema/surfaces.web.contract.schema.json | 10 + .../interfacectl-validator/dist/types.d.ts | 8 +- .../dist/types.d.ts.map | 2 +- packages/interfacectl-validator/src/index.ts | 17 + .../schema/surfaces.web.contract.schema.json | 10 + packages/interfacectl-validator/src/types.ts | 8 + .../test/evaluate-contract.test.mjs | 82 +-- 23 files changed, 1389 insertions(+), 41 deletions(-) create mode 100644 packages/interfacectl-cli/dist/commands/validate.d.ts create mode 100644 packages/interfacectl-cli/dist/commands/validate.d.ts.map create mode 100644 packages/interfacectl-cli/dist/commands/validate.js create mode 100644 packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts create mode 100644 packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map create mode 100644 packages/interfacectl-cli/dist/descriptors/static-analysis.js create mode 100644 packages/interfacectl-cli/dist/index.d.ts create mode 100644 packages/interfacectl-cli/dist/index.d.ts.map create mode 100644 packages/interfacectl-cli/dist/index.js diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts b/packages/interfacectl-cli/dist/commands/validate.d.ts new file mode 100644 index 0000000..acb8148 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts @@ -0,0 +1,14 @@ +type OutputFormat = "text" | "json"; +export interface ValidateCommandOptions { + contractPath?: string; + schemaPath?: string; + workspaceRoot?: string; + surfaceFilters?: string[]; + outputFormat?: OutputFormat; + outputPath?: string; + configPath?: string; + configProvided?: boolean; +} +export declare function runValidateCommand(options: ValidateCommandOptions): Promise; +export {}; +//# sourceMappingURL=validate.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts.map b/packages/interfacectl-cli/dist/commands/validate.d.ts.map new file mode 100644 index 0000000..716dfe7 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAgBA,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAkCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CA4NjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js new file mode 100644 index 0000000..9cf5362 --- /dev/null +++ b/packages/interfacectl-cli/dist/commands/validate.js @@ -0,0 +1,418 @@ +import path from "node:path"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import pc from "picocolors"; +import { validateContractStructure, evaluateContractCompliance, getBundledContractSchema, } from "@surfaces/interfacectl-validator"; +import { collectSurfaceDescriptors, } from "../descriptors/static-analysis.js"; +export async function runValidateCommand(options) { + const workspaceRoot = path.resolve(options.workspaceRoot ?? process.cwd()); + const contractInput = options.contractPath ?? "contracts/surfaces.web.contract.json"; + const contractPath = path.isAbsolute(contractInput) + ? contractInput + : path.resolve(workspaceRoot, contractInput); + const schemaPath = options.schemaPath + ? path.isAbsolute(options.schemaPath) + ? options.schemaPath + : path.resolve(workspaceRoot, options.schemaPath) + : undefined; + const outputFormat = options.outputFormat ?? "text"; + const isJson = outputFormat === "json"; + const outputPath = options.outputPath + ? path.isAbsolute(options.outputPath) + ? options.outputPath + : path.resolve(workspaceRoot, options.outputPath) + : undefined; + const configInput = options.configPath ?? "interfacectl.config.json"; + const configWasExplicit = Boolean(options.configProvided); + const configPath = path.isAbsolute(configInput) + ? configInput + : path.resolve(workspaceRoot, configInput); + const textReporter = createTextReporter({ + capture: Boolean(outputPath) && !isJson, + print: !isJson, + }); + const findings = []; + let surfaceRootMap = new Map(); + const finalize = async (exitCode, contractVersion) => { + if (isJson) { + const payload = buildJsonResult(contractPath, contractVersion ?? null, findings); + const serialized = `${JSON.stringify(payload, null, 2)}\n`; + if (outputPath) { + await writeFileWithParents(outputPath, serialized); + } + else { + process.stdout.write(serialized); + } + return exitCode; + } + if (outputPath) { + const contents = textReporter.lines.length > 0 + ? `${textReporter.lines.join("\n")}\n` + : ""; + await writeFileWithParents(outputPath, contents); + } + return exitCode; + }; + const contractSource = await loadJson(contractPath, "contract"); + if (!contractSource.ok) { + const message = `Failed to read contract JSON: ${contractSource.error}`; + if (!isJson) { + printHeader(pc.red("✖ Failed to read contract JSON"), textReporter); + textReporter.error(pc.red(contractSource.error)); + } + findings.push({ + code: "contract.read-error", + severity: "error", + message, + location: contractPath, + }); + return finalize(2, null); + } + const initialContractVersion = extractContractVersion(contractSource.value); + const configResult = await loadConfigFile(configPath); + if (configResult.ok) { + surfaceRootMap = new Map(Object.entries(configResult.config.surfaceRoots ?? {}).map(([surfaceId, surfaceRoot]) => [surfaceId, surfaceRoot])); + } + else if (!(configResult.reason === "missing" && !configWasExplicit)) { + const message = `Failed to load config: ${configResult.error}`; + if (!isJson) { + printHeader(pc.red("✖ Failed to load config"), textReporter); + textReporter.error(pc.red(configResult.error)); + } + findings.push({ + code: "config.load-error", + severity: "error", + message, + location: configPath, + }); + return finalize(2, initialContractVersion); + } + const schemaSource = schemaPath + ? await loadJson(schemaPath, "schema", true) + : { + ok: true, + value: getBundledContractSchema(), + }; + const schema = schemaSource.ok === true ? schemaSource.value : undefined; + if (schemaSource.ok === false && !schemaSource.optional) { + const message = `Failed to read contract schema: ${schemaSource.error}`; + if (!isJson) { + printHeader(pc.red("✖ Failed to read contract schema"), textReporter); + textReporter.error(pc.red(schemaSource.error)); + } + findings.push({ + code: "contract.schema-load-error", + severity: "error", + message, + location: schemaPath, + }); + return finalize(2, initialContractVersion); + } + const structureResult = schema + ? validateContractStructure(contractSource.value, schema) + : { + ok: true, + errors: [], + contract: contractSource.value, + }; + if (!structureResult.ok || !structureResult.contract) { + if (!isJson) { + printHeader(pc.red("✖ Contract structure validation failed"), textReporter); + for (const error of structureResult.errors) { + textReporter.error(pc.red(` • ${error}`)); + } + } + else { + for (const error of structureResult.errors) { + findings.push({ + code: "contract.schema-error", + severity: "error", + message: error, + }); + } + } + return finalize(2, initialContractVersion); + } + const contract = structureResult.contract; + const surfaceFilters = new Set((options.surfaceFilters ?? []).map((value) => value.trim())); + const structuralDescriptorResult = await collectSurfaceDescriptors({ + workspaceRoot, + contract, + surfaceFilters, + surfaceRootMap, + }); + if (structuralDescriptorResult.warnings.length > 0) { + if (!isJson) { + printHeader(pc.yellow("⚠ Surface descriptor warnings"), textReporter); + for (const warning of structuralDescriptorResult.warnings) { + textReporter.warn(pc.yellow(` • ${warning.message}`)); + } + } + for (const warning of structuralDescriptorResult.warnings) { + findings.push(issueToFinding(warning, "warning")); + } + } + if (structuralDescriptorResult.errors.length > 0) { + if (!isJson) { + printHeader(pc.red("✖ Surface descriptor errors"), textReporter); + for (const error of structuralDescriptorResult.errors) { + textReporter.error(pc.red(` • ${error.message}`)); + } + } + for (const error of structuralDescriptorResult.errors) { + findings.push(issueToFinding(error, "error")); + } + return finalize(2, contract.version ?? initialContractVersion); + } + const summary = evaluateContractCompliance(contract, structuralDescriptorResult.descriptors); + const violationFindings = mapViolationsToFindings(summary); + findings.push(...violationFindings); + if (!isJson) { + printSummary(summary, textReporter); + } + const hasErrors = violationFindings.some((finding) => finding.severity === "error"); + return finalize(hasErrors ? 1 : 0, contract.version ?? initialContractVersion); +} +async function loadJson(filePath, label, optional = false) { + try { + const raw = await readFile(filePath, "utf-8"); + return { ok: true, value: JSON.parse(raw) }; + } + catch (error) { + if (error.code === "ENOENT") { + const message = `${label} file not found at ${filePath}`; + return { ok: false, error: message, optional }; + } + return { + ok: false, + error: `Failed to read ${label} file at ${filePath}: ${error.message}`, + optional, + }; + } +} +function buildJsonResult(contractPath, contractVersion, findings) { + const summary = summarizeFindings(findings); + return { contractPath, contractVersion, summary, findings }; +} +function summarizeFindings(findings) { + let errors = 0; + let warnings = 0; + for (const finding of findings) { + if (finding.severity === "error") { + errors += 1; + } + else { + warnings += 1; + } + } + return { errors, warnings }; +} +function issueToFinding(issue, severity) { + return { + code: issue.code, + severity, + surface: issue.surfaceId, + message: issue.message, + location: issue.location, + }; +} +function mapViolationsToFindings(summary) { + const findings = []; + const { contract } = summary; + const codeMap = { + "unknown-surface": "surface.unknown", + "descriptor-missing": "descriptor.missing", + "descriptor-unused": "descriptor.unused", + "missing-section": "section.missing", + "unknown-section": "section.unexpected", + "font-not-allowed": "font.disallowed", + "color-not-allowed": "color.disallowed", + "layout-width-exceeded": "layout.width-exceeded", + "layout-width-undetermined": "layout.width-undetermined", + "layout-container-missing": "layout.container-missing", + "motion-duration-not-allowed": "motion.duration", + "motion-timing-not-allowed": "motion.timing", + }; + for (const report of summary.surfaceReports) { + for (const violation of report.violations) { + const details = (violation.details ?? {}); + const finding = { + code: codeMap[violation.type] ?? violation.type, + severity: "error", + surface: violation.surfaceId, + message: violation.message, + }; + if (typeof details.source === "string") { + finding.location = details.source; + } + switch (violation.type) { + case "missing-section": { + finding.expected = details.sectionId ?? details.requiredSections; + finding.found = null; + break; + } + case "unknown-section": { + finding.expected = contract.sections.map((section) => section.id); + finding.found = details.sectionId; + break; + } + case "font-not-allowed": { + finding.expected = Array.isArray(details.allowedFonts) + ? details.allowedFonts + : undefined; + finding.found = details.font; + break; + } + case "color-not-allowed": { + finding.expected = Array.isArray(details.allowedColors) + ? details.allowedColors + : undefined; + finding.found = details.color; + break; + } + case "layout-width-undetermined": { + finding.expected = details.expectedMaxWidth; + finding.found = null; + break; + } + case "layout-width-exceeded": { + finding.expected = details.allowedWidth; + finding.found = details.reportedWidth; + break; + } + case "layout-container-missing": { + finding.expected = + details.requiredContainers ?? details.requiredContainer; + finding.found = + details.missingContainers ?? details.containerSources; + break; + } + case "motion-duration-not-allowed": { + finding.expected = details.allowedDurations; + finding.found = details.durationMs; + break; + } + case "motion-timing-not-allowed": { + finding.expected = details.allowedTimingFunctions; + finding.found = details.timingFunction; + break; + } + case "unknown-surface": { + finding.expected = contract.surfaces.map((surface) => surface.id); + finding.found = violation.surfaceId; + break; + } + case "descriptor-missing": { + finding.expected = details.requiredSections; + finding.found = null; + break; + } + case "descriptor-unused": { + finding.expected = contract.surfaces.map((surface) => surface.id); + finding.found = violation.surfaceId; + break; + } + default: + break; + } + findings.push(finding); + } + } + return findings; +} +async function writeFileWithParents(filePath, contents) { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, contents, "utf8"); +} +async function loadConfigFile(configPath) { + try { + const raw = await readFile(configPath, "utf-8"); + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + throw new Error("Config must be a JSON object"); + } + return { ok: true, config: parsed }; + } + catch (error) { + if (error.code === "ENOENT") { + return { + ok: false, + reason: "missing", + error: `Config file not found at ${configPath}`, + }; + } + const message = error instanceof SyntaxError + ? `Config file at ${configPath} is not valid JSON: ${error.message}` + : `Failed to read config file at ${configPath}: ${error.message}`; + return { + ok: false, + reason: "invalid", + error: message, + }; + } +} +function createTextReporter(options) { + const lines = []; + const record = (line) => { + if (options.capture) { + lines.push(line); + } + }; + return { + lines, + log(line = "") { + record(line); + if (options.print) { + console.log(line); + } + }, + warn(line) { + record(line); + if (options.print) { + console.warn(line); + } + }, + error(line) { + record(line); + if (options.print) { + console.error(line); + } + }, + }; +} +function extractContractVersion(value) { + if (value && + typeof value === "object" && + "version" in value) { + const candidate = value.version; + return typeof candidate === "string" ? candidate : null; + } + return null; +} +function printSummary(summary, output) { + const violations = summary.surfaceReports.flatMap((report) => report.violations); + if (violations.length === 0) { + printHeader(pc.green("✔ All surfaces comply with the contract"), output); + for (const report of summary.surfaceReports) { + output.log(pc.green(` • ${report.surfaceId} ✅`)); + } + return; + } + printHeader(pc.red("✖ Contract violations detected"), output); + for (const report of summary.surfaceReports) { + if (report.violations.length === 0) { + output.log(pc.green(` • ${report.surfaceId}: OK`)); + continue; + } + output.log(pc.red(` • ${report.surfaceId}:`)); + for (const violation of report.violations) { + output.log(pc.red(` - ${violation.message}`)); + if (violation.details) { + output.log(pc.dim(` details: ${JSON.stringify(violation.details, null, 2).replaceAll("\n", "\n ")}`)); + } + } + } +} +function printHeader(message, output) { + output.log(); + output.log(message); +} diff --git a/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts b/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts new file mode 100644 index 0000000..13f9881 --- /dev/null +++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts @@ -0,0 +1,20 @@ +import { type InterfaceContract, type SurfaceDescriptor } from "@surfaces/interfacectl-validator"; +export interface DescriptorIssue { + surfaceId?: string; + code: string; + message: string; + location?: string; +} +export interface CollectSurfaceDescriptorsOptions { + workspaceRoot: string; + contract: InterfaceContract; + surfaceFilters: Set; + surfaceRootMap: Map; +} +export interface CollectSurfaceDescriptorsResult { + descriptors: SurfaceDescriptor[]; + warnings: DescriptorIssue[]; + errors: DescriptorIssue[]; +} +export declare function collectSurfaceDescriptors(options: CollectSurfaceDescriptorsOptions): Promise; +//# sourceMappingURL=static-analysis.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map b/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map new file mode 100644 index 0000000..989948e --- /dev/null +++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"static-analysis.d.ts","sourceRoot":"","sources":["../../src/descriptors/static-analysis.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EAMvB,MAAM,kCAAkC,CAAC;AAiC1C,MAAM,WAAW,eAAe;IAC9B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,gCAAgC;IAC/C,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAC5B,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,+BAA+B;IAC9C,WAAW,EAAE,iBAAiB,EAAE,CAAC;IACjC,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,+BAA+B,CAAC,CAyC1C"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/descriptors/static-analysis.js b/packages/interfacectl-cli/dist/descriptors/static-analysis.js new file mode 100644 index 0000000..54a0e4b --- /dev/null +++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.js @@ -0,0 +1,521 @@ +import path from "node:path"; +import { readFile, stat } from "node:fs/promises"; +import { globby } from "globby"; +const SECTION_ATTRIBUTE_REGEX = /data-(?:contract-)?section\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const CONTAINER_ATTRIBUTE_REGEX = /data-contract-container\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const CONTRACT_CONTAINER_TOKEN = "contract-container"; +const COMMON_GLOBBY_IGNORES = [ + "**/node_modules/**", + "**/.next/**", + "**/dist/**", + "**/.turbo/**", + "**/__tests__/**", + "**/?(*.)+(spec|test).[tj]s?(x)", +]; +const FONT_VAR_REGEX = /var\((--font-[a-z0-9-]+)\)/gi; +const FONT_FAMILY_REGEX = /font-family\s*:\s*([^;]+);/gi; +const COLOR_VAR_REGEX = /var\((--color-[a-z0-9-]+)\)/gi; +const COLOR_DECL_REGEX = /(?:color|background-color|background|border-color|border-top-color|border-right-color|border-bottom-color|border-left-color|outline-color|text-decoration-color|caret-color|column-rule-color)\s*:\s*([^;]+);/gi; +const MAX_WIDTH_VAR_REGEX = /--contract-max-width\s*:\s*([0-9.]+)\s*(px|rem|em)/i; +const MOTION_DURATION_VAR_REGEX = /--contract-motion-duration\s*:\s*([0-9.]+)\s*(ms|s)/i; +const MOTION_TIMING_VAR_REGEX = /--contract-motion-timing\s*:\s*([a-z-]+)\s*;/i; +const TRANSITION_DECL_REGEX = /transition[^:]*:\s*([^;]+);/gi; +const DURATION_DECL_REGEX = /(animation|transition)-duration\s*:\s*([^;]+);/gi; +const TIMING_DECL_REGEX = /(animation|transition)-timing-function\s*:\s*([^;]+);/gi; +export async function collectSurfaceDescriptors(options) { + const structuralDescriptors = []; + const warnings = []; + const errors = []; + for (const surface of options.contract.surfaces) { + if (options.surfaceFilters.size > 0 && + !options.surfaceFilters.has(surface.id)) { + continue; + } + const surfaceRoot = resolveSurfaceRoot(options.workspaceRoot, surface, options.surfaceRootMap); + if (!(await pathExists(surfaceRoot))) { + errors.push({ + surfaceId: surface.id, + code: "surface.missing", + message: `Surface "${surface.id}" expected at ${surfaceRoot} but directory was not found.`, + location: surfaceRoot, + }); + continue; + } + const descriptorResult = await extractSurfaceDescriptor(options.workspaceRoot, surfaceRoot, surface.id); + structuralDescriptors.push(descriptorResult.descriptor); + warnings.push(...descriptorResult.warnings); + errors.push(...descriptorResult.errors); + } + return { descriptors: structuralDescriptors, warnings, errors }; +} +function resolveSurfaceRoot(workspaceRoot, surface, surfaceRootMap) { + const configuredRoot = surfaceRootMap.get(surface.id); + if (configuredRoot) { + return path.resolve(workspaceRoot, configuredRoot); + } + return path.join(workspaceRoot, "apps", surface.id); +} +async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId) { + const warnings = []; + const errors = []; + const sectionFiles = await globby(["app/**/*.{ts,tsx,js,jsx}"], { + cwd: surfaceRoot, + absolute: true, + gitignore: true, + ignore: COMMON_GLOBBY_IGNORES, + }); + const fileContentCache = new Map(); + const sections = await extractSections(sectionFiles, workspaceRoot, fileContentCache); + if (sections.length === 0) { + warnings.push({ + surfaceId, + code: "sections.none-detected", + message: `No sections discovered for surface "${surfaceId}". Ensure elements include data-contract-section attributes.`, + }); + } + const layoutCssFiles = await globby(["app/**/*.css"], { + cwd: surfaceRoot, + absolute: true, + gitignore: true, + ignore: COMMON_GLOBBY_IGNORES, + }); + const layout = await extractLayout(layoutCssFiles, sectionFiles, workspaceRoot, fileContentCache); + const fonts = await extractFonts(surfaceRoot, sectionFiles, workspaceRoot, fileContentCache); + if (fonts.length === 0) { + const globalsPath = path.join(surfaceRoot, "app", "globals.css"); + warnings.push({ + surfaceId, + code: "fonts.none-detected", + message: `No fonts detected for surface "${surfaceId}". Verify font variables in layout.tsx or CSS font declarations.`, + location: (await pathExists(globalsPath)) + ? path.relative(workspaceRoot, globalsPath) + : undefined, + }); + } + const colors = await extractColors(surfaceRoot, layoutCssFiles, sectionFiles, workspaceRoot, fileContentCache); + if (colors.length === 0) { + const globalsPath = path.join(surfaceRoot, "app", "globals.css"); + warnings.push({ + surfaceId, + code: "colors.none-detected", + message: `No colors detected for surface "${surfaceId}". Verify color variables or CSS color declarations.`, + location: (await pathExists(globalsPath)) + ? path.relative(workspaceRoot, globalsPath) + : undefined, + }); + } + const motion = await extractMotion(layoutCssFiles, workspaceRoot, fileContentCache); + if (motion.length === 0) { + warnings.push({ + surfaceId, + code: "motion.none-detected", + message: `No motion declarations detected for surface "${surfaceId}".`, + }); + } + const structuralSurfaceDescriptor = { + surfaceId, + sections, + fonts, + colors, + layout, + motion, + }; + return { descriptor: structuralSurfaceDescriptor, warnings, errors }; +} +async function extractSections(filePaths, workspaceRoot, fileContentCache) { + const sections = new Map(); + for (const filePath of filePaths) { + const source = path.relative(workspaceRoot, filePath); + const content = await readFileCached(filePath, fileContentCache); + let match; + while ((match = SECTION_ATTRIBUTE_REGEX.exec(content)) !== null) { + const id = match[1] ?? match[2] ?? match[3] ?? match[4] ?? ""; + if (!id) { + continue; + } + if (!sections.has(id)) { + sections.set(id, { + id, + source, + }); + } + } + } + return [...sections.values()].sort((a, b) => a.id.localeCompare(b.id)); +} +async function extractFonts(surfaceRoot, sectionFiles, workspaceRoot, fileContentCache) { + const fontValues = new Map(); + const layoutPath = path.join(surfaceRoot, "app", "layout.tsx"); + if (await pathExists(layoutPath)) { + const layoutContent = await readFileCached(layoutPath, fileContentCache); + collectFontsFromContent(layoutContent, path.relative(workspaceRoot, layoutPath), fontValues); + } + const globalsPath = path.join(surfaceRoot, "app", "globals.css"); + if (await pathExists(globalsPath)) { + const globalsContent = await readFileCached(globalsPath, fileContentCache); + collectFontsFromContent(globalsContent, path.relative(workspaceRoot, globalsPath), fontValues); + } + for (const sectionFile of sectionFiles) { + const content = await readFileCached(sectionFile, fileContentCache); + collectFontsFromContent(content, path.relative(workspaceRoot, sectionFile), fontValues); + } + return [...fontValues.values()].sort((a, b) => a.value.localeCompare(b.value)); +} +async function extractColors(surfaceRoot, cssFilePaths, sectionFiles, workspaceRoot, fileContentCache) { + const colorValues = new Map(); + const layoutPath = path.join(surfaceRoot, "app", "layout.tsx"); + if (await pathExists(layoutPath)) { + const layoutContent = await readFileCached(layoutPath, fileContentCache); + collectColorsFromContent(layoutContent, path.relative(workspaceRoot, layoutPath), colorValues); + } + const globalsPath = path.join(surfaceRoot, "app", "globals.css"); + if (await pathExists(globalsPath)) { + const globalsContent = await readFileCached(globalsPath, fileContentCache); + collectColorsFromContent(globalsContent, path.relative(workspaceRoot, globalsPath), colorValues); + } + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + collectColorsFromContent(cssContent, path.relative(workspaceRoot, cssPath), colorValues); + } + for (const sectionFile of sectionFiles) { + const content = await readFileCached(sectionFile, fileContentCache); + collectColorsFromContent(content, path.relative(workspaceRoot, sectionFile), colorValues); + } + return [...colorValues.values()].sort((a, b) => a.value.localeCompare(b.value)); +} +async function extractLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache) { + let maxWidth = null; + let layoutSource; + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + const match = cssContent.match(MAX_WIDTH_VAR_REGEX); + if (match) { + const [, value, unit] = match; + const numericValue = Number.parseFloat(value); + if (Number.isFinite(numericValue) && unit.toLowerCase() === "px") { + maxWidth = numericValue; + layoutSource = path.relative(workspaceRoot, cssPath); + break; + } + } + } + const containerSources = new Set(); + const containers = new Set(); + for (const filePath of sectionFiles) { + const content = await readFileCached(filePath, fileContentCache); + const detectedContainers = collectContainersFromContent(content); + if (detectedContainers.size > 0) { + for (const container of detectedContainers) { + containers.add(container); + } + containerSources.add(path.relative(workspaceRoot, filePath)); + } + } + return { + maxContentWidth: maxWidth, + containers: [...containers].sort(), + containerSources: [...containerSources].sort(), + source: layoutSource, + }; +} +async function extractMotion(cssFilePaths, workspaceRoot, fileContentCache) { + const motions = new Map(); + const durationVariables = new Map(); + let defaultTiming; + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + const durationVarMatch = cssContent.match(MOTION_DURATION_VAR_REGEX); + if (durationVarMatch) { + const [, value, unit] = durationVarMatch; + const durationMs = parseDurationToMs(value, unit); + if (durationMs !== null) { + durationVariables.set("--contract-motion-duration", durationMs); + } + } + const timingVarMatch = cssContent.match(MOTION_TIMING_VAR_REGEX); + if (timingVarMatch) { + defaultTiming = timingVarMatch[1]; + } + } + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + const relative = path.relative(workspaceRoot, cssPath); + let match; + while ((match = DURATION_DECL_REGEX.exec(cssContent)) !== null) { + const [, , value] = match; + const durations = parseDurationExpressions(value, durationVariables); + for (const duration of durations) { + const key = toMotionKey(duration, defaultTiming ?? "linear"); + if (!motions.has(key)) { + motions.set(key, { + durationMs: duration, + timingFunction: defaultTiming ?? "linear", + source: relative, + }); + } + } + } + while ((match = TRANSITION_DECL_REGEX.exec(cssContent)) !== null) { + const [, value] = match; + const durations = parseDurationExpressions(value, durationVariables); + const timingFunctions = parseTimingFunctions(value, defaultTiming); + for (const duration of durations) { + for (const timing of timingFunctions) { + const key = toMotionKey(duration, timing); + if (!motions.has(key)) { + motions.set(key, { + durationMs: duration, + timingFunction: timing, + source: relative, + }); + } + } + } + } + while ((match = TIMING_DECL_REGEX.exec(cssContent)) !== null) { + const [, , value] = match; + const timingFunctions = parseTimingFunctions(value, defaultTiming); + for (const timing of timingFunctions) { + const key = toMotionKey(durationVariables.get("--contract-motion-duration") ?? 0, timing); + if (!motions.has(key)) { + motions.set(key, { + durationMs: durationVariables.get("--contract-motion-duration") ?? 0, + timingFunction: timing, + source: relative, + }); + } + } + } + } + return [...motions.values()].filter((motion) => motion.durationMs > 0); +} +function collectFontsFromContent(content, source, fontValues) { + let match; + while ((match = FONT_VAR_REGEX.exec(content)) !== null) { + const variable = `var(${match[1]})`; + if (!fontValues.has(variable)) { + fontValues.set(variable, { value: variable, source }); + } + } + while ((match = FONT_FAMILY_REGEX.exec(content)) !== null) { + const families = match[1] + .split(",") + .map((token) => token.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); + for (const family of families) { + if (!fontValues.has(family)) { + fontValues.set(family, { value: family, source }); + } + } + } +} +function collectColorsFromContent(content, source, colorValues) { + let match; + // Extract CSS color variables + while ((match = COLOR_VAR_REGEX.exec(content)) !== null) { + const variable = `var(${match[1]})`; + if (!colorValues.has(variable)) { + colorValues.set(variable, { value: variable, source }); + } + } + // Extract direct color declarations + while ((match = COLOR_DECL_REGEX.exec(content)) !== null) { + const colorValue = match[1].trim(); + if (!colorValue) { + continue; + } + // Parse color value - handle multiple values (e.g., in gradients) + const colors = parseColorValue(colorValue); + for (const color of colors) { + if (color && !colorValues.has(color)) { + colorValues.set(color, { value: color, source }); + } + } + } +} +function parseColorValue(value) { + const colors = []; + const trimmed = value.trim(); + // Skip if it's a gradient or other complex value + if (trimmed.includes("gradient") || + trimmed.includes("url(") || + trimmed.includes("calc(")) { + return colors; + } + // Handle comma-separated values (e.g., in rgba or multiple colors) + const parts = trimmed.split(",").map((p) => p.trim()); + for (const part of parts) { + // CSS variable + if (part.startsWith("var(")) { + const varMatch = part.match(/var\(([^)]+)\)/); + if (varMatch && varMatch[1].startsWith("--color-")) { + colors.push(`var(${varMatch[1]})`); + } + continue; + } + // Hex colors (#fff, #ffffff) + const hexMatch = part.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + colors.push(part.toLowerCase()); + continue; + } + // rgb/rgba colors + const rgbMatch = part.match(/^(rgba?)\s*\(\s*([^)]+)\s*\)$/i); + if (rgbMatch) { + colors.push(part.toLowerCase()); + continue; + } + // hsl/hsla colors + const hslMatch = part.match(/^(hsla?)\s*\(\s*([^)]+)\s*\)$/i); + if (hslMatch) { + colors.push(part.toLowerCase()); + continue; + } + // Named colors (case-insensitive) + const namedColorMatch = part.match(/^[a-z]+$/i); + if (namedColorMatch) { + // Common CSS named colors + const namedColor = part.toLowerCase(); + const commonColors = [ + "transparent", + "currentcolor", + "inherit", + "initial", + "unset", + "revert", + "black", + "white", + "red", + "green", + "blue", + "yellow", + "orange", + "purple", + "pink", + "brown", + "gray", + "grey", + "cyan", + "magenta", + "lime", + "navy", + "olive", + "teal", + "aqua", + "maroon", + "silver", + "gold", + ]; + // Only accept known CSS named colors, not arbitrary words + if (commonColors.includes(namedColor)) { + colors.push(namedColor); + } + } + } + return colors; +} +function collectContainersFromContent(content) { + const containers = new Set(); + let match; + while ((match = CONTAINER_ATTRIBUTE_REGEX.exec(content)) !== null) { + const raw = match[1] ?? match[2] ?? match[3] ?? match[4] ?? ""; + if (!raw) { + continue; + } + for (const token of raw.split(/\s+/).filter(Boolean)) { + containers.add(token); + } + } + if (content.includes(CONTRACT_CONTAINER_TOKEN)) { + containers.add(CONTRACT_CONTAINER_TOKEN); + } + return containers; +} +async function readFileCached(filePath, cache) { + const cached = cache.get(filePath); + if (cached !== undefined) { + return cached; + } + const contents = await readFile(filePath, "utf-8"); + cache.set(filePath, contents); + return contents; +} +async function pathExists(filePath) { + try { + await stat(filePath); + return true; + } + catch { + return false; + } +} +function parseDurationToMs(value, unit) { + const numericValue = Number.parseFloat(value); + if (!Number.isFinite(numericValue)) { + return null; + } + if (unit.toLowerCase() === "ms") { + return numericValue; + } + if (unit.toLowerCase() === "s") { + return numericValue * 1000; + } + return null; +} +function parseDurationExpressions(expression, durationVariables) { + const results = []; + const tokens = expression.split(/[, ]+/).filter(Boolean); + for (const token of tokens) { + const variableMatch = token.match(/var\((--[a-z0-9-]+)\)/i); + if (variableMatch) { + const variableName = variableMatch[1]; + const value = durationVariables.get(variableName); + if (value !== undefined) { + results.push(value); + } + continue; + } + const directMatch = token.match(/^([0-9.]+)(ms|s)$/i); + if (directMatch) { + const [, value, unit] = directMatch; + const duration = parseDurationToMs(value, unit); + if (duration !== null) { + results.push(duration); + } + } + } + return results; +} +function parseTimingFunctions(expression, fallback) { + const results = new Set(); + const tokens = expression.split(/[, ]+/).filter(Boolean); + for (const token of tokens) { + if (token.startsWith("var(")) { + continue; + } + if (isTimingFunction(token)) { + results.add(token); + } + } + if (results.size === 0 && fallback) { + results.add(fallback); + } + if (results.size === 0) { + results.add("linear"); + } + return [...results]; +} +function isTimingFunction(token) { + return ([ + "linear", + "ease", + "ease-in", + "ease-out", + "ease-in-out", + "step-start", + "step-end", + ].includes(token) || token.startsWith("cubic-bezier(")); +} +function toMotionKey(durationMs, timing) { + return `${durationMs}:${timing}`; +} diff --git a/packages/interfacectl-cli/dist/index.d.ts b/packages/interfacectl-cli/dist/index.d.ts new file mode 100644 index 0000000..dc1ec89 --- /dev/null +++ b/packages/interfacectl-cli/dist/index.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export {}; +//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/index.d.ts.map b/packages/interfacectl-cli/dist/index.d.ts.map new file mode 100644 index 0000000..535b86d --- /dev/null +++ b/packages/interfacectl-cli/dist/index.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js new file mode 100644 index 0000000..0a7181f --- /dev/null +++ b/packages/interfacectl-cli/dist/index.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import { Command } from "commander"; +import { runValidateCommand } from "./commands/validate.js"; +import pkg from "../package.json" with { type: "json" }; +const program = new Command(); +program + .name("interfacectl") + .description("Interface contract tooling for Surfaces") + .version(pkg.version ?? "0.0.0"); +program + .command("validate") + .description("Validate configured surfaces against the shared interface contract") + .option("--contract ", "Path to the contract JSON file") + .option("--schema ", "Optional path to the contract schema JSON file") + .option("--config ", "Optional path to the interfacectl config JSON file (defaults to interfacectl.config.json)") + .option("--root ", "Project root (defaults to current working directory)") + .option("--workspace-root ", "Workspace root (defaults to current working directory)") + .option("--surface ", "Limit validation to the provided surface identifiers") + .option("--json", "Emit machine-readable JSON instead of human-readable text output") + .option("--format ", "Output format (text|json)") + .option("--out ", "Write output to the provided file path instead of stdout") + .action(async (options) => { + const env = process.env; + const requestedRoot = options.root ?? options.workspaceRoot ?? env.SURFACES_ROOT; + const workspaceRoot = typeof requestedRoot === "string" && requestedRoot.length > 0 + ? requestedRoot + : undefined; + const requestedContract = options.contract ?? env.SURFACES_CONTRACT ?? undefined; + const contractPath = typeof requestedContract === "string" && requestedContract.length > 0 + ? requestedContract + : "contracts/surfaces.web.contract.json"; + const requestedConfig = options.config ?? env.SURFACES_CONFIG ?? undefined; + const formatInput = (options.format ?? (options.json ? "json" : undefined))?.toLowerCase(); + const outputFormat = formatInput === "json" ? "json" : formatInput === "text" ? "text" : "text"; + if (formatInput && + formatInput !== "text" && + formatInput !== "json") { + console.error(`Invalid format "${options.format}". Expected "text" or "json".`); + process.exitCode = 1; + return; + } + const exitCode = await runValidateCommand({ + contractPath, + schemaPath: options.schema, + workspaceRoot, + surfaceFilters: options.surface ?? [], + outputFormat, + outputPath: options.out, + configPath: requestedConfig, + configProvided: Boolean(requestedConfig), + }); + process.exitCode = exitCode; +}); +program.parseAsync(process.argv).catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts index 45b2c73..9f85006 100644 --- a/packages/interfacectl-cli/src/commands/validate.ts +++ b/packages/interfacectl-cli/src/commands/validate.ts @@ -360,6 +360,7 @@ function mapViolationsToFindings( "missing-section": "section.missing", "unknown-section": "section.unexpected", "font-not-allowed": "font.disallowed", + "color-not-allowed": "color.disallowed", "layout-width-exceeded": "layout.width-exceeded", "layout-width-undetermined": "layout.width-undetermined", "layout-container-missing": "layout.container-missing", @@ -400,6 +401,13 @@ function mapViolationsToFindings( finding.found = details.font; break; } + case "color-not-allowed": { + finding.expected = Array.isArray(details.allowedColors) + ? details.allowedColors + : undefined; + finding.found = details.color; + break; + } case "layout-width-undetermined": { finding.expected = details.expectedMaxWidth; finding.found = null; diff --git a/packages/interfacectl-cli/src/descriptors/static-analysis.ts b/packages/interfacectl-cli/src/descriptors/static-analysis.ts index f74bb0f..9db781b 100644 --- a/packages/interfacectl-cli/src/descriptors/static-analysis.ts +++ b/packages/interfacectl-cli/src/descriptors/static-analysis.ts @@ -5,6 +5,7 @@ import { type InterfaceContract, type SurfaceDescriptor, type SurfaceFontDescriptor, + type SurfaceColorDescriptor, type SurfaceLayoutDescriptor, type SurfaceMotionDescriptor, type SurfaceSectionDescriptor, @@ -27,6 +28,9 @@ const COMMON_GLOBBY_IGNORES = [ const FONT_VAR_REGEX = /var\((--font-[a-z0-9-]+)\)/gi; const FONT_FAMILY_REGEX = /font-family\s*:\s*([^;]+);/gi; +const COLOR_VAR_REGEX = /var\((--color-[a-z0-9-]+)\)/gi; +const COLOR_DECL_REGEX = + /(?:color|background-color|background|border-color|border-top-color|border-right-color|border-bottom-color|border-left-color|outline-color|text-decoration-color|caret-color|column-rule-color)\s*:\s*([^;]+);/gi; const MAX_WIDTH_VAR_REGEX = /--contract-max-width\s*:\s*([0-9.]+)\s*(px|rem|em)/i; const MOTION_DURATION_VAR_REGEX = @@ -180,6 +184,25 @@ async function extractSurfaceDescriptor( }); } + const colors = await extractColors( + surfaceRoot, + layoutCssFiles, + sectionFiles, + workspaceRoot, + fileContentCache, + ); + if (colors.length === 0) { + const globalsPath = path.join(surfaceRoot, "app", "globals.css"); + warnings.push({ + surfaceId, + code: "colors.none-detected", + message: `No colors detected for surface "${surfaceId}". Verify color variables or CSS color declarations.`, + location: (await pathExists(globalsPath)) + ? path.relative(workspaceRoot, globalsPath) + : undefined, + }); + } + const motion = await extractMotion(layoutCssFiles, workspaceRoot, fileContentCache); if (motion.length === 0) { warnings.push({ @@ -193,6 +216,7 @@ async function extractSurfaceDescriptor( surfaceId, sections, fonts, + colors, layout, motion, }; @@ -273,6 +297,58 @@ async function extractFonts( ); } +async function extractColors( + surfaceRoot: string, + cssFilePaths: string[], + sectionFiles: string[], + workspaceRoot: string, + fileContentCache: Map, +): Promise { + const colorValues = new Map(); + + const layoutPath = path.join(surfaceRoot, "app", "layout.tsx"); + if (await pathExists(layoutPath)) { + const layoutContent = await readFileCached(layoutPath, fileContentCache); + collectColorsFromContent( + layoutContent, + path.relative(workspaceRoot, layoutPath), + colorValues, + ); + } + + const globalsPath = path.join(surfaceRoot, "app", "globals.css"); + if (await pathExists(globalsPath)) { + const globalsContent = await readFileCached(globalsPath, fileContentCache); + collectColorsFromContent( + globalsContent, + path.relative(workspaceRoot, globalsPath), + colorValues, + ); + } + + for (const cssPath of cssFilePaths) { + const cssContent = await readFileCached(cssPath, fileContentCache); + collectColorsFromContent( + cssContent, + path.relative(workspaceRoot, cssPath), + colorValues, + ); + } + + for (const sectionFile of sectionFiles) { + const content = await readFileCached(sectionFile, fileContentCache); + collectColorsFromContent( + content, + path.relative(workspaceRoot, sectionFile), + colorValues, + ); + } + + return [...colorValues.values()].sort((a, b) => + a.value.localeCompare(b.value), + ); +} + async function extractLayout( cssFilePaths: string[], sectionFiles: string[], @@ -433,6 +509,130 @@ function collectFontsFromContent( } } +function collectColorsFromContent( + content: string, + source: string, + colorValues: Map, +) { + let match: RegExpExecArray | null; + + // Extract CSS color variables + while ((match = COLOR_VAR_REGEX.exec(content)) !== null) { + const variable = `var(${match[1]})`; + if (!colorValues.has(variable)) { + colorValues.set(variable, { value: variable, source }); + } + } + + // Extract direct color declarations + while ((match = COLOR_DECL_REGEX.exec(content)) !== null) { + const colorValue = match[1].trim(); + if (!colorValue) { + continue; + } + + // Parse color value - handle multiple values (e.g., in gradients) + const colors = parseColorValue(colorValue); + for (const color of colors) { + if (color && !colorValues.has(color)) { + colorValues.set(color, { value: color, source }); + } + } + } +} + +function parseColorValue(value: string): string[] { + const colors: string[] = []; + const trimmed = value.trim(); + + // Skip if it's a gradient or other complex value + if ( + trimmed.includes("gradient") || + trimmed.includes("url(") || + trimmed.includes("calc(") + ) { + return colors; + } + + // Handle comma-separated values (e.g., in rgba or multiple colors) + const parts = trimmed.split(",").map((p) => p.trim()); + + for (const part of parts) { + // CSS variable + if (part.startsWith("var(")) { + const varMatch = part.match(/var\(([^)]+)\)/); + if (varMatch && varMatch[1].startsWith("--color-")) { + colors.push(`var(${varMatch[1]})`); + } + continue; + } + + // Hex colors (#fff, #ffffff) + const hexMatch = part.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + colors.push(part.toLowerCase()); + continue; + } + + // rgb/rgba colors + const rgbMatch = part.match(/^(rgba?)\s*\(\s*([^)]+)\s*\)$/i); + if (rgbMatch) { + colors.push(part.toLowerCase()); + continue; + } + + // hsl/hsla colors + const hslMatch = part.match(/^(hsla?)\s*\(\s*([^)]+)\s*\)$/i); + if (hslMatch) { + colors.push(part.toLowerCase()); + continue; + } + + // Named colors (case-insensitive) + const namedColorMatch = part.match(/^[a-z]+$/i); + if (namedColorMatch) { + // Common CSS named colors + const namedColor = part.toLowerCase(); + const commonColors = [ + "transparent", + "currentcolor", + "inherit", + "initial", + "unset", + "revert", + "black", + "white", + "red", + "green", + "blue", + "yellow", + "orange", + "purple", + "pink", + "brown", + "gray", + "grey", + "cyan", + "magenta", + "lime", + "navy", + "olive", + "teal", + "aqua", + "maroon", + "silver", + "gold", + ]; + // Only accept known CSS named colors, not arbitrary words + if (commonColors.includes(namedColor)) { + colors.push(namedColor); + } + } + } + + return colors; +} + function collectContainersFromContent(content: string): Set { const containers = new Set(); let match: RegExpExecArray | null; diff --git a/packages/interfacectl-cli/test/fixtures/minimal-project/contracts/ui.contract.json b/packages/interfacectl-cli/test/fixtures/minimal-project/contracts/ui.contract.json index e62de3a..2657f56 100644 --- a/packages/interfacectl-cli/test/fixtures/minimal-project/contracts/ui.contract.json +++ b/packages/interfacectl-cli/test/fixtures/minimal-project/contracts/ui.contract.json @@ -15,6 +15,11 @@ "Demo Sans", "sans-serif" ], + "allowedColors": [ + "var(--color-primary)", + "#ffffff", + "#000000" + ], "layout": { "maxContentWidth": 960 } diff --git a/packages/interfacectl-cli/test/static-analysis.test.mjs b/packages/interfacectl-cli/test/static-analysis.test.mjs index b0c9f94..4846ce8 100644 --- a/packages/interfacectl-cli/test/static-analysis.test.mjs +++ b/packages/interfacectl-cli/test/static-analysis.test.mjs @@ -11,7 +11,7 @@ import { import { collectSurfaceDescriptors } from "../dist/descriptors/static-analysis.js"; -test("collectSurfaceDescriptors captures sections, containers, fonts, and motion", async () => { +test("collectSurfaceDescriptors captures sections, containers, fonts, colors, and motion", async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-static-")); const surfaceId = "demo-surface"; const surfaceRoot = path.join(tempRoot, "apps", surfaceId); @@ -62,14 +62,23 @@ test("collectSurfaceDescriptors captures sections, containers, fonts, and motion --contract-max-width: 960px; --contract-motion-duration: 120ms; --contract-motion-timing: ease-in; + --color-primary: #0066cc; + --color-background: #ffffff; } .contract-container { transition: opacity var(--contract-motion-duration) ease-in; + background-color: var(--color-background); } .demo-body { font-family: var(--font-demo), "Demo Sans", monospace; + color: var(--color-primary); + } + + .demo-text { + color: #333333; + border-color: rgb(200, 200, 200); } `, "utf-8", @@ -108,6 +117,12 @@ test("collectSurfaceDescriptors captures sections, containers, fonts, and motion type: "web", requiredSections: ["main.hero"], allowedFonts: ["var(--font-demo)", "Demo Sans", "monospace"], + allowedColors: [ + "var(--color-primary)", + "var(--color-background)", + "#333333", + "rgb(200, 200, 200)", + ], layout: { maxContentWidth: 960, requiredContainers: ["primary-shell"], @@ -150,6 +165,14 @@ test("collectSurfaceDescriptors captures sections, containers, fonts, and motion const fontValues = descriptor.fonts.map((font) => font.value).sort(); assert.deepEqual(fontValues, ["Demo Sans", "monospace", "var(--font-demo)"]); + const colorValues = descriptor.colors.map((color) => color.value).sort(); + assert.deepEqual(colorValues, [ + "#333333", + "rgb(200, 200, 200)", + "var(--color-background)", + "var(--color-primary)", + ]); + assert.ok(descriptor.motion.length > 0, "expected motion descriptors"); const durations = descriptor.motion.map((motion) => motion.durationMs); assert.ok(durations.includes(120), "should include duration from CSS variable"); diff --git a/packages/interfacectl-validator/dist/index.d.ts b/packages/interfacectl-validator/dist/index.d.ts index e83644d..1569f77 100644 --- a/packages/interfacectl-validator/dist/index.d.ts +++ b/packages/interfacectl-validator/dist/index.d.ts @@ -8,5 +8,5 @@ export interface ContractStructureValidation { export declare function validateContractStructure(contractData: unknown, schema: object): ContractStructureValidation; export declare function evaluateSurfaceCompliance(contract: InterfaceContract, descriptor: SurfaceDescriptor): SurfaceReport; export declare function evaluateContractCompliance(contract: InterfaceContract, descriptors: SurfaceDescriptor[]): ValidationSummary; -export type { InterfaceContract, ContractSurface, ContractSection, ContractConstraints, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, } from "./types.js"; +export type { InterfaceContract, ContractSurface, ContractSection, ContractConstraints, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, } from "./types.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/index.d.ts.map b/packages/interfacectl-validator/dist/index.d.ts.map index c48db42..2e438e9 100644 --- a/packages/interfacectl-validator/dist/index.d.ts.map +++ b/packages/interfacectl-validator/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EAIlB,MAAM,YAAY,CAAC;AAOpB,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,2BAA2B,CAyB7B;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAyJf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AA8BD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,uBAAuB,EACvB,uBAAuB,EACvB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,YAAY,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,iBAAiB,EAIlB,MAAM,YAAY,CAAC;AAOpB,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,OAAO,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,CAAC,EAAE,iBAAiB,CAAC;CAC9B;AAED,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,OAAO,EACrB,MAAM,EAAE,MAAM,GACb,2BAA2B,CAyB7B;AAED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAyKf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AA8BD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,GACnB,MAAM,YAAY,CAAC"} \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/index.js b/packages/interfacectl-validator/dist/index.js index b4274cd..2166546 100644 --- a/packages/interfacectl-validator/dist/index.js +++ b/packages/interfacectl-validator/dist/index.js @@ -81,6 +81,21 @@ export function evaluateSurfaceCompliance(contract, descriptor) { }); } } + const allowedColors = new Set(surface.allowedColors); + for (const color of descriptor.colors) { + if (!allowedColors.has(color.value)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-not-allowed", + message: `Color "${color.value}" is not allowed for surface "${descriptor.surfaceId}".`, + details: { + color: color.value, + source: color.source, + allowedColors: [...allowedColors], + }, + }); + } + } const reportedWidth = descriptor.layout.maxContentWidth; if (reportedWidth === null || reportedWidth === undefined) { violations.push({ diff --git a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json index e6085da..7a0a1f6 100644 --- a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json +++ b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json @@ -55,6 +55,7 @@ "type", "requiredSections", "allowedFonts", + "allowedColors", "layout" ], "properties": { @@ -91,6 +92,15 @@ }, "uniqueItems": true }, + "allowedColors": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, "layout": { "type": "object", "additionalProperties": false, diff --git a/packages/interfacectl-validator/dist/types.d.ts b/packages/interfacectl-validator/dist/types.d.ts index 05299fb..f1fa216 100644 --- a/packages/interfacectl-validator/dist/types.d.ts +++ b/packages/interfacectl-validator/dist/types.d.ts @@ -5,6 +5,7 @@ export interface ContractSurface { type: SurfaceType; requiredSections: string[]; allowedFonts: string[]; + allowedColors: string[]; layout: { maxContentWidth: number; requiredContainers?: string[]; @@ -37,6 +38,10 @@ export interface SurfaceFontDescriptor { value: string; source?: string; } +export interface SurfaceColorDescriptor { + value: string; + source?: string; +} export interface SurfaceMotionDescriptor { durationMs: number; timingFunction: string; @@ -52,10 +57,11 @@ export interface SurfaceDescriptor { surfaceId: string; sections: SurfaceSectionDescriptor[]; fonts: SurfaceFontDescriptor[]; + colors: SurfaceColorDescriptor[]; layout: SurfaceLayoutDescriptor; motion: SurfaceMotionDescriptor[]; } -export type DriftViolationType = "unknown-surface" | "missing-section" | "unknown-section" | "font-not-allowed" | "layout-width-exceeded" | "layout-width-undetermined" | "layout-container-missing" | "motion-duration-not-allowed" | "motion-timing-not-allowed" | "descriptor-missing" | "descriptor-unused"; +export type DriftViolationType = "unknown-surface" | "missing-section" | "unknown-section" | "font-not-allowed" | "color-not-allowed" | "layout-width-exceeded" | "layout-width-undetermined" | "layout-container-missing" | "motion-duration-not-allowed" | "motion-timing-not-allowed" | "descriptor-missing" | "descriptor-unused"; export interface DriftViolation { surfaceId: string; type: DriftViolationType; diff --git a/packages/interfacectl-validator/dist/types.d.ts.map b/packages/interfacectl-validator/dist/types.d.ts.map index ca64179..270a1d8 100644 --- a/packages/interfacectl-validator/dist/types.d.ts.map +++ b/packages/interfacectl-validator/dist/types.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,WAAW,CAAC;IAClB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,MAAM,EAAE;QACN,eAAe,EAAE,MAAM,CAAC;QACxB,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;KAC/B,CAAC;CACH;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE;QACN,kBAAkB,EAAE,MAAM,EAAE,CAAC;QAC7B,sBAAsB,EAAE,MAAM,EAAE,CAAC;KAClC,CAAC;CACH;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,wBAAwB;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,wBAAwB,EAAE,CAAC;IACrC,KAAK,EAAE,qBAAqB,EAAE,CAAC;IAC/B,MAAM,EAAE,uBAAuB,CAAC;IAChC,MAAM,EAAE,uBAAuB,EAAE,CAAC;CACnC;AAED,MAAM,MAAM,kBAAkB,GAC1B,iBAAiB,GACjB,iBAAiB,GACjB,iBAAiB,GACjB,kBAAkB,GAClB,uBAAuB,GACvB,2BAA2B,GAC3B,0BAA0B,GAC1B,6BAA6B,GAC7B,2BAA2B,GAC3B,oBAAoB,GACpB,mBAAmB,CAAC;AAExB,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,cAAc,EAAE,aAAa,EAAE,CAAC;CACjC"} \ No newline at end of file +{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,KAAK,CAAC;AAExC,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,WAAW,CAAC;IAClB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,MAAM,EAAE;QACN,eAAe,EAAE,MAAM,CAAC;QACxB,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;KAC/B,CAAC;CACH;AAED,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE;QACN,kBAAkB,EAAE,MAAM,EAAE,CAAC;QAC7B,sBAAsB,EAAE,MAAM,EAAE,CAAC;KAClC,CAAC;CACH;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,QAAQ,EAAE,eAAe,EAAE,CAAC;IAC5B,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,wBAAwB;IACvC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,uBAAuB;IACtC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,wBAAwB,EAAE,CAAC;IACrC,KAAK,EAAE,qBAAqB,EAAE,CAAC;IAC/B,MAAM,EAAE,sBAAsB,EAAE,CAAC;IACjC,MAAM,EAAE,uBAAuB,CAAC;IAChC,MAAM,EAAE,uBAAuB,EAAE,CAAC;CACnC;AAED,MAAM,MAAM,kBAAkB,GAC1B,iBAAiB,GACjB,iBAAiB,GACjB,iBAAiB,GACjB,kBAAkB,GAClB,mBAAmB,GACnB,uBAAuB,GACvB,2BAA2B,GAC3B,0BAA0B,GAC1B,6BAA6B,GAC7B,2BAA2B,GAC3B,oBAAoB,GACpB,mBAAmB,CAAC;AAExB,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,kBAAkB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC;AAED,MAAM,WAAW,aAAa;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,cAAc,EAAE,aAAa,EAAE,CAAC;CACjC"} \ No newline at end of file diff --git a/packages/interfacectl-validator/src/index.ts b/packages/interfacectl-validator/src/index.ts index 0cc0e41..e3903de 100644 --- a/packages/interfacectl-validator/src/index.ts +++ b/packages/interfacectl-validator/src/index.ts @@ -120,6 +120,22 @@ export function evaluateSurfaceCompliance( } } + const allowedColors = new Set(surface.allowedColors); + for (const color of descriptor.colors) { + if (!allowedColors.has(color.value)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "color-not-allowed", + message: `Color "${color.value}" is not allowed for surface "${descriptor.surfaceId}".`, + details: { + color: color.value, + source: color.source, + allowedColors: [...allowedColors], + }, + }); + } + } + const reportedWidth = descriptor.layout.maxContentWidth; if (reportedWidth === null || reportedWidth === undefined) { violations.push({ @@ -299,6 +315,7 @@ export type { SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, + SurfaceColorDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, SurfaceReport, diff --git a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json index e6085da..7a0a1f6 100644 --- a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json +++ b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json @@ -55,6 +55,7 @@ "type", "requiredSections", "allowedFonts", + "allowedColors", "layout" ], "properties": { @@ -91,6 +92,15 @@ }, "uniqueItems": true }, + "allowedColors": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, "layout": { "type": "object", "additionalProperties": false, diff --git a/packages/interfacectl-validator/src/types.ts b/packages/interfacectl-validator/src/types.ts index 0488d87..424ab44 100644 --- a/packages/interfacectl-validator/src/types.ts +++ b/packages/interfacectl-validator/src/types.ts @@ -6,6 +6,7 @@ export interface ContractSurface { type: SurfaceType; requiredSections: string[]; allowedFonts: string[]; + allowedColors: string[]; layout: { maxContentWidth: number; requiredContainers?: string[]; @@ -44,6 +45,11 @@ export interface SurfaceFontDescriptor { source?: string; } +export interface SurfaceColorDescriptor { + value: string; + source?: string; +} + export interface SurfaceMotionDescriptor { durationMs: number; timingFunction: string; @@ -61,6 +67,7 @@ export interface SurfaceDescriptor { surfaceId: string; sections: SurfaceSectionDescriptor[]; fonts: SurfaceFontDescriptor[]; + colors: SurfaceColorDescriptor[]; layout: SurfaceLayoutDescriptor; motion: SurfaceMotionDescriptor[]; } @@ -70,6 +77,7 @@ export type DriftViolationType = | "missing-section" | "unknown-section" | "font-not-allowed" + | "color-not-allowed" | "layout-width-exceeded" | "layout-width-undetermined" | "layout-container-missing" diff --git a/packages/interfacectl-validator/test/evaluate-contract.test.mjs b/packages/interfacectl-validator/test/evaluate-contract.test.mjs index 91a7ec3..aae297a 100644 --- a/packages/interfacectl-validator/test/evaluate-contract.test.mjs +++ b/packages/interfacectl-validator/test/evaluate-contract.test.mjs @@ -27,24 +27,26 @@ const baseContract = { test("defaults to contract-container when requiredContainers omitted", () => { const contract = { ...baseContract, - surfaces: [ - { - id: "surface-a", - displayName: "Surface A", - type: "web", - requiredSections: ["main.hero"], - allowedFonts: ["var(--font-a)"], - layout: { - maxContentWidth: 960, + surfaces: [ + { + id: "surface-a", + displayName: "Surface A", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-a)"], + allowedColors: ["var(--color-primary)"], + layout: { + maxContentWidth: 960, + }, }, - }, - ], + ], }; const descriptor = { surfaceId: "surface-a", sections: [{ id: "main.hero" }], fonts: [{ value: "var(--font-a)" }], + colors: [{ value: "var(--color-primary)" }], layout: { maxContentWidth: 920, containers: ["contract-container"], @@ -66,25 +68,27 @@ test("defaults to contract-container when requiredContainers omitted", () => { test("reports missing custom required containers", () => { const contract = { ...baseContract, - surfaces: [ - { - id: "surface-b", - displayName: "Surface B", - type: "web", - requiredSections: ["main.hero"], - allowedFonts: ["var(--font-b)"], - layout: { - maxContentWidth: 960, - requiredContainers: ["primary-shell", "contract-container"], + surfaces: [ + { + id: "surface-b", + displayName: "Surface B", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-b)"], + allowedColors: ["var(--color-b)"], + layout: { + maxContentWidth: 960, + requiredContainers: ["primary-shell", "contract-container"], + }, }, - }, - ], + ], }; const descriptor = { surfaceId: "surface-b", sections: [{ id: "main.hero" }], fonts: [{ value: "var(--font-b)" }], + colors: [{ value: "var(--color-b)" }], layout: { maxContentWidth: 960, containers: ["primary-shell"], @@ -110,25 +114,27 @@ test("reports missing custom required containers", () => { test("captures layout width and motion violations", () => { const contract = { ...baseContract, - surfaces: [ - { - id: "surface-c", - displayName: "Surface C", - type: "web", - requiredSections: ["main.hero"], - allowedFonts: ["var(--font-c)"], - layout: { - maxContentWidth: 720, - requiredContainers: [], + surfaces: [ + { + id: "surface-c", + displayName: "Surface C", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-c)"], + allowedColors: ["var(--color-c)"], + layout: { + maxContentWidth: 720, + requiredContainers: [], + }, }, - }, - ], + ], }; const descriptor = { surfaceId: "surface-c", sections: [{ id: "main.hero" }], fonts: [{ value: "var(--font-c)" }], + colors: [{ value: "var(--color-disallowed)" }], layout: { maxContentWidth: 960, containers: [], @@ -146,8 +152,12 @@ test("captures layout width and motion violations", () => { const violationTypes = report.violations.map((violation) => violation.type); assert.deepEqual( violationTypes.sort(), - ["layout-width-exceeded", "motion-duration-not-allowed", "motion-timing-not-allowed"].sort(), + ["color-not-allowed", "layout-width-exceeded", "motion-duration-not-allowed", "motion-timing-not-allowed"].sort(), ); + + const colorViolation = report.violations.find((v) => v.type === "color-not-allowed"); + assert.ok(colorViolation, "should have color violation"); + assert.equal(colorViolation.details?.color, "var(--color-disallowed)"); });