diff --git a/VERIFICATION_SUMMARY.md b/VERIFICATION_SUMMARY.md
new file mode 100644
index 0000000..9307788
--- /dev/null
+++ b/VERIFICATION_SUMMARY.md
@@ -0,0 +1,124 @@
+# Color Policy Implementation Verification
+
+## ✅ Code Names Match Documentation
+
+All emitted codes match the documentation in `color-policy.md`:
+
+- ✅ `color.raw-value.used` - Emitted for raw color literals
+- ✅ `color.token.namespace.violation` - Emitted for CSS variables not matching allowed namespaces
+- ✅ `contract.deprecated-field` - Emitted when `allowedColors` is present
+
+## ✅ Severity Mapping Matches Documentation
+
+### `color.raw-value.used`
+- **Policy: `"warn"`** → Severity: `"warning"` (does not fail validation)
+- **Policy: `"strict"`** → Severity: `"error"` (fails validation with exit code)
+
+Implementation location: `packages/interfacectl-cli/src/commands/validate.ts:578-592`
+
+```typescript
+case "color-raw-value-used": {
+ finding.expected = details.allowedValues;
+ finding.found = details.colorValue;
+ // Set severity based on policy: "warn" -> warning, "strict" -> error
+ if (details.policy === "warn") {
+ finding.severity = "warning";
+ } else if (details.policy === "strict") {
+ finding.severity = "error";
+ }
+ break;
+}
+```
+
+### `color.token.namespace.violation`
+- **Default**: Severity: `"warning"` (does not fail validation)
+
+Implementation location: `packages/interfacectl-cli/src/commands/validate.ts:595-601`
+
+```typescript
+case "color-token-namespace-violation": {
+ finding.expected = details.allowedNamespaces;
+ finding.found = details.tokenName;
+ // Token namespace violations are warnings by default
+ finding.severity = "warning";
+ break;
+}
+```
+
+### `contract.deprecated-field`
+- **Default**: Severity: `"warning"` (does not fail validation)
+
+Implementation location: `packages/interfacectl-cli/src/commands/validate.ts:258`
+
+## ✅ Exit Code Behavior
+
+**Warnings do not fail validation** - Only errors cause non-zero exit codes.
+
+Implementation location: `packages/interfacectl-cli/src/commands/validate.ts:330-348`
+
+```typescript
+} else {
+ // Filter to only error-level findings for exit code determination
+ const errorFindings = violationFindings.filter((f) => f.severity === "error");
+ if (errorFindings.length === 0) {
+ exitCode = 0; // Only warnings, don't fail
+ } else {
+ // Find the highest severity category (E2 > E1)
+ // ... exit code logic based on error findings only
+ }
+}
+```
+
+## Test Coverage
+
+All scenarios are covered in tests:
+
+- ✅ Schema accepts contract without `allowedColors`
+- ✅ Schema accepts contract with `color` policy
+- ✅ Deprecation warning emitted for `allowedColors`
+- ✅ Raw literal detection with `warn` policy (severity: warning)
+- ✅ Raw literal detection with `strict` policy (severity: error)
+- ✅ Allowlist/denylist behavior
+- ✅ Token namespace validation
+- ✅ Policy `off` skips checks
+
+## Ready for surfaces-monorepo Integration
+
+The implementation is ready for use in surfaces-monorepo:
+
+1. **Contracts with `allowedColors`** will:
+ - ✅ Pass schema validation
+ - ✅ Emit `contract.deprecated-field` warnings
+ - ✅ Continue to compliance checks (layout pageFrame drift will appear)
+
+2. **Contracts with `color` policy** will:
+ - ✅ Pass schema validation
+ - ✅ Emit `color.raw-value.used` findings based on policy (warn/strict)
+ - ✅ Emit `color.token.namespace.violation` warnings for invalid tokens
+
+3. **Exit codes**:
+ - ✅ `rawValues.policy: "warn"` → warnings only, exit code 0
+ - ✅ `rawValues.policy: "strict"` → errors, exit code based on category (E1/E2)
+
+## Next Steps for surfaces-monorepo
+
+1. Pull updated interfacectl changes
+2. Run `pnpm validate:ci`
+3. Verify output includes:
+ - `contract.deprecated-field` warnings for legacy `allowedColors`
+ - Actual compliance checks (layout pageFrame drift)
+4. (Optional) Add `color` policy section to contract:
+ ```json
+ {
+ "color": {
+ "sourceOfTruth": {
+ "type": "tokens",
+ "tokenNamespaces": ["--color-"]
+ },
+ "rawValues": {
+ "policy": "warn"
+ }
+ }
+ }
+ ```
+5. Leave `allowedColors` on surfaces for one iteration to see both deprecation warnings and v1 color findings
diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts.map b/packages/interfacectl-cli/dist/commands/validate.d.ts.map
index e1d5039..9579669 100644
--- a/packages/interfacectl-cli/dist/commands/validate.d.ts.map
+++ b/packages/interfacectl-cli/dist/commands/validate.d.ts.map
@@ -1 +1 @@
-{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAeA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAmCpC,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;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAiQjB"}
\ No newline at end of file
+{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAeA,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAmCpC,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;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAqSjB"}
\ No newline at end of file
diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js
index ba4e870..b4784b3 100644
--- a/packages/interfacectl-cli/dist/commands/validate.js
+++ b/packages/interfacectl-cli/dist/commands/validate.js
@@ -126,15 +126,19 @@ export async function runValidateCommand(options) {
};
if (!structureResult.ok || !structureResult.contract) {
if (!isJson) {
- printHeader(pc.red("✖ Contract structure validation failed"), textReporter);
+ printHeader(pc.red("✖ Contract schema validation failed (capability gap)"), textReporter);
+ textReporter.error(pc.dim("Schema validation errors indicate the contract structure is not supported by this version of interfacectl."));
for (const error of structureResult.errors) {
textReporter.error(pc.red(` • ${error}`));
}
}
else {
for (const error of structureResult.errors) {
+ // Check if this is an additionalProperties error (capability gap)
+ const isCapabilityGap = error.includes("Additional property") ||
+ error.includes("is not allowed");
findings.push({
- code: "contract.schema-error",
+ code: isCapabilityGap ? "contract.schema-unsupported-field" : "contract.schema-error",
severity: "error",
category: "E0",
message: error,
@@ -145,6 +149,23 @@ export async function runValidateCommand(options) {
return finalize(e0ExitCode, initialContractVersion);
}
const contract = structureResult.contract;
+ // Check for deprecated allowedColors fields
+ for (let i = 0; i < contract.surfaces.length; i++) {
+ const surface = contract.surfaces[i];
+ if (surface.allowedColors !== undefined) {
+ const finding = {
+ code: "contract.deprecated-field",
+ severity: "warning",
+ category: "E0",
+ message: `allowedColors is deprecated. Migrate to color.sourceOfTruth + color.rawValues policy.`,
+ location: `/surfaces/${i}/allowedColors`,
+ };
+ findings.push(finding);
+ if (!isJson) {
+ textReporter.warn(pc.yellow(` • Surface "${surface.id}": allowedColors is deprecated (use color.sourceOfTruth + color.rawValues)`));
+ }
+ }
+ }
const surfaceFilters = new Set((options.surfaceFilters ?? []).map((value) => value.trim()));
const structuralDescriptorResult = await collectSurfaceDescriptors({
workspaceRoot,
@@ -188,24 +209,31 @@ export async function runValidateCommand(options) {
exitCode = 0;
}
else {
- // Find the highest severity category (E2 > E1)
- let maxCategory = null;
- for (const finding of violationFindings) {
- const category = finding.category;
- if (category === "E2") {
- maxCategory = "E2";
- break; // E2 is highest, no need to continue
- }
- else if (category === "E1" && (maxCategory === null || maxCategory === "E1")) {
- maxCategory = "E1";
- }
- }
- if (maxCategory) {
- exitCode = getExitCodeForCategory(maxCategory, exitCodeVersion);
+ // Filter to only error-level findings for exit code determination
+ const errorFindings = violationFindings.filter((f) => f.severity === "error");
+ if (errorFindings.length === 0) {
+ exitCode = 0; // Only warnings, don't fail
}
else {
- // Fallback (should not happen, but handle gracefully)
- exitCode = exitCodeVersion === "v2" ? 30 : 1;
+ // Find the highest severity category (E2 > E1)
+ let maxCategory = null;
+ for (const finding of errorFindings) {
+ const category = finding.category;
+ if (category === "E2") {
+ maxCategory = "E2";
+ break; // E2 is highest, no need to continue
+ }
+ else if (category === "E1" && (maxCategory === null || maxCategory === "E1")) {
+ maxCategory = "E1";
+ }
+ }
+ if (maxCategory) {
+ exitCode = getExitCodeForCategory(maxCategory, exitCodeVersion);
+ }
+ else {
+ // Fallback (should not happen, but handle gracefully)
+ exitCode = exitCodeVersion === "v2" ? 30 : 1;
+ }
}
// Print deprecation warning for v1
if (exitCodeVersion === "v1") {
@@ -272,8 +300,16 @@ function mapViolationsToFindings(summary) {
"layout-width-exceeded": "layout.width-exceeded",
"layout-width-undetermined": "layout.width-undetermined",
"layout-container-missing": "layout.container-missing",
+ "layout-pageframe-container-not-found": "layout.pageframe.container-not-found",
+ "layout-pageframe-maxwidth-mismatch": "layout.pageframe.maxwidth-mismatch",
+ "layout-pageframe-padding-mismatch": "layout.pageframe.padding-mismatch",
+ "layout-pageframe-selector-unsupported": "layout.pageframe.selector-unsupported",
+ "layout-pageframe-non-deterministic-value": "layout.pageframe.non-deterministic-value",
+ "layout-pageframe-unextractable-value": "layout.pageframe.unextractable-value",
"motion-duration-not-allowed": "motion.duration",
"motion-timing-not-allowed": "motion.timing",
+ "color-raw-value-used": "color.raw-value.used",
+ "color-token-namespace-violation": "color.token.namespace.violation",
};
for (const report of summary.surfaceReports) {
for (const violation of report.violations) {
@@ -331,6 +367,45 @@ function mapViolationsToFindings(summary) {
details.missingContainers ?? details.containerSources;
break;
}
+ case "layout-pageframe-selector-unsupported": {
+ finding.expected = details.supportedSelectors;
+ finding.found = details.selector;
+ break;
+ }
+ case "layout-pageframe-container-not-found": {
+ finding.expected = details.selector;
+ finding.found = null;
+ break;
+ }
+ case "layout-pageframe-maxwidth-mismatch": {
+ finding.expected = details.expected;
+ finding.found = details.actual;
+ break;
+ }
+ case "layout-pageframe-padding-mismatch": {
+ finding.expected = details.expected;
+ finding.found = {
+ left: details.actualLeft,
+ right: details.actualRight,
+ };
+ break;
+ }
+ case "layout-pageframe-non-deterministic-value": {
+ finding.expected = details.expected;
+ finding.found = details.actual ?? {
+ left: details.actualLeft,
+ right: details.actualRight,
+ };
+ break;
+ }
+ case "layout-pageframe-unextractable-value": {
+ finding.expected = details.expected;
+ finding.found = details.actual ?? {
+ left: details.actualLeft,
+ right: details.actualRight,
+ };
+ break;
+ }
case "motion-duration-not-allowed": {
finding.expected = details.allowedDurations;
finding.found = details.durationMs;
@@ -356,6 +431,25 @@ function mapViolationsToFindings(summary) {
finding.found = violation.surfaceId;
break;
}
+ case "color-raw-value-used": {
+ finding.expected = details.allowedValues;
+ finding.found = details.colorValue;
+ // Set severity based on policy: "warn" -> warning, "strict" -> error
+ if (details.policy === "warn") {
+ finding.severity = "warning";
+ }
+ else if (details.policy === "strict") {
+ finding.severity = "error";
+ }
+ break;
+ }
+ case "color-token-namespace-violation": {
+ finding.expected = details.allowedNamespaces;
+ finding.found = details.tokenName;
+ // Token namespace violations are warnings by default
+ finding.severity = "warning";
+ break;
+ }
default:
break;
}
@@ -442,7 +536,8 @@ function printSummary(summary, output) {
}
return;
}
- printHeader(pc.red("✖ Contract violations detected"), output);
+ printHeader(pc.red("✖ Surface compliance violations detected"), output);
+ output.log(pc.dim("Compliance violations indicate surfaces do not match the contract requirements."));
for (const report of summary.surfaceReports) {
if (report.violations.length === 0) {
output.log(pc.green(` • ${report.surfaceId}: OK`));
diff --git a/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map b/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map
index 989948e..52d29ba 100644
--- a/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map
+++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.d.ts.map
@@ -1 +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
+{"version":3,"file":"static-analysis.d.ts","sourceRoot":"","sources":["../../src/descriptors/static-analysis.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EAOvB,MAAM,kCAAkC,CAAC;AA4D1C,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,CA0C1C"}
\ 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
index d71dd4a..28c3a91 100644
--- a/packages/interfacectl-cli/dist/descriptors/static-analysis.js
+++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.js
@@ -4,6 +4,30 @@ 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 PAGE_CONTAINER_ATTRIBUTE_REGEX = /data-contract\s*=\s*(?:"page-container"|'page-container'|{`page-container`}|{\s*["'`]page-container["'`]\s*})/g;
+// Inline style extraction
+const INLINE_STYLE_REGEX = /style\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g;
+const INLINE_MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*px/gi;
+const INLINE_PADDING_LEFT_REGEX = /padding-left\s*:\s*([0-9.]+)\s*px/gi;
+const INLINE_PADDING_RIGHT_REGEX = /padding-right\s*:\s*([0-9.]+)\s*px/gi;
+const INLINE_PADDING_INLINE_REGEX = /padding-inline\s*:\s*([0-9.]+)\s*px/gi;
+// CSS rule extraction for [data-contract="page-container"]
+const CSS_SELECTOR_PAGE_CONTAINER_REGEX = /\[data-contract\s*=\s*["']page-container["']\]\s*\{([^}]+)\}/gi;
+const CSS_MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*px/gi;
+const CSS_PADDING_LEFT_REGEX = /padding-left\s*:\s*([0-9.]+)\s*px/gi;
+const CSS_PADDING_RIGHT_REGEX = /padding-right\s*:\s*([0-9.]+)\s*px/gi;
+const CSS_PADDING_INLINE_REGEX = /padding-inline\s*:\s*([0-9.]+)\s*px/gi;
+// Tailwind class extraction (best-effort)
+const TAILWIND_MAX_WIDTH_REGEX = /max-w-\[([0-9.]+)px\]/gi;
+const TAILWIND_PADDING_X_REGEX = /px-\[([0-9.]+)px\]/gi;
+const TAILWIND_PADDING_LEFT_REGEX = /pl-\[([0-9.]+)px\]/gi;
+const TAILWIND_PADDING_RIGHT_REGEX = /pr-\[([0-9.]+)px\]/gi;
+// Non-deterministic value detection
+const CLAMP_REGEX = /clamp\s*\(/i;
+const CALC_REGEX = /calc\s*\(/i;
+// Optional CSS custom properties (fallback)
+const PAGE_FRAME_MAX_WIDTH_VAR_REGEX = /--contract-page-frame-max-width\s*:\s*([0-9.]+)\s*px/i;
+const PAGE_FRAME_PADDING_VAR_REGEX = /--contract-page-frame-padding-x\s*:\s*([0-9.]+)\s*px/i;
const COMMON_GLOBBY_IGNORES = [
"**/node_modules/**",
"**/.next/**",
@@ -41,7 +65,7 @@ export async function collectSurfaceDescriptors(options) {
});
continue;
}
- const descriptorResult = await extractSurfaceDescriptor(options.workspaceRoot, surfaceRoot, surface.id);
+ const descriptorResult = await extractSurfaceDescriptor(options.workspaceRoot, surfaceRoot, surface.id, surface);
structuralDescriptors.push(descriptorResult.descriptor);
warnings.push(...descriptorResult.warnings);
errors.push(...descriptorResult.errors);
@@ -55,7 +79,7 @@ function resolveSurfaceRoot(workspaceRoot, surface, surfaceRootMap) {
}
return path.join(workspaceRoot, "apps", surface.id);
}
-async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId) {
+async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId, surface) {
const warnings = [];
const errors = [];
const sectionFiles = await globby(["app/**/*.{ts,tsx,js,jsx}"], {
@@ -79,7 +103,7 @@ async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId) {
gitignore: true,
ignore: COMMON_GLOBBY_IGNORES,
});
- const layout = await extractLayout(layoutCssFiles, sectionFiles, workspaceRoot, fileContentCache);
+ const layout = await extractLayout(layoutCssFiles, sectionFiles, workspaceRoot, fileContentCache, surface);
const fonts = await extractFonts(surfaceRoot, sectionFiles, workspaceRoot, fileContentCache);
if (fonts.length === 0) {
const globalsPath = path.join(surfaceRoot, "app", "globals.css");
@@ -183,7 +207,7 @@ async function extractColors(surfaceRoot, cssFilePaths, sectionFiles, workspaceR
}
return [...colorValues.values()].sort((a, b) => a.value.localeCompare(b.value));
}
-async function extractLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache) {
+async function extractLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache, surface) {
let maxWidth = null;
let layoutSource;
for (const cssPath of cssFilePaths) {
@@ -211,11 +235,305 @@ async function extractLayout(cssFilePaths, sectionFiles, workspaceRoot, fileCont
containerSources.add(path.relative(workspaceRoot, filePath));
}
}
+ // Extract pageFrame layout if contract defines it
+ let pageFrame;
+ if (surface?.layout.pageFrame) {
+ pageFrame = await extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache, surface.layout.pageFrame);
+ }
return {
maxContentWidth: maxWidth,
containers: [...containers].sort(),
containerSources: [...containerSources].sort(),
source: layoutSource,
+ pageFrame,
+ };
+}
+async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache, pageFrameContract) {
+ if (!pageFrameContract) {
+ return undefined;
+ }
+ const containerSelector = pageFrameContract.containerSelector;
+ // Check if selector is supported (v1 only supports data-contract="page-container")
+ const isSupportedSelector = containerSelector === '[data-contract="page-container"]' ||
+ containerSelector === "[data-contract='page-container']" ||
+ containerSelector === '[data-contract={page-container}]';
+ if (!isSupportedSelector) {
+ // Return undefined to trigger selectorUnsupported violation
+ return undefined;
+ }
+ // Check if page-container marker exists in source files
+ let containerFound = false;
+ let containerSource;
+ let containerFileContent;
+ for (const filePath of sectionFiles) {
+ const content = await readFileCached(filePath, fileContentCache);
+ // Reset regex state
+ PAGE_CONTAINER_ATTRIBUTE_REGEX.lastIndex = 0;
+ if (PAGE_CONTAINER_ATTRIBUTE_REGEX.test(content)) {
+ containerFound = true;
+ containerSource = path.relative(workspaceRoot, filePath);
+ containerFileContent = content;
+ break;
+ }
+ }
+ if (!containerFound) {
+ // Container marker not found - return partial descriptor
+ return {
+ containerSelector,
+ maxWidthPx: null,
+ paddingLeftPx: null,
+ paddingRightPx: null,
+ source: undefined,
+ maxWidthHasClampCalc: undefined,
+ paddingHasClampCalc: undefined,
+ };
+ }
+ let maxWidthPx = null;
+ let paddingLeftPx = null;
+ let paddingRightPx = null;
+ let extractionSource;
+ let maxWidthHasClampCalc = false;
+ let paddingHasClampCalc = false;
+ // Strategy A: Extract from inline styles on the marked element
+ if (containerFileContent) {
+ INLINE_STYLE_REGEX.lastIndex = 0;
+ let styleMatch;
+ while ((styleMatch = INLINE_STYLE_REGEX.exec(containerFileContent)) !== null) {
+ const styleContent = styleMatch[1] ?? styleMatch[2] ?? styleMatch[3] ?? styleMatch[4] ?? "";
+ // Extract max-width
+ if (maxWidthPx === null) {
+ INLINE_MAX_WIDTH_REGEX.lastIndex = 0;
+ const maxWidthMatch = INLINE_MAX_WIDTH_REGEX.exec(styleContent);
+ if (maxWidthMatch) {
+ const maxWidthValue = maxWidthMatch[0];
+ // Check if this specific max-width value uses clamp/calc
+ if (CLAMP_REGEX.test(maxWidthValue) || CALC_REGEX.test(maxWidthValue)) {
+ maxWidthHasClampCalc = true;
+ }
+ else {
+ const value = Number.parseFloat(maxWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ maxWidthPx = value;
+ extractionSource = containerSource;
+ }
+ }
+ }
+ }
+ // Extract padding
+ if (paddingLeftPx === null || paddingRightPx === null) {
+ INLINE_PADDING_INLINE_REGEX.lastIndex = 0;
+ const paddingInlineMatch = INLINE_PADDING_INLINE_REGEX.exec(styleContent);
+ if (paddingInlineMatch) {
+ const paddingValue = paddingInlineMatch[0];
+ // Check if this specific padding value uses clamp/calc
+ if (CLAMP_REGEX.test(paddingValue) || CALC_REGEX.test(paddingValue)) {
+ paddingHasClampCalc = true;
+ }
+ else {
+ const value = Number.parseFloat(paddingInlineMatch[1]);
+ if (Number.isFinite(value)) {
+ paddingLeftPx = value;
+ paddingRightPx = value;
+ extractionSource = containerSource;
+ }
+ }
+ }
+ else {
+ INLINE_PADDING_LEFT_REGEX.lastIndex = 0;
+ INLINE_PADDING_RIGHT_REGEX.lastIndex = 0;
+ const leftMatch = INLINE_PADDING_LEFT_REGEX.exec(styleContent);
+ const rightMatch = INLINE_PADDING_RIGHT_REGEX.exec(styleContent);
+ if (leftMatch && rightMatch) {
+ const leftValueStr = leftMatch[0];
+ const rightValueStr = rightMatch[0];
+ // Check if padding values use clamp/calc
+ if (CLAMP_REGEX.test(leftValueStr) ||
+ CALC_REGEX.test(leftValueStr) ||
+ CLAMP_REGEX.test(rightValueStr) ||
+ CALC_REGEX.test(rightValueStr)) {
+ paddingHasClampCalc = true;
+ }
+ else {
+ const leftValue = Number.parseFloat(leftMatch[1]);
+ const rightValue = Number.parseFloat(rightMatch[1]);
+ if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) {
+ paddingLeftPx = leftValue;
+ paddingRightPx = rightValue;
+ extractionSource = containerSource;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ // Strategy B: Extract from CSS rules targeting [data-contract="page-container"]
+ if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ for (const cssPath of cssFilePaths) {
+ const cssContent = await readFileCached(cssPath, fileContentCache);
+ CSS_SELECTOR_PAGE_CONTAINER_REGEX.lastIndex = 0;
+ let selectorMatch;
+ while ((selectorMatch = CSS_SELECTOR_PAGE_CONTAINER_REGEX.exec(cssContent)) !== null) {
+ const ruleContent = selectorMatch[1];
+ // Extract max-width (check for clamp/calc in max-width declaration)
+ if (maxWidthPx === null) {
+ // First check if max-width exists (even with clamp/calc)
+ const maxWidthDeclMatch = /max-width\s*:\s*([^;]+)/i.exec(ruleContent);
+ if (maxWidthDeclMatch) {
+ const maxWidthValue = maxWidthDeclMatch[1].trim();
+ // Check if this specific max-width value uses clamp/calc
+ if (CLAMP_REGEX.test(maxWidthValue) || CALC_REGEX.test(maxWidthValue)) {
+ maxWidthHasClampCalc = true;
+ }
+ else {
+ // Try to extract px value
+ CSS_MAX_WIDTH_REGEX.lastIndex = 0;
+ const maxWidthMatch = CSS_MAX_WIDTH_REGEX.exec(ruleContent);
+ if (maxWidthMatch) {
+ const value = Number.parseFloat(maxWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ maxWidthPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ }
+ }
+ // Extract padding (check for clamp/calc in padding declarations)
+ if (paddingLeftPx === null || paddingRightPx === null) {
+ // First check if padding-inline exists (even with clamp/calc)
+ const paddingInlineDeclMatch = /padding-inline\s*:\s*([^;]+)/i.exec(ruleContent);
+ if (paddingInlineDeclMatch) {
+ const paddingValue = paddingInlineDeclMatch[1].trim();
+ // Check if this specific padding value uses clamp/calc
+ if (CLAMP_REGEX.test(paddingValue) || CALC_REGEX.test(paddingValue)) {
+ paddingHasClampCalc = true;
+ }
+ else {
+ // Try to extract px value
+ CSS_PADDING_INLINE_REGEX.lastIndex = 0;
+ const paddingInlineMatch = CSS_PADDING_INLINE_REGEX.exec(ruleContent);
+ if (paddingInlineMatch) {
+ const value = Number.parseFloat(paddingInlineMatch[1]);
+ if (Number.isFinite(value)) {
+ paddingLeftPx = value;
+ paddingRightPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ }
+ else {
+ CSS_PADDING_LEFT_REGEX.lastIndex = 0;
+ CSS_PADDING_RIGHT_REGEX.lastIndex = 0;
+ const leftMatch = CSS_PADDING_LEFT_REGEX.exec(ruleContent);
+ const rightMatch = CSS_PADDING_RIGHT_REGEX.exec(ruleContent);
+ if (leftMatch && rightMatch) {
+ const leftValueStr = leftMatch[0];
+ const rightValueStr = rightMatch[0];
+ // Check if padding values use clamp/calc
+ if (CLAMP_REGEX.test(leftValueStr) ||
+ CALC_REGEX.test(leftValueStr) ||
+ CLAMP_REGEX.test(rightValueStr) ||
+ CALC_REGEX.test(rightValueStr)) {
+ paddingHasClampCalc = true;
+ }
+ else {
+ const leftValue = Number.parseFloat(leftMatch[1]);
+ const rightValue = Number.parseFloat(rightMatch[1]);
+ if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) {
+ paddingLeftPx = leftValue;
+ paddingRightPx = rightValue;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ // Strategy C: Extract from Tailwind bracket classes (best-effort, v1)
+ if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ for (const filePath of sectionFiles) {
+ const content = await readFileCached(filePath, fileContentCache);
+ // Extract max-width from max-w-[NNNpx]
+ if (maxWidthPx === null) {
+ TAILWIND_MAX_WIDTH_REGEX.lastIndex = 0;
+ const maxWidthMatch = TAILWIND_MAX_WIDTH_REGEX.exec(content);
+ if (maxWidthMatch) {
+ const value = Number.parseFloat(maxWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ maxWidthPx = value;
+ extractionSource = path.relative(workspaceRoot, filePath);
+ }
+ }
+ }
+ // Extract padding from px-[NNpx] or pl-[NNpx]/pr-[NNpx]
+ if (paddingLeftPx === null || paddingRightPx === null) {
+ TAILWIND_PADDING_X_REGEX.lastIndex = 0;
+ const paddingXMatch = TAILWIND_PADDING_X_REGEX.exec(content);
+ if (paddingXMatch) {
+ const value = Number.parseFloat(paddingXMatch[1]);
+ if (Number.isFinite(value)) {
+ paddingLeftPx = value;
+ paddingRightPx = value;
+ extractionSource = path.relative(workspaceRoot, filePath);
+ }
+ }
+ else {
+ TAILWIND_PADDING_LEFT_REGEX.lastIndex = 0;
+ TAILWIND_PADDING_RIGHT_REGEX.lastIndex = 0;
+ const leftMatch = TAILWIND_PADDING_LEFT_REGEX.exec(content);
+ const rightMatch = TAILWIND_PADDING_RIGHT_REGEX.exec(content);
+ if (leftMatch && rightMatch) {
+ const leftValue = Number.parseFloat(leftMatch[1]);
+ const rightValue = Number.parseFloat(rightMatch[1]);
+ if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) {
+ paddingLeftPx = leftValue;
+ paddingRightPx = rightValue;
+ extractionSource = path.relative(workspaceRoot, filePath);
+ }
+ }
+ }
+ }
+ }
+ }
+ // Optional: CSS custom properties (fallback, not required)
+ if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ for (const cssPath of cssFilePaths) {
+ const cssContent = await readFileCached(cssPath, fileContentCache);
+ if (maxWidthPx === null) {
+ const varMatch = cssContent.match(PAGE_FRAME_MAX_WIDTH_VAR_REGEX);
+ if (varMatch) {
+ const value = Number.parseFloat(varMatch[1]);
+ if (Number.isFinite(value)) {
+ maxWidthPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ if (paddingLeftPx === null || paddingRightPx === null) {
+ const paddingXMatch = cssContent.match(PAGE_FRAME_PADDING_VAR_REGEX);
+ if (paddingXMatch) {
+ const value = Number.parseFloat(paddingXMatch[1]);
+ if (Number.isFinite(value)) {
+ paddingLeftPx = value;
+ paddingRightPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ }
+ }
+ return {
+ containerSelector,
+ maxWidthPx,
+ paddingLeftPx,
+ paddingRightPx,
+ source: extractionSource ?? containerSource,
+ maxWidthHasClampCalc: maxWidthHasClampCalc || undefined,
+ paddingHasClampCalc: paddingHasClampCalc || undefined,
};
}
async function extractMotion(cssFilePaths, workspaceRoot, fileContentCache) {
diff --git a/packages/interfacectl-cli/docs/color-policy.md b/packages/interfacectl-cli/docs/color-policy.md
new file mode 100644
index 0000000..247a0ac
--- /dev/null
+++ b/packages/interfacectl-cli/docs/color-policy.md
@@ -0,0 +1,293 @@
+# Color Policy
+
+The color policy section provides a flexible, evolution-friendly way to enforce color usage across surfaces. It replaces the deprecated `allowedColors` field on individual surfaces with a centralized policy that supports static analysis.
+
+## Overview
+
+The color policy is defined at the contract level and applies to all surfaces. It consists of four main sections:
+
+- **`sourceOfTruth`**: Defines where colors should come from (tokens vs. none)
+- **`rawValues`**: Controls detection and enforcement of raw color literals (hex, rgb, hsl)
+- **`semantics`**: (Future) Semantic role enforcement (accent, text, background, border)
+- **`consistency`**: (Future) Cross-surface consistency checks
+
+## Migration from `allowedColors`
+
+The `allowedColors` field on surfaces is **deprecated** and will be removed in a future version. Migrate to the new color policy:
+
+### Before (Deprecated)
+
+```json
+{
+ "surfaces": [
+ {
+ "id": "web-app",
+ "allowedColors": ["var(--color-primary)", "var(--color-secondary)"]
+ }
+ ]
+}
+```
+
+### After (Recommended)
+
+```json
+{
+ "color": {
+ "sourceOfTruth": {
+ "type": "tokens",
+ "tokenNamespaces": ["--color-"]
+ },
+ "rawValues": {
+ "policy": "warn"
+ }
+ },
+ "surfaces": [
+ {
+ "id": "web-app"
+ }
+ ]
+}
+```
+
+When `allowedColors` is present, interfacectl will emit a deprecation warning (`contract.deprecated-field`) but will not fail validation. The warning includes a JSON pointer to the deprecated field and guidance on migration.
+
+## Configuration
+
+### `sourceOfTruth`
+
+Defines the authoritative source for colors.
+
+```json
+{
+ "sourceOfTruth": {
+ "type": "tokens" | "none",
+ "tokenNamespaces": ["--color-", "--ds-color-"] // Required if type is "tokens"
+ }
+}
+```
+
+- **`type: "tokens"`**: Colors must come from CSS variables (design tokens). Requires `tokenNamespaces`.
+- **`type: "none"`**: No source-of-truth enforcement (useful for gradual migration).
+
+**Token Namespace Validation**: When `type` is `"tokens"`, interfacectl validates that all CSS variable references start with one of the configured namespaces. Variables that don't match trigger `color.token.namespace.violation`.
+
+Example violation:
+- Contract: `tokenNamespaces: ["--color-"]`
+- Code: `var(--custom-red)` → Violation (doesn't start with `--color-`)
+- Code: `var(--color-primary)` → ✅ Valid
+
+### `rawValues`
+
+Controls detection and enforcement of raw color literals (hex, rgb, hsl, hsla).
+
+```json
+{
+ "rawValues": {
+ "policy": "off" | "warn" | "strict",
+ "allowlist": ["#ffffff", "#000000"], // Optional
+ "denylist": ["#ff0000"] // Optional
+ }
+}
+```
+
+**Policy Levels:**
+- **`"off"`**: No raw literal detection (except denylist violations)
+- **`"warn"`**: Emit warnings for raw literals (does not fail validation)
+- **`"strict"`**: Emit errors for raw literals (fails validation with exit code 1)
+
+**Allowlist & Denylist:**
+- **Allowlist**: Raw literals that are explicitly permitted (e.g., `#ffffff`, `#000000` for base colors)
+- **Denylist**: Raw literals that are always forbidden, regardless of policy level
+
+**Detection:**
+- Hex colors: `#rgb`, `#rrggbb`, `#rrggbbaa`
+- RGB/RGBA: `rgb(...)`, `rgba(...)`
+- HSL/HSLA: `hsl(...)`, `hsla(...)`
+- CSS variables (`var(--...)`) are **not** considered raw literals
+
+**Example:**
+```json
+{
+ "rawValues": {
+ "policy": "warn",
+ "allowlist": ["#ffffff", "#000000"],
+ "denylist": ["#ff0000"]
+ }
+}
+```
+
+- `#ff00aa` → Warning (not allowlisted)
+- `#ffffff` → ✅ Allowed (in allowlist)
+- `#ff0000` → Violation (in denylist, even if allowlisted)
+
+### `semantics` (Future)
+
+Semantic role enforcement for colors. Not yet enforced in v1, but schema accepts the structure for future use.
+
+```json
+{
+ "semantics": {
+ "roles": {
+ "accent": { "enforcement": "warn" },
+ "text": { "enforcement": "strict" },
+ "background": { "enforcement": "warn" },
+ "border": { "enforcement": "off" }
+ }
+ }
+}
+```
+
+### `consistency` (Future)
+
+Cross-surface consistency checks. Not yet enforced in v1, but schema accepts the structure for future use.
+
+```json
+{
+ "consistency": {
+ "acrossSurfaces": {
+ "enforcement": "warn",
+ "signals": ["token-name", "css-var-name", "class-fragment"]
+ }
+ }
+}
+```
+
+## Validation Codes
+
+### `color.raw-value.used`
+
+Emitted when a raw color literal is detected and violates the policy.
+
+- **Severity**: `warn` or `error` (based on `rawValues.policy`)
+- **Category**: `E1` (Token Policy)
+- **Details**:
+ - `colorValue`: The detected raw literal
+ - `source`: File location (if available)
+ - `policy`: The policy level (`warn` or `strict`)
+ - `jsonPointer`: `/color/rawValues`
+
+**Example:**
+```json
+{
+ "code": "color.raw-value.used",
+ "severity": "warn",
+ "category": "E1",
+ "message": "Raw color literal \"#ff00aa\" detected. Use design tokens instead.",
+ "location": "app/globals.css",
+ "found": "#ff00aa"
+}
+```
+
+### `color.token.namespace.violation`
+
+Emitted when a CSS variable doesn't start with any allowed namespace.
+
+- **Severity**: `warn` (default)
+- **Category**: `E1` (Token Policy)
+- **Details**:
+ - `tokenName`: The variable name (e.g., `--not-allowed-token`)
+ - `allowedNamespaces`: Array of allowed namespace prefixes
+ - `source`: File location (if available)
+ - `jsonPointer`: `/color/sourceOfTruth/tokenNamespaces`
+
+**Example:**
+```json
+{
+ "code": "color.token.namespace.violation",
+ "severity": "warn",
+ "category": "E1",
+ "message": "Color token \"--not-allowed-token\" does not start with any allowed namespace. Allowed namespaces: --color-, --ds-color-.",
+ "location": "app/components/Button.tsx",
+ "found": "--not-allowed-token",
+ "expected": ["--color-", "--ds-color-"]
+}
+```
+
+### `contract.deprecated-field`
+
+Emitted when `allowedColors` is present on a surface.
+
+- **Severity**: `warning`
+- **Category**: `E0` (Artifact Invalid)
+- **Details**:
+ - `field`: `"allowedColors"`
+ - `location`: JSON pointer to the field (e.g., `/surfaces/0/allowedColors`)
+ - `replacement`: `["color.sourceOfTruth", "color.rawValues"]`
+
+**Example:**
+```json
+{
+ "code": "contract.deprecated-field",
+ "severity": "warning",
+ "category": "E0",
+ "message": "allowedColors is deprecated. Migrate to color.sourceOfTruth + color.rawValues policy.",
+ "location": "/surfaces/0/allowedColors"
+}
+```
+
+## Static Analysis
+
+v1 color validation uses static analysis to extract colors from:
+
+- CSS files (`.css`)
+- Inline styles in component files (`.tsx`, `.jsx`, `.ts`, `.js`)
+- CSS variable references (`var(--...)`)
+- Raw color literals in CSS declarations
+
+**Limitations:**
+- Does not validate runtime computed colors
+- Does not parse complex CSS expressions (gradients, calc, etc.)
+- Conservative detection to avoid false positives
+
+## Best Practices
+
+1. **Start with `warn`**: Use `policy: "warn"` initially to identify raw literals without blocking builds
+2. **Use allowlists sparingly**: Only allowlist truly necessary base colors (e.g., `#ffffff`, `#000000`)
+3. **Denylist dangerous colors**: Use denylist to explicitly forbid problematic colors
+4. **Namespace tokens consistently**: Use a single namespace prefix (e.g., `--color-`) for all design tokens
+5. **Migrate gradually**: Keep `allowedColors` during migration, then remove once all surfaces use tokens
+
+## Example Contract
+
+```json
+{
+ "contractId": "ui.contract",
+ "version": "1.0.0",
+ "color": {
+ "sourceOfTruth": {
+ "type": "tokens",
+ "tokenNamespaces": ["--color-"]
+ },
+ "rawValues": {
+ "policy": "warn",
+ "allowlist": ["#ffffff", "#000000"],
+ "denylist": []
+ }
+ },
+ "surfaces": [
+ {
+ "id": "web-app",
+ "displayName": "Web App",
+ "type": "web",
+ "requiredSections": ["header"],
+ "allowedFonts": ["var(--font-primary)"],
+ "layout": {
+ "maxContentWidth": 1200
+ }
+ }
+ ],
+ "sections": [
+ {
+ "id": "header",
+ "intent": "Page header",
+ "description": "Main header section"
+ }
+ ],
+ "constraints": {
+ "motion": {
+ "allowedDurationsMs": [200, 300],
+ "allowedTimingFunctions": ["ease", "ease-in-out"]
+ }
+ }
+}
+```
diff --git a/packages/interfacectl-cli/docs/layout-pageframe.md b/packages/interfacectl-cli/docs/layout-pageframe.md
new file mode 100644
index 0000000..253999b
--- /dev/null
+++ b/packages/interfacectl-cli/docs/layout-pageframe.md
@@ -0,0 +1,264 @@
+# Layout Page Frame Validation
+
+The `layout.pageFrame` contract section enables validation of page container layout properties using static analysis of source files.
+
+## What It Validates
+
+The pageFrame validator checks:
+
+- **Container existence**: Verifies the element matching `containerSelector` exists in the DOM (via `data-contract="page-container"` marker)
+- **Max-width**: Compares extracted `max-width` CSS property against `containerMaxWidthPx`
+- **Horizontal padding**: Compares extracted `padding-left` and `padding-right` (or `padding-inline`) against `paddingXpx`
+
+## What It Does Not Validate
+
+- Framework-specific class names as the primary mechanism (Tailwind bracket classes are supported as a fallback)
+- Visual appearance or styling beyond the specified properties
+- Responsive breakpoints or media queries
+- Animation or transition properties
+- Content structure or semantic HTML
+- Values expressed using `clamp()`, `calc()`, or other non-deterministic CSS functions
+
+## Static Analysis v1
+
+The validator uses static analysis to extract deterministic pixel values from source files. It does not require runtime execution or special CSS custom properties.
+
+### Extraction Strategy
+
+The validator extracts values in this priority order:
+
+1. **Inline styles** on the marked element (`data-contract="page-container"`)
+ - Parses `style` attribute for `max-width`, `padding-left`, `padding-right`, or `padding-inline`
+ - Supports px values only
+
+2. **CSS rules** targeting the marker selector
+ - Detects rules like `[data-contract="page-container"] { max-width: ...; padding-inline: ...; }`
+ - Supports px values only
+ - If values use `clamp()` or `calc()`, returns `layout.pageFrame.unextractable-value`
+
+3. **Tailwind bracket classes** (best-effort, v1)
+ - Supports `max-w-[NNNpx]` and `px-[NNpx]` or `pl-[NNpx]/pr-[NNpx]`
+ - If only tokenized classes exist (e.g., `px-6`), returns `layout.pageFrame.unextractable-value` with guidance
+
+4. **CSS custom properties** (optional fallback)
+ - `--contract-page-frame-max-width` and `--contract-page-frame-padding-x`
+ - Not required, but supported if present
+
+### Limitations
+
+Static analysis v1:
+- **Requires deterministic px values** - cannot validate `clamp()`, `calc()`, or responsive expressions
+- **Requires container marker** - element must have `data-contract="page-container"` attribute
+- **Selector support** - v1 only supports `[data-contract="page-container"]` selector
+- **Tailwind classes** - only bracket notation (`max-w-[1200px]`) is supported, not tokenized classes
+
+If values cannot be extracted deterministically, the validator reports `layout.pageFrame.unextractable-value` with guidance on how to express the values.
+
+## Contract Schema
+
+Add `pageFrame` as an optional property within a surface's `layout` section:
+
+```json
+{
+ "surfaces": [
+ {
+ "id": "my-surface",
+ "type": "web",
+ "layout": {
+ "maxContentWidth": 1200,
+ "pageFrame": {
+ "containerSelector": "[data-contract=\"page-container\"]",
+ "containerMaxWidthPx": 1200,
+ "paddingXpx": 24,
+ "alignment": "center",
+ "enforcement": "strict"
+ }
+ }
+ }
+ ]
+}
+```
+
+### Schema Properties
+
+- `containerSelector` (required): CSS selector identifying the primary page container. v1 only supports `[data-contract="page-container"]`
+- `containerMaxWidthPx` (required): Expected max-width in pixels
+- `paddingXpx` (required): Expected horizontal padding (left and right) in pixels
+- `alignment` (optional): `"center"` or `"left"`, defaults to `"center"` (not validated in v1)
+- `enforcement` (optional): `"strict"` or `"warn"`, defaults to `"strict"`
+ - `strict`: Any mismatch causes validation failure (non-zero exit code)
+ - `warn`: Mismatches are reported but exit code is 0
+
+## Implementation Examples
+
+### Inline Styles (Recommended)
+
+```tsx
+
+ {children}
+
+```
+
+### CSS Rule on Marker Selector
+
+```css
+[data-contract="page-container"] {
+ max-width: 1200px;
+ padding-left: 24px;
+ padding-right: 24px;
+}
+```
+
+```tsx
+
+ {children}
+
+```
+
+### Tailwind Bracket Classes (Best-Effort)
+
+```tsx
+
+ {children}
+
+```
+
+## CLI Usage
+
+Validate pageFrame layouts using static analysis:
+
+```bash
+interfacectl validate \
+ --contract path/to/interface.contract.json \
+ --root path/to/surface
+```
+
+Filter to specific surfaces:
+
+```bash
+interfacectl validate \
+ --contract path/to/interface.contract.json \
+ --root path/to/surface \
+ --surface my-surface
+```
+
+Output in JSON format:
+
+```bash
+interfacectl validate \
+ --contract path/to/interface.contract.json \
+ --root path/to/surface \
+ --format json
+```
+
+## Violation Types
+
+### `layout.pageFrame.selector-unsupported`
+
+The container selector in the contract is not supported in static analysis v1.
+
+**Resolution**: Use `[data-contract="page-container"]` as the selector.
+
+### `layout.pageFrame.container-not-found`
+
+The page container marker (`data-contract="page-container"`) was not found in source files.
+
+**Resolution**: Add `data-contract="page-container"` to the container element.
+
+### `layout.pageFrame.maxwidth-mismatch`
+
+Extracted max-width does not match the expected value.
+
+**Resolution**: Update the max-width to match the contract value.
+
+### `layout.pageFrame.padding-mismatch`
+
+Extracted padding does not match the expected value.
+
+**Resolution**: Update the padding to match the contract value.
+
+### `layout.pageFrame.unextractable-value`
+
+Values could not be extracted deterministically. This occurs when:
+- Values use `clamp()`, `calc()`, or other non-deterministic CSS functions
+- Only tokenized Tailwind classes are used (e.g., `px-6` instead of `px-[24px]`)
+- No matching styles are found in the expected locations
+
+**Resolution**: Express values using:
+- Inline styles with fixed px values
+- CSS rules targeting `[data-contract="page-container"]` with fixed px values
+- Tailwind bracket classes (e.g., `px-[24px]`)
+
+### `layout.pageFrame.non-deterministic-value`
+
+Values were found but use non-deterministic expressions like `clamp()` or `calc()`.
+
+**Resolution**: Replace with fixed px values or use inline styles.
+
+## Example Failure Output
+
+### Text Format
+
+```
+✖ Contract violations detected
+ • my-surface:
+ - Page frame max-width mismatch for surface "my-surface": expected 1200px, found 1400px.
+ - Page frame padding could not be extracted for surface "my-surface". Expected 24px. Static analysis requires deterministic px values.
+```
+
+### JSON Format
+
+```json
+{
+ "contractPath": "path/to/interface.contract.json",
+ "contractVersion": "1.0.0",
+ "summary": {
+ "errors": 2,
+ "warnings": 0
+ },
+ "findings": [
+ {
+ "code": "layout.pageframe.maxwidth-mismatch",
+ "severity": "error",
+ "category": "E2",
+ "surface": "my-surface",
+ "message": "Page frame max-width mismatch for surface \"my-surface\": expected 1200px, found 1400px.",
+ "expected": 1200,
+ "found": 1400,
+ "location": "app/layout.tsx"
+ },
+ {
+ "code": "layout.pageframe.unextractable-value",
+ "severity": "error",
+ "category": "E2",
+ "surface": "my-surface",
+ "message": "Page frame padding could not be extracted for surface \"my-surface\". Expected 24px. Static analysis requires deterministic px values.",
+ "expected": 24,
+ "found": {
+ "left": null,
+ "right": null
+ }
+ }
+ ]
+}
+```
+
+## Exit Codes
+
+- `strict` mode with violations → exit code 1 (v1) or 30 (v2)
+- `warn` mode with violations → exit code 0
+- No violations → exit code 0
+
+## Future Enhancements
+
+Runtime computed validation may be added later via `--url` option, but is not part of v1. The current implementation focuses on deterministic static analysis that works reliably in CI without requiring a running server.
diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts
index d05bb3e..c2a2f06 100644
--- a/packages/interfacectl-cli/src/commands/validate.ts
+++ b/packages/interfacectl-cli/src/commands/validate.ts
@@ -220,16 +220,24 @@ export async function runValidateCommand(
if (!structureResult.ok || !structureResult.contract) {
if (!isJson) {
printHeader(
- pc.red("✖ Contract structure validation failed"),
+ pc.red("✖ Contract schema validation failed (capability gap)"),
textReporter,
);
+ textReporter.error(
+ pc.dim(
+ "Schema validation errors indicate the contract structure is not supported by this version of interfacectl.",
+ ),
+ );
for (const error of structureResult.errors) {
textReporter.error(pc.red(` • ${error}`));
}
} else {
for (const error of structureResult.errors) {
+ // Check if this is an additionalProperties error (capability gap)
+ const isCapabilityGap = error.includes("Additional property") ||
+ error.includes("is not allowed");
findings.push({
- code: "contract.schema-error",
+ code: isCapabilityGap ? "contract.schema-unsupported-field" : "contract.schema-error",
severity: "error",
category: "E0",
message: error,
@@ -242,6 +250,29 @@ export async function runValidateCommand(
const contract = structureResult.contract;
+ // Check for deprecated allowedColors fields
+ for (let i = 0; i < contract.surfaces.length; i++) {
+ const surface = contract.surfaces[i];
+ if (surface.allowedColors !== undefined) {
+ const finding: JsonFinding = {
+ code: "contract.deprecated-field",
+ severity: "warning",
+ category: "E0",
+ message: `allowedColors is deprecated. Migrate to color.sourceOfTruth + color.rawValues policy.`,
+ location: `/surfaces/${i}/allowedColors`,
+ };
+ findings.push(finding);
+ if (!isJson) {
+ textReporter.warn(
+ pc.yellow(
+ ` • Surface "${surface.id}": allowedColors is deprecated (use color.sourceOfTruth + color.rawValues)`,
+ ),
+ );
+ }
+ }
+ }
+
+
const surfaceFilters = new Set(
(options.surfaceFilters ?? []).map((value) => value.trim()),
);
@@ -292,29 +323,34 @@ export async function runValidateCommand(
if (!isJson) {
printSummary(summary, textReporter);
}
-
// Determine exit code based on violation categories
let exitCode: number;
if (violationFindings.length === 0) {
exitCode = 0;
} else {
- // Find the highest severity category (E2 > E1)
- let maxCategory: ViolationCategory | null = null;
- for (const finding of violationFindings) {
- const category = finding.category;
- if (category === "E2") {
- maxCategory = "E2";
- break; // E2 is highest, no need to continue
- } else if (category === "E1" && (maxCategory === null || maxCategory === "E1")) {
- maxCategory = "E1";
+ // Filter to only error-level findings for exit code determination
+ const errorFindings = violationFindings.filter((f) => f.severity === "error");
+ if (errorFindings.length === 0) {
+ exitCode = 0; // Only warnings, don't fail
+ } else {
+ // Find the highest severity category (E2 > E1)
+ let maxCategory: ViolationCategory | null = null;
+ for (const finding of errorFindings) {
+ const category = finding.category;
+ if (category === "E2") {
+ maxCategory = "E2";
+ break; // E2 is highest, no need to continue
+ } else if (category === "E1" && (maxCategory === null || maxCategory === "E1")) {
+ maxCategory = "E1";
+ }
}
- }
- if (maxCategory) {
- exitCode = getExitCodeForCategory(maxCategory, exitCodeVersion);
- } else {
- // Fallback (should not happen, but handle gracefully)
- exitCode = exitCodeVersion === "v2" ? 30 : 1;
+ if (maxCategory) {
+ exitCode = getExitCodeForCategory(maxCategory, exitCodeVersion);
+ } else {
+ // Fallback (should not happen, but handle gracefully)
+ exitCode = exitCodeVersion === "v2" ? 30 : 1;
+ }
}
// Print deprecation warning for v1
@@ -410,8 +446,16 @@ function mapViolationsToFindings(
"layout-width-exceeded": "layout.width-exceeded",
"layout-width-undetermined": "layout.width-undetermined",
"layout-container-missing": "layout.container-missing",
+ "layout-pageframe-container-not-found": "layout.pageframe.container-not-found",
+ "layout-pageframe-maxwidth-mismatch": "layout.pageframe.maxwidth-mismatch",
+ "layout-pageframe-padding-mismatch": "layout.pageframe.padding-mismatch",
+ "layout-pageframe-selector-unsupported": "layout.pageframe.selector-unsupported",
+ "layout-pageframe-non-deterministic-value": "layout.pageframe.non-deterministic-value",
+ "layout-pageframe-unextractable-value": "layout.pageframe.unextractable-value",
"motion-duration-not-allowed": "motion.duration",
"motion-timing-not-allowed": "motion.timing",
+ "color-raw-value-used": "color.raw-value.used",
+ "color-token-namespace-violation": "color.token.namespace.violation",
};
for (const report of summary.surfaceReports) {
@@ -473,6 +517,45 @@ function mapViolationsToFindings(
details.missingContainers ?? details.containerSources;
break;
}
+ case "layout-pageframe-selector-unsupported": {
+ finding.expected = details.supportedSelectors;
+ finding.found = details.selector;
+ break;
+ }
+ case "layout-pageframe-container-not-found": {
+ finding.expected = details.selector;
+ finding.found = null;
+ break;
+ }
+ case "layout-pageframe-maxwidth-mismatch": {
+ finding.expected = details.expected;
+ finding.found = details.actual;
+ break;
+ }
+ case "layout-pageframe-padding-mismatch": {
+ finding.expected = details.expected;
+ finding.found = {
+ left: details.actualLeft,
+ right: details.actualRight,
+ };
+ break;
+ }
+ case "layout-pageframe-non-deterministic-value": {
+ finding.expected = details.expected;
+ finding.found = details.actual ?? {
+ left: details.actualLeft,
+ right: details.actualRight,
+ };
+ break;
+ }
+ case "layout-pageframe-unextractable-value": {
+ finding.expected = details.expected;
+ finding.found = details.actual ?? {
+ left: details.actualLeft,
+ right: details.actualRight,
+ };
+ break;
+ }
case "motion-duration-not-allowed": {
finding.expected = details.allowedDurations;
finding.found = details.durationMs;
@@ -498,6 +581,24 @@ function mapViolationsToFindings(
finding.found = violation.surfaceId;
break;
}
+ case "color-raw-value-used": {
+ finding.expected = details.allowedValues;
+ finding.found = details.colorValue;
+ // Set severity based on policy: "warn" -> warning, "strict" -> error
+ if (details.policy === "warn") {
+ finding.severity = "warning";
+ } else if (details.policy === "strict") {
+ finding.severity = "error";
+ }
+ break;
+ }
+ case "color-token-namespace-violation": {
+ finding.expected = details.allowedNamespaces;
+ finding.found = details.tokenName;
+ // Token namespace violations are warnings by default
+ finding.severity = "warning";
+ break;
+ }
default:
break;
}
@@ -614,7 +715,10 @@ function printSummary(
return;
}
- printHeader(pc.red("✖ Contract violations detected"), output);
+ printHeader(pc.red("✖ Surface compliance violations detected"), output);
+ output.log(
+ pc.dim("Compliance violations indicate surfaces do not match the contract requirements."),
+ );
for (const report of summary.surfaceReports) {
if (report.violations.length === 0) {
diff --git a/packages/interfacectl-cli/src/descriptors/static-analysis.ts b/packages/interfacectl-cli/src/descriptors/static-analysis.ts
index 60c94c9..9af1522 100644
--- a/packages/interfacectl-cli/src/descriptors/static-analysis.ts
+++ b/packages/interfacectl-cli/src/descriptors/static-analysis.ts
@@ -9,6 +9,7 @@ import {
type SurfaceLayoutDescriptor,
type SurfaceMotionDescriptor,
type SurfaceSectionDescriptor,
+ type PageFrameLayoutDescriptor,
} from "@surfaces/interfacectl-validator";
const SECTION_ATTRIBUTE_REGEX =
@@ -17,6 +18,33 @@ const SECTION_ATTRIBUTE_REGEX =
const CONTAINER_ATTRIBUTE_REGEX =
/data-contract-container\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g;
const CONTRACT_CONTAINER_TOKEN = "contract-container";
+const PAGE_CONTAINER_ATTRIBUTE_REGEX =
+ /data-contract\s*=\s*(?:"page-container"|'page-container'|{`page-container`}|{\s*["'`]page-container["'`]\s*})/g;
+// Inline style extraction
+const INLINE_STYLE_REGEX = /style\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g;
+const INLINE_MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*px/gi;
+const INLINE_PADDING_LEFT_REGEX = /padding-left\s*:\s*([0-9.]+)\s*px/gi;
+const INLINE_PADDING_RIGHT_REGEX = /padding-right\s*:\s*([0-9.]+)\s*px/gi;
+const INLINE_PADDING_INLINE_REGEX = /padding-inline\s*:\s*([0-9.]+)\s*px/gi;
+// CSS rule extraction for [data-contract="page-container"]
+const CSS_SELECTOR_PAGE_CONTAINER_REGEX = /\[data-contract\s*=\s*["']page-container["']\]\s*\{([^}]+)\}/gi;
+const CSS_MAX_WIDTH_REGEX = /max-width\s*:\s*([0-9.]+)\s*px/gi;
+const CSS_PADDING_LEFT_REGEX = /padding-left\s*:\s*([0-9.]+)\s*px/gi;
+const CSS_PADDING_RIGHT_REGEX = /padding-right\s*:\s*([0-9.]+)\s*px/gi;
+const CSS_PADDING_INLINE_REGEX = /padding-inline\s*:\s*([0-9.]+)\s*px/gi;
+// Tailwind class extraction (best-effort)
+const TAILWIND_MAX_WIDTH_REGEX = /max-w-\[([0-9.]+)px\]/gi;
+const TAILWIND_PADDING_X_REGEX = /px-\[([0-9.]+)px\]/gi;
+const TAILWIND_PADDING_LEFT_REGEX = /pl-\[([0-9.]+)px\]/gi;
+const TAILWIND_PADDING_RIGHT_REGEX = /pr-\[([0-9.]+)px\]/gi;
+// Non-deterministic value detection
+const CLAMP_REGEX = /clamp\s*\(/i;
+const CALC_REGEX = /calc\s*\(/i;
+// Optional CSS custom properties (fallback)
+const PAGE_FRAME_MAX_WIDTH_VAR_REGEX =
+ /--contract-page-frame-max-width\s*:\s*([0-9.]+)\s*px/i;
+const PAGE_FRAME_PADDING_VAR_REGEX =
+ /--contract-page-frame-padding-x\s*:\s*([0-9.]+)\s*px/i;
const COMMON_GLOBBY_IGNORES = [
"**/node_modules/**",
"**/.next/**",
@@ -93,11 +121,12 @@ export async function collectSurfaceDescriptors(
continue;
}
- const descriptorResult = await extractSurfaceDescriptor(
- options.workspaceRoot,
- surfaceRoot,
- surface.id,
- );
+ const descriptorResult = await extractSurfaceDescriptor(
+ options.workspaceRoot,
+ surfaceRoot,
+ surface.id,
+ surface,
+ );
structuralDescriptors.push(descriptorResult.descriptor);
warnings.push(...descriptorResult.warnings);
@@ -123,6 +152,7 @@ async function extractSurfaceDescriptor(
workspaceRoot: string,
surfaceRoot: string,
surfaceId: string,
+ surface?: InterfaceContract["surfaces"][number],
): Promise<{
descriptor: SurfaceDescriptor;
warnings: DescriptorIssue[];
@@ -165,6 +195,7 @@ async function extractSurfaceDescriptor(
sectionFiles,
workspaceRoot,
fileContentCache,
+ surface,
);
const fonts = await extractFonts(
surfaceRoot,
@@ -354,6 +385,7 @@ async function extractLayout(
sectionFiles: string[],
workspaceRoot: string,
fileContentCache: Map,
+ surface?: InterfaceContract["surfaces"][number],
): Promise {
let maxWidth: number | null = null;
let layoutSource: string | undefined;
@@ -385,11 +417,336 @@ async function extractLayout(
}
}
+ // Extract pageFrame layout if contract defines it
+ let pageFrame: PageFrameLayoutDescriptor | undefined;
+ if (surface?.layout.pageFrame) {
+ pageFrame = await extractPageFrameLayout(
+ cssFilePaths,
+ sectionFiles,
+ workspaceRoot,
+ fileContentCache,
+ surface.layout.pageFrame,
+ );
+ }
+
return {
maxContentWidth: maxWidth,
containers: [...containers].sort(),
containerSources: [...containerSources].sort(),
source: layoutSource,
+ pageFrame,
+ };
+}
+
+async function extractPageFrameLayout(
+ cssFilePaths: string[],
+ sectionFiles: string[],
+ workspaceRoot: string,
+ fileContentCache: Map,
+ pageFrameContract: InterfaceContract["surfaces"][number]["layout"]["pageFrame"],
+): Promise {
+ if (!pageFrameContract) {
+ return undefined;
+ }
+
+ const containerSelector = pageFrameContract.containerSelector;
+
+ // Check if selector is supported (v1 only supports data-contract="page-container")
+ const isSupportedSelector =
+ containerSelector === '[data-contract="page-container"]' ||
+ containerSelector === "[data-contract='page-container']" ||
+ containerSelector === '[data-contract={page-container}]';
+
+ if (!isSupportedSelector) {
+ // Return undefined to trigger selectorUnsupported violation
+ return undefined;
+ }
+
+ // Check if page-container marker exists in source files
+ let containerFound = false;
+ let containerSource: string | undefined;
+ let containerFileContent: string | undefined;
+ for (const filePath of sectionFiles) {
+ const content = await readFileCached(filePath, fileContentCache);
+ // Reset regex state
+ PAGE_CONTAINER_ATTRIBUTE_REGEX.lastIndex = 0;
+ if (PAGE_CONTAINER_ATTRIBUTE_REGEX.test(content)) {
+ containerFound = true;
+ containerSource = path.relative(workspaceRoot, filePath);
+ containerFileContent = content;
+ break;
+ }
+ }
+
+ if (!containerFound) {
+ // Container marker not found - return partial descriptor
+ return {
+ containerSelector,
+ maxWidthPx: null,
+ paddingLeftPx: null,
+ paddingRightPx: null,
+ source: undefined,
+ maxWidthHasClampCalc: undefined,
+ paddingHasClampCalc: undefined,
+ };
+ }
+
+ let maxWidthPx: number | null = null;
+ let paddingLeftPx: number | null = null;
+ let paddingRightPx: number | null = null;
+ let extractionSource: string | undefined;
+ let maxWidthHasClampCalc = false;
+ let paddingHasClampCalc = false;
+
+ // Strategy A: Extract from inline styles on the marked element
+ if (containerFileContent) {
+ INLINE_STYLE_REGEX.lastIndex = 0;
+ let styleMatch: RegExpExecArray | null;
+ while ((styleMatch = INLINE_STYLE_REGEX.exec(containerFileContent)) !== null) {
+ const styleContent =
+ styleMatch[1] ?? styleMatch[2] ?? styleMatch[3] ?? styleMatch[4] ?? "";
+
+ // Extract max-width
+ if (maxWidthPx === null) {
+ INLINE_MAX_WIDTH_REGEX.lastIndex = 0;
+ const maxWidthMatch = INLINE_MAX_WIDTH_REGEX.exec(styleContent);
+ if (maxWidthMatch) {
+ const maxWidthValue = maxWidthMatch[0];
+ // Check if this specific max-width value uses clamp/calc
+ if (CLAMP_REGEX.test(maxWidthValue) || CALC_REGEX.test(maxWidthValue)) {
+ maxWidthHasClampCalc = true;
+ } else {
+ const value = Number.parseFloat(maxWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ maxWidthPx = value;
+ extractionSource = containerSource;
+ }
+ }
+ }
+ }
+
+ // Extract padding
+ if (paddingLeftPx === null || paddingRightPx === null) {
+ INLINE_PADDING_INLINE_REGEX.lastIndex = 0;
+ const paddingInlineMatch = INLINE_PADDING_INLINE_REGEX.exec(styleContent);
+ if (paddingInlineMatch) {
+ const paddingValue = paddingInlineMatch[0];
+ // Check if this specific padding value uses clamp/calc
+ if (CLAMP_REGEX.test(paddingValue) || CALC_REGEX.test(paddingValue)) {
+ paddingHasClampCalc = true;
+ } else {
+ const value = Number.parseFloat(paddingInlineMatch[1]);
+ if (Number.isFinite(value)) {
+ paddingLeftPx = value;
+ paddingRightPx = value;
+ extractionSource = containerSource;
+ }
+ }
+ } else {
+ INLINE_PADDING_LEFT_REGEX.lastIndex = 0;
+ INLINE_PADDING_RIGHT_REGEX.lastIndex = 0;
+ const leftMatch = INLINE_PADDING_LEFT_REGEX.exec(styleContent);
+ const rightMatch = INLINE_PADDING_RIGHT_REGEX.exec(styleContent);
+ if (leftMatch && rightMatch) {
+ const leftValueStr = leftMatch[0];
+ const rightValueStr = rightMatch[0];
+ // Check if padding values use clamp/calc
+ if (
+ CLAMP_REGEX.test(leftValueStr) ||
+ CALC_REGEX.test(leftValueStr) ||
+ CLAMP_REGEX.test(rightValueStr) ||
+ CALC_REGEX.test(rightValueStr)
+ ) {
+ paddingHasClampCalc = true;
+ } else {
+ const leftValue = Number.parseFloat(leftMatch[1]);
+ const rightValue = Number.parseFloat(rightMatch[1]);
+ if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) {
+ paddingLeftPx = leftValue;
+ paddingRightPx = rightValue;
+ extractionSource = containerSource;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Strategy B: Extract from CSS rules targeting [data-contract="page-container"]
+ if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ for (const cssPath of cssFilePaths) {
+ const cssContent = await readFileCached(cssPath, fileContentCache);
+
+ CSS_SELECTOR_PAGE_CONTAINER_REGEX.lastIndex = 0;
+ let selectorMatch: RegExpExecArray | null;
+ while ((selectorMatch = CSS_SELECTOR_PAGE_CONTAINER_REGEX.exec(cssContent)) !== null) {
+ const ruleContent = selectorMatch[1];
+
+ // Extract max-width (check for clamp/calc in max-width declaration)
+ if (maxWidthPx === null) {
+ // First check if max-width exists (even with clamp/calc)
+ const maxWidthDeclMatch = /max-width\s*:\s*([^;]+)/i.exec(ruleContent);
+ if (maxWidthDeclMatch) {
+ const maxWidthValue = maxWidthDeclMatch[1].trim();
+ // Check if this specific max-width value uses clamp/calc
+ if (CLAMP_REGEX.test(maxWidthValue) || CALC_REGEX.test(maxWidthValue)) {
+ maxWidthHasClampCalc = true;
+ } else {
+ // Try to extract px value
+ CSS_MAX_WIDTH_REGEX.lastIndex = 0;
+ const maxWidthMatch = CSS_MAX_WIDTH_REGEX.exec(ruleContent);
+ if (maxWidthMatch) {
+ const value = Number.parseFloat(maxWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ maxWidthPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ }
+ }
+
+ // Extract padding (check for clamp/calc in padding declarations)
+ if (paddingLeftPx === null || paddingRightPx === null) {
+ // First check if padding-inline exists (even with clamp/calc)
+ const paddingInlineDeclMatch = /padding-inline\s*:\s*([^;]+)/i.exec(ruleContent);
+ if (paddingInlineDeclMatch) {
+ const paddingValue = paddingInlineDeclMatch[1].trim();
+ // Check if this specific padding value uses clamp/calc
+ if (CLAMP_REGEX.test(paddingValue) || CALC_REGEX.test(paddingValue)) {
+ paddingHasClampCalc = true;
+ } else {
+ // Try to extract px value
+ CSS_PADDING_INLINE_REGEX.lastIndex = 0;
+ const paddingInlineMatch = CSS_PADDING_INLINE_REGEX.exec(ruleContent);
+ if (paddingInlineMatch) {
+ const value = Number.parseFloat(paddingInlineMatch[1]);
+ if (Number.isFinite(value)) {
+ paddingLeftPx = value;
+ paddingRightPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ } else {
+ CSS_PADDING_LEFT_REGEX.lastIndex = 0;
+ CSS_PADDING_RIGHT_REGEX.lastIndex = 0;
+ const leftMatch = CSS_PADDING_LEFT_REGEX.exec(ruleContent);
+ const rightMatch = CSS_PADDING_RIGHT_REGEX.exec(ruleContent);
+ if (leftMatch && rightMatch) {
+ const leftValueStr = leftMatch[0];
+ const rightValueStr = rightMatch[0];
+ // Check if padding values use clamp/calc
+ if (
+ CLAMP_REGEX.test(leftValueStr) ||
+ CALC_REGEX.test(leftValueStr) ||
+ CLAMP_REGEX.test(rightValueStr) ||
+ CALC_REGEX.test(rightValueStr)
+ ) {
+ paddingHasClampCalc = true;
+ } else {
+ const leftValue = Number.parseFloat(leftMatch[1]);
+ const rightValue = Number.parseFloat(rightMatch[1]);
+ if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) {
+ paddingLeftPx = leftValue;
+ paddingRightPx = rightValue;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Strategy C: Extract from Tailwind bracket classes (best-effort, v1)
+ if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ for (const filePath of sectionFiles) {
+ const content = await readFileCached(filePath, fileContentCache);
+
+ // Extract max-width from max-w-[NNNpx]
+ if (maxWidthPx === null) {
+ TAILWIND_MAX_WIDTH_REGEX.lastIndex = 0;
+ const maxWidthMatch = TAILWIND_MAX_WIDTH_REGEX.exec(content);
+ if (maxWidthMatch) {
+ const value = Number.parseFloat(maxWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ maxWidthPx = value;
+ extractionSource = path.relative(workspaceRoot, filePath);
+ }
+ }
+ }
+
+ // Extract padding from px-[NNpx] or pl-[NNpx]/pr-[NNpx]
+ if (paddingLeftPx === null || paddingRightPx === null) {
+ TAILWIND_PADDING_X_REGEX.lastIndex = 0;
+ const paddingXMatch = TAILWIND_PADDING_X_REGEX.exec(content);
+ if (paddingXMatch) {
+ const value = Number.parseFloat(paddingXMatch[1]);
+ if (Number.isFinite(value)) {
+ paddingLeftPx = value;
+ paddingRightPx = value;
+ extractionSource = path.relative(workspaceRoot, filePath);
+ }
+ } else {
+ TAILWIND_PADDING_LEFT_REGEX.lastIndex = 0;
+ TAILWIND_PADDING_RIGHT_REGEX.lastIndex = 0;
+ const leftMatch = TAILWIND_PADDING_LEFT_REGEX.exec(content);
+ const rightMatch = TAILWIND_PADDING_RIGHT_REGEX.exec(content);
+ if (leftMatch && rightMatch) {
+ const leftValue = Number.parseFloat(leftMatch[1]);
+ const rightValue = Number.parseFloat(rightMatch[1]);
+ if (Number.isFinite(leftValue) && Number.isFinite(rightValue)) {
+ paddingLeftPx = leftValue;
+ paddingRightPx = rightValue;
+ extractionSource = path.relative(workspaceRoot, filePath);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Optional: CSS custom properties (fallback, not required)
+ if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ for (const cssPath of cssFilePaths) {
+ const cssContent = await readFileCached(cssPath, fileContentCache);
+
+ if (maxWidthPx === null) {
+ const varMatch = cssContent.match(PAGE_FRAME_MAX_WIDTH_VAR_REGEX);
+ if (varMatch) {
+ const value = Number.parseFloat(varMatch[1]);
+ if (Number.isFinite(value)) {
+ maxWidthPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+
+ if (paddingLeftPx === null || paddingRightPx === null) {
+ const paddingXMatch = cssContent.match(PAGE_FRAME_PADDING_VAR_REGEX);
+ if (paddingXMatch) {
+ const value = Number.parseFloat(paddingXMatch[1]);
+ if (Number.isFinite(value)) {
+ paddingLeftPx = value;
+ paddingRightPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+ }
+ }
+
+ return {
+ containerSelector,
+ maxWidthPx,
+ paddingLeftPx,
+ paddingRightPx,
+ source: extractionSource ?? containerSource,
+ maxWidthHasClampCalc: maxWidthHasClampCalc || undefined,
+ paddingHasClampCalc: paddingHasClampCalc || undefined,
};
}
diff --git a/packages/interfacectl-cli/src/utils/normalize.ts b/packages/interfacectl-cli/src/utils/normalize.ts
index 987f586..0c5b4fd 100644
--- a/packages/interfacectl-cli/src/utils/normalize.ts
+++ b/packages/interfacectl-cli/src/utils/normalize.ts
@@ -158,10 +158,12 @@ export function normalizeContract(
if (SET_LIKE_FIELDS.has("allowedColors")) {
const original = surface.allowedColors;
- const sorted = normalizeSetField(original);
- if (JSON.stringify(original) !== JSON.stringify(sorted)) {
- metadata.reorderedPaths.push(`surfaces[${surfaceIdx}].allowedColors`);
- normalizedSurface.allowedColors = sorted;
+ if (original) {
+ const sorted = normalizeSetField(original);
+ if (JSON.stringify(original) !== JSON.stringify(sorted)) {
+ metadata.reorderedPaths.push(`surfaces[${surfaceIdx}].allowedColors`);
+ normalizedSurface.allowedColors = sorted;
+ }
}
}
diff --git a/packages/interfacectl-cli/src/utils/violation-classifier.ts b/packages/interfacectl-cli/src/utils/violation-classifier.ts
index 52be6f0..90666ae 100644
--- a/packages/interfacectl-cli/src/utils/violation-classifier.ts
+++ b/packages/interfacectl-cli/src/utils/violation-classifier.ts
@@ -17,6 +17,8 @@ export function classifyViolationType(
"color-not-allowed",
"motion-duration-not-allowed",
"motion-timing-not-allowed",
+ "color-raw-value-used",
+ "color-token-namespace-violation",
];
if (e1Types.includes(type)) {
diff --git a/packages/interfacectl-cli/test/color-deprecation.test.mjs b/packages/interfacectl-cli/test/color-deprecation.test.mjs
new file mode 100644
index 0000000..c35a1f4
--- /dev/null
+++ b/packages/interfacectl-cli/test/color-deprecation.test.mjs
@@ -0,0 +1,188 @@
+import { test } from "node:test";
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import assert from "node:assert/strict";
+import os from "node:os";
+import {
+ mkdtemp,
+ mkdir,
+ writeFile,
+ rm,
+} from "node:fs/promises";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const cliPackageDir = path.resolve(__dirname, "..");
+const cliExecutable = path.resolve(cliPackageDir, "dist", "index.js");
+
+async function runCommand(command, args, options = {}) {
+ const proc = spawn(command, args, {
+ ...options,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ let stdout = "";
+ let stderr = "";
+
+ proc.stdout.on("data", (chunk) => {
+ stdout += chunk.toString();
+ });
+
+ proc.stderr.on("data", (chunk) => {
+ stderr += chunk.toString();
+ });
+
+ const exitCode = await once(proc, "exit").then(([code]) => code ?? 1);
+
+ return { exitCode, stdout, stderr };
+}
+
+test("validate emits deprecation warning for allowedColors", async () => {
+ const tempRoot = await mkdtemp(
+ path.join(os.tmpdir(), "interfacectl-deprecation-"),
+ );
+
+ try {
+ const contractPath = path.join(tempRoot, "contract.json");
+ const contract = {
+ contractId: "test",
+ version: "1.0.0",
+ surfaces: [
+ {
+ id: "test-surface",
+ displayName: "Test Surface",
+ type: "web",
+ requiredSections: ["header"],
+ allowedFonts: ["Inter"],
+ allowedColors: ["#000000", "#ffffff"],
+ layout: {
+ maxContentWidth: 1200,
+ },
+ },
+ ],
+ sections: [
+ {
+ id: "header",
+ intent: "Page header",
+ description: "Main header",
+ },
+ ],
+ constraints: {
+ motion: {
+ allowedDurationsMs: [200],
+ allowedTimingFunctions: ["ease"],
+ },
+ },
+ };
+
+ await writeFile(contractPath, JSON.stringify(contract, null, 2), "utf-8");
+
+ // Create minimal surface directory structure with source files
+ const surfaceDir = path.join(tempRoot, "apps", "test-surface");
+ await mkdir(surfaceDir, { recursive: true });
+ await writeFile(path.join(surfaceDir, "package.json"), JSON.stringify({ name: "test-surface" }), "utf-8");
+ // Create minimal app structure for Next.js surface
+ const appDir = path.join(surfaceDir, "app");
+ await mkdir(appDir, { recursive: true });
+ await writeFile(path.join(appDir, "layout.tsx"), `export default function Layout({ children }) { return {children}
; }`, "utf-8");
+ await writeFile(path.join(appDir, "page.tsx"), `export default function Page() { return Test
; }`, "utf-8");
+
+ const result = await runCommand(
+ "node",
+ [cliExecutable, "validate", "--contract", contractPath, "--workspace-root", tempRoot, "--format", "json", "--exit-codes", "v2"],
+ { cwd: tempRoot },
+ );
+
+ // Should pass schema validation (allowedColors is accepted but deprecated)
+ assert.equal(result.exitCode, 0, `Command failed: ${result.stderr}\n${result.stdout}`);
+
+ const output = JSON.parse(result.stdout);
+ assert.ok(output.findings);
+
+ // Should have deprecation warning
+ const deprecationFinding = output.findings.find(
+ (f) => f.code === "contract.deprecated-field",
+ );
+ assert.ok(deprecationFinding, "Should emit contract.deprecated-field finding");
+ assert.equal(deprecationFinding.severity, "warning");
+ assert(deprecationFinding.message.includes("allowedColors"));
+ assert(deprecationFinding.message.includes("deprecated"));
+ assert(deprecationFinding.location, "Should include jsonPointer location");
+ } finally {
+ await rm(tempRoot, { recursive: true, force: true });
+ }
+});
+
+test("validate accepts contract with color policy", async () => {
+ const tempRoot = await mkdtemp(
+ path.join(os.tmpdir(), "interfacectl-color-policy-"),
+ );
+
+ try {
+ const contractPath = path.join(tempRoot, "contract.json");
+ const contract = {
+ contractId: "test",
+ version: "1.0.0",
+ surfaces: [
+ {
+ id: "test-surface",
+ displayName: "Test Surface",
+ type: "web",
+ requiredSections: ["header"],
+ allowedFonts: ["Inter"],
+ layout: {
+ maxContentWidth: 1200,
+ },
+ },
+ ],
+ sections: [
+ {
+ id: "header",
+ intent: "Page header",
+ description: "Main header",
+ },
+ ],
+ constraints: {
+ motion: {
+ allowedDurationsMs: [200],
+ allowedTimingFunctions: ["ease"],
+ },
+ },
+ color: {
+ sourceOfTruth: {
+ type: "tokens",
+ tokenNamespaces: ["--color-"],
+ },
+ rawValues: {
+ policy: "warn",
+ },
+ },
+ };
+
+ await writeFile(contractPath, JSON.stringify(contract, null, 2), "utf-8");
+
+ // Create minimal surface directory structure with source files
+ const surfaceDir = path.join(tempRoot, "apps", "test-surface");
+ await mkdir(surfaceDir, { recursive: true });
+ await writeFile(path.join(surfaceDir, "package.json"), JSON.stringify({ name: "test-surface" }), "utf-8");
+ // Create minimal app structure for Next.js surface
+ const appDir = path.join(surfaceDir, "app");
+ await mkdir(appDir, { recursive: true });
+ await writeFile(path.join(appDir, "layout.tsx"), `export default function Layout({ children }) { return {children}
; }`, "utf-8");
+ await writeFile(path.join(appDir, "page.tsx"), `export default function Page() { return Test
; }`, "utf-8");
+
+ const result = await runCommand(
+ "node",
+ [cliExecutable, "validate", "--contract", contractPath, "--workspace-root", tempRoot, "--format", "json", "--exit-codes", "v2"],
+ { cwd: tempRoot },
+ );
+
+ // Should pass schema validation
+ assert.equal(result.exitCode, 0, `Command failed: ${result.stderr}\n${result.stdout}`);
+ } finally {
+ await rm(tempRoot, { recursive: true, force: true });
+ }
+});
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/globals.css b/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/globals.css
new file mode 100644
index 0000000..1aff918
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/globals.css
@@ -0,0 +1,4 @@
+[data-contract="page-container"] {
+ max-width: 1200px;
+ padding-inline: clamp(16px, 2vw, 24px);
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/layout.tsx
new file mode 100644
index 0000000..d8ea5ba
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/clamp-padding/app/layout.tsx
@@ -0,0 +1,8 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.json b/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.json
new file mode 100644
index 0000000..4efbeb8
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.json
@@ -0,0 +1,37 @@
+{
+ "contractId": "test-pageframe-static",
+ "version": "1.0.0",
+ "surfaces": [
+ {
+ "id": "test-surface",
+ "displayName": "Test Surface",
+ "type": "web",
+ "requiredSections": ["header"],
+ "allowedFonts": ["Inter"],
+ "allowedColors": ["#000000"],
+ "layout": {
+ "maxContentWidth": 1200,
+ "pageFrame": {
+ "containerSelector": "[data-contract=\"page-container\"]",
+ "containerMaxWidthPx": 1200,
+ "paddingXpx": 24,
+ "alignment": "center",
+ "enforcement": "strict"
+ }
+ }
+ }
+ ],
+ "sections": [
+ {
+ "id": "header",
+ "intent": "Page header",
+ "description": "Main page header section"
+ }
+ ],
+ "constraints": {
+ "motion": {
+ "allowedDurationsMs": [200, 300],
+ "allowedTimingFunctions": ["ease", "ease-in-out"]
+ }
+ }
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/globals.css b/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/globals.css
new file mode 100644
index 0000000..68de65b
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/globals.css
@@ -0,0 +1,5 @@
+[data-contract="page-container"] {
+ max-width: 1200px;
+ padding-left: 24px;
+ padding-right: 24px;
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/layout.tsx
new file mode 100644
index 0000000..62612a3
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/css-rule/app/layout.tsx
@@ -0,0 +1,7 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/inline-styles/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/inline-styles/app/layout.tsx
new file mode 100644
index 0000000..86e4931
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/inline-styles/app/layout.tsx
@@ -0,0 +1,14 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/maxwidth-fail/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/maxwidth-fail/app/layout.tsx
new file mode 100644
index 0000000..099a154
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/maxwidth-fail/app/layout.tsx
@@ -0,0 +1,14 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/missing-marker/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/missing-marker/app/layout.tsx
new file mode 100644
index 0000000..8e125ef
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/missing-marker/app/layout.tsx
@@ -0,0 +1,7 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/tailwind/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/tailwind/app/layout.tsx
new file mode 100644
index 0000000..c8b53cd
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/tailwind/app/layout.tsx
@@ -0,0 +1,10 @@
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe/contract.json b/packages/interfacectl-cli/test/fixtures/pageframe/contract.json
new file mode 100644
index 0000000..cb5d44d
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe/contract.json
@@ -0,0 +1,37 @@
+{
+ "contractId": "test-pageframe",
+ "version": "1.0.0",
+ "surfaces": [
+ {
+ "id": "test-surface",
+ "displayName": "Test Surface",
+ "type": "web",
+ "requiredSections": ["header"],
+ "allowedFonts": ["Inter"],
+ "allowedColors": ["#000000"],
+ "layout": {
+ "maxContentWidth": 1200,
+ "pageFrame": {
+ "containerSelector": ".container",
+ "containerMaxWidthPx": 1200,
+ "paddingXpx": 24,
+ "alignment": "center",
+ "enforcement": "strict"
+ }
+ }
+ }
+ ],
+ "sections": [
+ {
+ "id": "header",
+ "intent": "Page header",
+ "description": "Main page header section"
+ }
+ ],
+ "constraints": {
+ "motion": {
+ "allowedDurationsMs": [200, 300],
+ "allowedTimingFunctions": ["ease", "ease-in-out"]
+ }
+ }
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe/failing.html b/packages/interfacectl-cli/test/fixtures/pageframe/failing.html
new file mode 100644
index 0000000..4adff72
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe/failing.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Failing Page Frame Test
+
+
+
+
+
Test Page
+
This page should fail pageFrame validation.
+
+
+
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe/passing.html b/packages/interfacectl-cli/test/fixtures/pageframe/passing.html
new file mode 100644
index 0000000..0798a85
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe/passing.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ Passing Page Frame Test
+
+
+
+
+
Test Page
+
This page should pass pageFrame validation with:
+
+ - containerSelector: ".container"
+ - containerMaxWidthPx: 1200
+ - paddingXpx: 24
+ - alignment: "center"
+
+
+
+
diff --git a/packages/interfacectl-cli/test/pageframe-static.test.mjs b/packages/interfacectl-cli/test/pageframe-static.test.mjs
new file mode 100644
index 0000000..b1e239a
--- /dev/null
+++ b/packages/interfacectl-cli/test/pageframe-static.test.mjs
@@ -0,0 +1,136 @@
+import { test } from "node:test";
+import assert from "node:assert";
+import { spawn } from "node:child_process";
+import { promisify } from "node:util";
+import path from "node:path";
+import url from "node:url";
+import { readFile } from "node:fs/promises";
+
+const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
+const fixturesDir = path.join(__dirname, "fixtures", "pageframe-static");
+const contractPath = path.join(fixturesDir, "contract.json");
+
+async function runValidate(fixtureName) {
+ const fixtureRoot = path.join(fixturesDir, fixtureName);
+ return new Promise((resolve, reject) => {
+ const proc = spawn(
+ "node",
+ [
+ path.join(__dirname, "..", "dist", "index.js"),
+ "validate",
+ "--contract",
+ contractPath,
+ "--root",
+ fixtureRoot,
+ "--format",
+ "json",
+ ],
+ {
+ cwd: path.join(__dirname, ".."),
+ },
+ );
+
+ let stdout = "";
+ let stderr = "";
+
+ proc.stdout.on("data", (data) => {
+ stdout += data.toString();
+ });
+
+ proc.stderr.on("data", (data) => {
+ stderr += data.toString();
+ });
+
+ proc.on("close", (code) => {
+ resolve({ code, stdout, stderr });
+ });
+
+ proc.on("error", (error) => {
+ reject(error);
+ });
+ });
+}
+
+test("pageFrame validation - inline styles (passing)", async () => {
+ const result = await runValidate("inline-styles");
+ const output = JSON.parse(result.stdout);
+
+ // Should pass - no pageFrame violations
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+ assert.strictEqual(pageFrameViolations.length, 0, "Should have no pageFrame violations");
+ assert.strictEqual(result.code, 0, "Should exit with code 0");
+});
+
+test("pageFrame validation - CSS rule (passing)", async () => {
+ const result = await runValidate("css-rule");
+ const output = JSON.parse(result.stdout);
+
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+ assert.strictEqual(pageFrameViolations.length, 0, "Should have no pageFrame violations");
+ assert.strictEqual(result.code, 0, "Should exit with code 0");
+});
+
+test("pageFrame validation - Tailwind classes (passing)", async () => {
+ const result = await runValidate("tailwind");
+ const output = JSON.parse(result.stdout);
+
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+ assert.strictEqual(pageFrameViolations.length, 0, "Should have no pageFrame violations");
+ assert.strictEqual(result.code, 0, "Should exit with code 0");
+});
+
+test("pageFrame validation - clamp() padding (non-deterministic)", async () => {
+ const result = await runValidate("clamp-padding");
+ const output = JSON.parse(result.stdout);
+
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+
+ // Should have non-deterministic-value violation for padding (not unextractable-value)
+ const paddingViolation = pageFrameViolations.find(
+ (v) => v.code === "layout.pageframe.non-deterministic-value",
+ );
+ assert.ok(paddingViolation, "Should have non-deterministic-value violation for padding");
+ // Check that the violation details include the property
+ assert.ok(paddingViolation.expected !== undefined, "Violation should have expected value");
+ assert.strictEqual(result.code, 1, "Should exit with code 1 (strict mode)");
+});
+
+test("pageFrame validation - missing marker (container not found)", async () => {
+ const result = await runValidate("missing-marker");
+ const output = JSON.parse(result.stdout);
+
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+
+ const containerViolation = pageFrameViolations.find(
+ (v) => v.code === "layout.pageframe.container-not-found",
+ );
+ assert.ok(containerViolation, "Should have container-not-found violation");
+ assert.strictEqual(result.code, 1, "Should exit with code 1");
+});
+
+test("pageFrame validation - max-width mismatch (failing)", async () => {
+ const result = await runValidate("maxwidth-fail");
+ const output = JSON.parse(result.stdout);
+
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+
+ const maxWidthViolation = pageFrameViolations.find(
+ (v) => v.code === "layout.pageframe.maxwidth-mismatch",
+ );
+ assert.ok(maxWidthViolation, "Should have maxwidth-mismatch violation");
+ assert.strictEqual(maxWidthViolation.expected, 1200);
+ assert.strictEqual(maxWidthViolation.found, 1400);
+ assert.strictEqual(result.code, 1, "Should exit with code 1");
+});
diff --git a/packages/interfacectl-cli/test/pageframe-validator.test.mjs b/packages/interfacectl-cli/test/pageframe-validator.test.mjs
new file mode 100644
index 0000000..0a14989
--- /dev/null
+++ b/packages/interfacectl-cli/test/pageframe-validator.test.mjs
@@ -0,0 +1,72 @@
+import { test } from "node:test";
+import assert from "node:assert";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import url from "node:url";
+import { spawn } from "node:child_process";
+import { promisify } from "node:util";
+
+const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
+const fixturesDir = path.join(__dirname, "fixtures", "pageframe");
+
+// Helper to parse px values (replicate logic from validator)
+function parsePxValue(cssValue) {
+ if (cssValue === "none" || cssValue === "auto") {
+ return null;
+ }
+ const match = cssValue.match(/^([\d.]+)px$/);
+ if (match) {
+ return parseFloat(match[1]);
+ }
+ return null;
+}
+
+test("parsePxValue - valid pixel values", () => {
+ assert.strictEqual(parsePxValue("1200px"), 1200);
+ assert.strictEqual(parsePxValue("0px"), 0);
+ assert.strictEqual(parsePxValue("24.5px"), 24.5);
+ assert.strictEqual(parsePxValue("1000px"), 1000);
+});
+
+test("parsePxValue - invalid values", () => {
+ assert.strictEqual(parsePxValue("auto"), null);
+ assert.strictEqual(parsePxValue("none"), null);
+ assert.strictEqual(parsePxValue("100%"), null);
+ assert.strictEqual(parsePxValue("100"), null);
+ assert.strictEqual(parsePxValue(""), null);
+});
+
+test("parsePxValue - style comparison tolerance", () => {
+ const expected = 1200;
+ const actual1 = 1200;
+ const actual2 = 1201;
+ const actual3 = 1199;
+ const tolerance = 0;
+
+ assert.strictEqual(Math.abs(actual1 - expected) <= tolerance, true);
+ assert.strictEqual(Math.abs(actual2 - expected) <= tolerance, false);
+ assert.strictEqual(Math.abs(actual3 - expected) <= tolerance, false);
+
+ const tolerance2 = 2;
+ assert.strictEqual(Math.abs(actual2 - expected) <= tolerance2, true);
+ assert.strictEqual(Math.abs(actual3 - expected) <= tolerance2, true);
+});
+
+// Integration test with HTML fixtures
+test("pageFrame validation - integration with HTML fixture", async () => {
+ // Create a simple HTTP server fixture
+ const fixturePath = path.join(fixturesDir, "passing.html");
+
+ // Ensure fixtures directory exists
+ try {
+ await readFile(fixturePath);
+ } catch (error) {
+ // Skip test if fixture doesn't exist yet
+ console.log("Skipping integration test - fixtures not set up");
+ return;
+ }
+
+ // This test would require a running server, so we'll mark it as a placeholder
+ // for now. In a real scenario, we'd start a local server and test against it.
+ assert.ok(true, "Integration test placeholder");
+});
diff --git a/packages/interfacectl-validator/dist/index.d.ts b/packages/interfacectl-validator/dist/index.d.ts
index c1534c2..8049b3e 100644
--- a/packages/interfacectl-validator/dist/index.d.ts
+++ b/packages/interfacectl-validator/dist/index.d.ts
@@ -8,6 +8,6 @@ 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, SurfaceColorDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, DiffOutput, DiffEntry, DiffChangeType, DriftRisk, Severity, SafetyLevel, EnforcementPolicy, EnforcementMode, AutofixRule, FixSummary, FixEntry, FixError, } from "./types.js";
+export type { InterfaceContract, ContractSurface, ContractSection, ContractConstraints, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, PageFrameLayoutDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, DiffOutput, DiffEntry, DiffChangeType, DriftRisk, Severity, SafetyLevel, EnforcementPolicy, EnforcementMode, AutofixRule, FixSummary, FixEntry, FixError, } from "./types.js";
export { getBundledDiffSchema, getBundledPolicySchema, getBundledFixSummarySchema, validateDiffOutput, validatePolicy, validateFixSummary, type ValidationResult, } from "./schema-validate.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 120703b..b1790f1 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,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,EAClB,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,0BAA0B,EAC1B,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,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,EAKlB,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;AA4ID,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAqUf;AAED,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,iBAAiB,EAC3B,WAAW,EAAE,iBAAiB,EAAE,GAC/B,iBAAiB,CA6CnB;AAyCD,YAAY,EACV,iBAAiB,EACjB,eAAe,EACf,eAAe,EACf,mBAAmB,EACnB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,UAAU,EACV,QAAQ,EACR,QAAQ,GACT,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,0BAA0B,EAC1B,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,gBAAgB,GACtB,MAAM,sBAAsB,CAAC"}
\ No newline at end of file
diff --git a/packages/interfacectl-validator/dist/index.js b/packages/interfacectl-validator/dist/index.js
index e1c0eee..1494f2b 100644
--- a/packages/interfacectl-validator/dist/index.js
+++ b/packages/interfacectl-validator/dist/index.js
@@ -27,6 +27,116 @@ export function validateContractStructure(contractData, schema) {
contract: contractData,
};
}
+function validateColorPolicy(contract, descriptor, violations) {
+ const colorPolicy = contract.color;
+ if (!colorPolicy) {
+ return;
+ }
+ // Validate raw color literals
+ if (colorPolicy.rawValues) {
+ const policy = colorPolicy.rawValues.policy;
+ const allowlist = new Set(colorPolicy.rawValues.allowlist ?? []);
+ const denylist = new Set(colorPolicy.rawValues.denylist ?? []);
+ for (const color of descriptor.colors) {
+ const colorValue = color.value;
+ // Skip CSS variables
+ if (colorValue.startsWith("var(")) {
+ continue;
+ }
+ // Check if it's a raw color literal (hex, rgb, hsl)
+ const isRawLiteral = isRawColorLiteral(colorValue);
+ if (!isRawLiteral) {
+ continue;
+ }
+ // Check denylist first (denylist takes precedence, even when policy is "off")
+ if (denylist.has(colorValue)) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "color-raw-value-used",
+ message: `Raw color literal "${colorValue}" is not allowed (denylist). Use design tokens instead.`,
+ details: {
+ colorValue,
+ source: color.source,
+ policy: policy ?? "off",
+ jsonPointer: "/color/rawValues",
+ },
+ });
+ continue;
+ }
+ // Only check allowlist and policy violations when policy is not "off"
+ if (policy !== "off") {
+ // Check allowlist
+ if (allowlist.has(colorValue)) {
+ continue; // Allowlisted, skip
+ }
+ // Emit violation based on policy
+ if (policy === "warn" || policy === "strict") {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "color-raw-value-used",
+ message: `Raw color literal "${colorValue}" detected. Use design tokens instead.`,
+ details: {
+ colorValue,
+ source: color.source,
+ policy,
+ jsonPointer: "/color/rawValues",
+ },
+ });
+ }
+ }
+ }
+ }
+ // Validate token namespaces
+ if (colorPolicy.sourceOfTruth &&
+ colorPolicy.sourceOfTruth.type === "tokens" &&
+ colorPolicy.sourceOfTruth.tokenNamespaces) {
+ const allowedNamespaces = colorPolicy.sourceOfTruth.tokenNamespaces;
+ for (const color of descriptor.colors) {
+ const colorValue = color.value;
+ // Only check CSS variables
+ if (!colorValue.startsWith("var(")) {
+ continue;
+ }
+ // Extract variable name from var(--name)
+ const varMatch = colorValue.match(/var\((--[^)]+)\)/);
+ if (!varMatch) {
+ continue;
+ }
+ const varName = varMatch[1];
+ // Check if variable name starts with any allowed namespace
+ const hasAllowedNamespace = allowedNamespaces.some((namespace) => varName.startsWith(namespace));
+ if (!hasAllowedNamespace) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "color-token-namespace-violation",
+ message: `Color token "${varName}" does not start with any allowed namespace. Allowed namespaces: ${allowedNamespaces.join(", ")}.`,
+ details: {
+ tokenName: varName,
+ allowedNamespaces,
+ source: color.source,
+ jsonPointer: "/color/sourceOfTruth/tokenNamespaces",
+ },
+ });
+ }
+ }
+ }
+}
+function isRawColorLiteral(value) {
+ const trimmed = value.trim().toLowerCase();
+ // Hex colors (#rgb, #rrggbb, #rrggbbaa)
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(trimmed)) {
+ return true;
+ }
+ // rgb/rgba colors
+ if (/^rgba?\s*\(/.test(trimmed)) {
+ return true;
+ }
+ // hsl/hsla colors
+ if (/^hsla?\s*\(/.test(trimmed)) {
+ return true;
+ }
+ return false;
+}
export function evaluateSurfaceCompliance(contract, descriptor) {
const surface = findSurface(contract.surfaces, descriptor.surfaceId);
const violations = [];
@@ -81,21 +191,28 @@ 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],
- },
- });
+ // Legacy allowedColors check (deprecated)
+ if (surface.allowedColors) {
+ 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],
+ },
+ });
+ }
}
}
+ // New color policy validation
+ if (contract.color) {
+ validateColorPolicy(contract, descriptor, violations);
+ }
const reportedWidth = descriptor.layout.maxContentWidth;
if (reportedWidth === null || reportedWidth === undefined) {
violations.push({
@@ -170,6 +287,148 @@ export function evaluateSurfaceCompliance(contract, descriptor) {
});
}
}
+ // Validate pageFrame layout if contract defines it
+ if (surface.layout.pageFrame && descriptor.layout.pageFrame) {
+ const pageFrameContract = surface.layout.pageFrame;
+ const pageFrameDescriptor = descriptor.layout.pageFrame;
+ const enforcement = pageFrameContract.enforcement ?? "strict";
+ // Check if selector is supported
+ const containerSelector = pageFrameContract.containerSelector;
+ const isSupportedSelector = containerSelector === '[data-contract="page-container"]' ||
+ containerSelector === "[data-contract='page-container']" ||
+ containerSelector === '[data-contract={page-container}]';
+ if (!isSupportedSelector) {
+ const violation = {
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-selector-unsupported",
+ message: `Page frame container selector "${containerSelector}" is not supported in static analysis. Use '[data-contract="page-container"]' instead.`,
+ details: {
+ selector: containerSelector,
+ supportedSelectors: ['[data-contract="page-container"]'],
+ },
+ };
+ violations.push(violation);
+ }
+ else {
+ // Validate container exists
+ if (pageFrameDescriptor.maxWidthPx === null &&
+ pageFrameDescriptor.paddingLeftPx === null &&
+ pageFrameDescriptor.paddingRightPx === null) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-container-not-found",
+ message: `Page container with data-contract="page-container" not found for surface "${descriptor.surfaceId}".`,
+ details: {
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ else {
+ // Validate max-width
+ const expectedMaxWidth = pageFrameContract.containerMaxWidthPx;
+ const actualMaxWidth = pageFrameDescriptor.maxWidthPx;
+ if (actualMaxWidth === null) {
+ // Check if clamp/calc was detected
+ if (pageFrameDescriptor.maxWidthHasClampCalc) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-non-deterministic-value",
+ message: `Page frame max-width uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedMaxWidth}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`,
+ details: {
+ property: "max-width",
+ expected: expectedMaxWidth,
+ actual: null,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ else {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-unextractable-value",
+ message: `Page frame max-width could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedMaxWidth}px. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (max-w-[${expectedMaxWidth}px]).`,
+ details: {
+ property: "max-width",
+ expected: expectedMaxWidth,
+ actual: null,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ }
+ else if (actualMaxWidth !== expectedMaxWidth) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-maxwidth-mismatch",
+ message: `Page frame max-width mismatch for surface "${descriptor.surfaceId}": expected ${expectedMaxWidth}px, found ${actualMaxWidth}px.`,
+ details: {
+ expected: expectedMaxWidth,
+ actual: actualMaxWidth,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ // Validate padding
+ const expectedPadding = pageFrameContract.paddingXpx;
+ const actualPaddingLeft = pageFrameDescriptor.paddingLeftPx;
+ const actualPaddingRight = pageFrameDescriptor.paddingRightPx;
+ if (actualPaddingLeft === null || actualPaddingRight === null) {
+ // Check if clamp/calc was detected
+ if (pageFrameDescriptor.paddingHasClampCalc) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-non-deterministic-value",
+ message: `Page frame padding uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedPadding}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`,
+ details: {
+ property: "padding",
+ expected: expectedPadding,
+ actualLeft: actualPaddingLeft,
+ actualRight: actualPaddingRight,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ else {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-unextractable-value",
+ message: `Page frame padding could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedPadding}px. Static analysis requires deterministic px values. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (px-[${expectedPadding}px]).`,
+ details: {
+ property: "padding",
+ expected: expectedPadding,
+ actualLeft: actualPaddingLeft,
+ actualRight: actualPaddingRight,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ }
+ else if (actualPaddingLeft !== expectedPadding ||
+ actualPaddingRight !== expectedPadding) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-padding-mismatch",
+ message: `Page frame padding mismatch for surface "${descriptor.surfaceId}": expected ${expectedPadding}px on both sides, found left=${actualPaddingLeft}px right=${actualPaddingRight}px.`,
+ details: {
+ expected: expectedPadding,
+ actualLeft: actualPaddingLeft,
+ actualRight: actualPaddingRight,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ }
+ }
+ // Apply enforcement mode: warn mode violations don't affect exit code
+ // This is handled at the CLI level by checking violation severity
+ }
return {
surfaceId: descriptor.surfaceId,
violations,
@@ -221,10 +480,20 @@ function formatAjvErrors(errors) {
return errors.map((error) => {
const dataPath = error.instancePath || error.schemaPath;
const baseMessage = error.message ?? "Validation error";
+ // Enhance error messages for common schema issues
+ let enhancedMessage = baseMessage;
+ if (error.keyword === "additionalProperties" && error.params?.additionalProperty) {
+ const prop = error.params.additionalProperty;
+ enhancedMessage = `Additional property "${prop}" is not allowed. This may indicate a capability gap - the field is not supported by the current schema version.`;
+ }
+ else if (error.keyword === "required" && error.params?.missingProperty) {
+ const prop = error.params.missingProperty;
+ enhancedMessage = `Required property "${prop}" is missing.`;
+ }
if (error.params && Object.keys(error.params).length > 0) {
- return `${dataPath}: ${baseMessage} (${JSON.stringify(error.params)})`;
+ return `${dataPath}: ${enhancedMessage} (${JSON.stringify(error.params)})`;
}
- return `${dataPath}: ${baseMessage}`;
+ return `${dataPath}: ${enhancedMessage}`;
});
}
function buildSectionIndex(sections) {
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 7a0a1f6..243f641 100644
--- a/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json
+++ b/packages/interfacectl-validator/dist/schema/surfaces.web.contract.schema.json
@@ -119,6 +119,9 @@
"minLength": 1
},
"uniqueItems": true
+ },
+ "pageFrame": {
+ "$ref": "#/$defs/pageFrame"
}
}
}
@@ -183,6 +186,39 @@
}
}
}
+ },
+ "pageFrame": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "containerSelector",
+ "containerMaxWidthPx",
+ "paddingXpx"
+ ],
+ "properties": {
+ "containerSelector": {
+ "type": "string",
+ "minLength": 1
+ },
+ "containerMaxWidthPx": {
+ "type": "number",
+ "minimum": 0
+ },
+ "paddingXpx": {
+ "type": "number",
+ "minimum": 0
+ },
+ "alignment": {
+ "type": "string",
+ "enum": ["center", "left"],
+ "default": "center"
+ },
+ "enforcement": {
+ "type": "string",
+ "enum": ["strict", "warn"],
+ "default": "strict"
+ }
+ }
}
}
}
diff --git a/packages/interfacectl-validator/dist/types.d.ts b/packages/interfacectl-validator/dist/types.d.ts
index 2b9cc41..a7302d1 100644
--- a/packages/interfacectl-validator/dist/types.d.ts
+++ b/packages/interfacectl-validator/dist/types.d.ts
@@ -1,4 +1,11 @@
export type SurfaceType = "web" | "cli";
+export interface PageFrameLayout {
+ containerSelector: string;
+ containerMaxWidthPx: number;
+ paddingXpx: number;
+ alignment?: "center" | "left";
+ enforcement?: "strict" | "warn";
+}
export interface ContractSurface {
id: string;
displayName: string;
@@ -9,6 +16,7 @@ export interface ContractSurface {
layout: {
maxContentWidth: number;
requiredContainers?: string[];
+ pageFrame?: PageFrameLayout;
};
}
export interface ContractSection {
@@ -47,11 +55,21 @@ export interface SurfaceMotionDescriptor {
timingFunction: string;
source?: string;
}
+export interface PageFrameLayoutDescriptor {
+ containerSelector: string;
+ maxWidthPx?: number | null;
+ paddingLeftPx?: number | null;
+ paddingRightPx?: number | null;
+ source?: string;
+ maxWidthHasClampCalc?: boolean;
+ paddingHasClampCalc?: boolean;
+}
export interface SurfaceLayoutDescriptor {
maxContentWidth?: number | null;
containers?: string[];
containerSources?: string[];
source?: string;
+ pageFrame?: PageFrameLayoutDescriptor;
}
export interface SurfaceDescriptor {
surfaceId: string;
@@ -61,7 +79,7 @@ export interface SurfaceDescriptor {
layout: SurfaceLayoutDescriptor;
motion: SurfaceMotionDescriptor[];
}
-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 type DriftViolationType = "unknown-surface" | "missing-section" | "unknown-section" | "font-not-allowed" | "color-not-allowed" | "layout-width-exceeded" | "layout-width-undetermined" | "layout-container-missing" | "layout-pageframe-selector-unsupported" | "layout-pageframe-container-not-found" | "layout-pageframe-maxwidth-mismatch" | "layout-pageframe-padding-mismatch" | "layout-pageframe-non-deterministic-value" | "layout-pageframe-unextractable-value" | "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 d5c9ce3..e56b3c8 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":"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"}
\ No newline at end of file
+{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
\ No newline at end of file
diff --git a/packages/interfacectl-validator/src/index.ts b/packages/interfacectl-validator/src/index.ts
index 4b8bb20..0238eee 100644
--- a/packages/interfacectl-validator/src/index.ts
+++ b/packages/interfacectl-validator/src/index.ts
@@ -8,6 +8,7 @@ import {
DriftViolation,
ContractSection,
ContractSurface,
+ PageFrameLayoutDescriptor,
} from "./types.js";
import bundledSchema from "./schema/surfaces.web.contract.schema.json" with {
type: "json",
@@ -55,6 +56,144 @@ export function validateContractStructure(
};
}
+function validateColorPolicy(
+ contract: InterfaceContract,
+ descriptor: SurfaceDescriptor,
+ violations: DriftViolation[],
+): void {
+ const colorPolicy = contract.color;
+ if (!colorPolicy) {
+ return;
+ }
+
+ // Validate raw color literals
+ if (colorPolicy.rawValues) {
+ const policy = colorPolicy.rawValues.policy;
+ const allowlist = new Set(colorPolicy.rawValues.allowlist ?? []);
+ const denylist = new Set(colorPolicy.rawValues.denylist ?? []);
+
+ for (const color of descriptor.colors) {
+ const colorValue = color.value;
+
+ // Skip CSS variables
+ if (colorValue.startsWith("var(")) {
+ continue;
+ }
+
+ // Check if it's a raw color literal (hex, rgb, hsl)
+ const isRawLiteral = isRawColorLiteral(colorValue);
+ if (!isRawLiteral) {
+ continue;
+ }
+
+ // Check denylist first (denylist takes precedence, even when policy is "off")
+ if (denylist.has(colorValue)) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "color-raw-value-used",
+ message: `Raw color literal "${colorValue}" is not allowed (denylist). Use design tokens instead.`,
+ details: {
+ colorValue,
+ source: color.source,
+ policy: policy ?? "off",
+ jsonPointer: "/color/rawValues",
+ },
+ });
+ continue;
+ }
+
+ // Only check allowlist and policy violations when policy is not "off"
+ if (policy !== "off") {
+ // Check allowlist
+ if (allowlist.has(colorValue)) {
+ continue; // Allowlisted, skip
+ }
+
+ // Emit violation based on policy
+ if (policy === "warn" || policy === "strict") {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "color-raw-value-used",
+ message: `Raw color literal "${colorValue}" detected. Use design tokens instead.`,
+ details: {
+ colorValue,
+ source: color.source,
+ policy,
+ jsonPointer: "/color/rawValues",
+ },
+ });
+ }
+ }
+ }
+ }
+
+ // Validate token namespaces
+ if (
+ colorPolicy.sourceOfTruth &&
+ colorPolicy.sourceOfTruth.type === "tokens" &&
+ colorPolicy.sourceOfTruth.tokenNamespaces
+ ) {
+ const allowedNamespaces = colorPolicy.sourceOfTruth.tokenNamespaces;
+
+ for (const color of descriptor.colors) {
+ const colorValue = color.value;
+
+ // Only check CSS variables
+ if (!colorValue.startsWith("var(")) {
+ continue;
+ }
+
+ // Extract variable name from var(--name)
+ const varMatch = colorValue.match(/var\((--[^)]+)\)/);
+ if (!varMatch) {
+ continue;
+ }
+
+ const varName = varMatch[1];
+
+ // Check if variable name starts with any allowed namespace
+ const hasAllowedNamespace = allowedNamespaces.some((namespace) =>
+ varName.startsWith(namespace),
+ );
+
+ if (!hasAllowedNamespace) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "color-token-namespace-violation",
+ message: `Color token "${varName}" does not start with any allowed namespace. Allowed namespaces: ${allowedNamespaces.join(", ")}.`,
+ details: {
+ tokenName: varName,
+ allowedNamespaces,
+ source: color.source,
+ jsonPointer: "/color/sourceOfTruth/tokenNamespaces",
+ },
+ });
+ }
+ }
+ }
+}
+
+function isRawColorLiteral(value: string): boolean {
+ const trimmed = value.trim().toLowerCase();
+
+ // Hex colors (#rgb, #rrggbb, #rrggbbaa)
+ if (/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(trimmed)) {
+ return true;
+ }
+
+ // rgb/rgba colors
+ if (/^rgba?\s*\(/.test(trimmed)) {
+ return true;
+ }
+
+ // hsl/hsla colors
+ if (/^hsla?\s*\(/.test(trimmed)) {
+ return true;
+ }
+
+ return false;
+}
+
export function evaluateSurfaceCompliance(
contract: InterfaceContract,
descriptor: SurfaceDescriptor,
@@ -120,22 +259,30 @@ 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],
- },
- });
+ // Legacy allowedColors check (deprecated)
+ if (surface.allowedColors) {
+ 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],
+ },
+ });
+ }
}
}
+ // New color policy validation
+ if (contract.color) {
+ validateColorPolicy(contract, descriptor, violations);
+ }
+
const reportedWidth = descriptor.layout.maxContentWidth;
if (reportedWidth === null || reportedWidth === undefined) {
violations.push({
@@ -223,6 +370,154 @@ export function evaluateSurfaceCompliance(
}
}
+ // Validate pageFrame layout if contract defines it
+ if (surface.layout.pageFrame && descriptor.layout.pageFrame) {
+ const pageFrameContract = surface.layout.pageFrame;
+ const pageFrameDescriptor = descriptor.layout.pageFrame;
+ const enforcement = pageFrameContract.enforcement ?? "strict";
+
+ // Check if selector is supported
+ const containerSelector = pageFrameContract.containerSelector;
+ const isSupportedSelector =
+ containerSelector === '[data-contract="page-container"]' ||
+ containerSelector === "[data-contract='page-container']" ||
+ containerSelector === '[data-contract={page-container}]';
+
+ if (!isSupportedSelector) {
+ const violation: DriftViolation = {
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-selector-unsupported",
+ message: `Page frame container selector "${containerSelector}" is not supported in static analysis. Use '[data-contract="page-container"]' instead.`,
+ details: {
+ selector: containerSelector,
+ supportedSelectors: ['[data-contract="page-container"]'],
+ },
+ };
+ violations.push(violation);
+ } else {
+ // Validate container exists
+ if (
+ pageFrameDescriptor.maxWidthPx === null &&
+ pageFrameDescriptor.paddingLeftPx === null &&
+ pageFrameDescriptor.paddingRightPx === null
+ ) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-container-not-found",
+ message: `Page container with data-contract="page-container" not found for surface "${descriptor.surfaceId}".`,
+ details: {
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ } else {
+ // Validate max-width
+ const expectedMaxWidth = pageFrameContract.containerMaxWidthPx;
+ const actualMaxWidth = pageFrameDescriptor.maxWidthPx;
+
+ if (actualMaxWidth === null) {
+ // Check if clamp/calc was detected
+ if (pageFrameDescriptor.maxWidthHasClampCalc) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-non-deterministic-value",
+ message: `Page frame max-width uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedMaxWidth}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`,
+ details: {
+ property: "max-width",
+ expected: expectedMaxWidth,
+ actual: null,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ } else {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-unextractable-value",
+ message: `Page frame max-width could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedMaxWidth}px. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (max-w-[${expectedMaxWidth}px]).`,
+ details: {
+ property: "max-width",
+ expected: expectedMaxWidth,
+ actual: null,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ } else if (actualMaxWidth !== expectedMaxWidth) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-maxwidth-mismatch",
+ message: `Page frame max-width mismatch for surface "${descriptor.surfaceId}": expected ${expectedMaxWidth}px, found ${actualMaxWidth}px.`,
+ details: {
+ expected: expectedMaxWidth,
+ actual: actualMaxWidth,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+
+ // Validate padding
+ const expectedPadding = pageFrameContract.paddingXpx;
+ const actualPaddingLeft = pageFrameDescriptor.paddingLeftPx;
+ const actualPaddingRight = pageFrameDescriptor.paddingRightPx;
+
+ if (actualPaddingLeft === null || actualPaddingRight === null) {
+ // Check if clamp/calc was detected
+ if (pageFrameDescriptor.paddingHasClampCalc) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-non-deterministic-value",
+ message: `Page frame padding uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedPadding}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`,
+ details: {
+ property: "padding",
+ expected: expectedPadding,
+ actualLeft: actualPaddingLeft,
+ actualRight: actualPaddingRight,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ } else {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-unextractable-value",
+ message: `Page frame padding could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedPadding}px. Static analysis requires deterministic px values. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (px-[${expectedPadding}px]).`,
+ details: {
+ property: "padding",
+ expected: expectedPadding,
+ actualLeft: actualPaddingLeft,
+ actualRight: actualPaddingRight,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ } else if (
+ actualPaddingLeft !== expectedPadding ||
+ actualPaddingRight !== expectedPadding
+ ) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-padding-mismatch",
+ message: `Page frame padding mismatch for surface "${descriptor.surfaceId}": expected ${expectedPadding}px on both sides, found left=${actualPaddingLeft}px right=${actualPaddingRight}px.`,
+ details: {
+ expected: expectedPadding,
+ actualLeft: actualPaddingLeft,
+ actualRight: actualPaddingRight,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ }
+ }
+
+ // Apply enforcement mode: warn mode violations don't affect exit code
+ // This is handled at the CLI level by checking violation severity
+ }
+
return {
surfaceId: descriptor.surfaceId,
violations,
@@ -287,10 +582,21 @@ function formatAjvErrors(errors: ErrorObject[] | null | undefined): string[] {
return errors.map((error) => {
const dataPath = error.instancePath || error.schemaPath;
const baseMessage = error.message ?? "Validation error";
+
+ // Enhance error messages for common schema issues
+ let enhancedMessage = baseMessage;
+ if (error.keyword === "additionalProperties" && error.params?.additionalProperty) {
+ const prop = error.params.additionalProperty;
+ enhancedMessage = `Additional property "${prop}" is not allowed. This may indicate a capability gap - the field is not supported by the current schema version.`;
+ } else if (error.keyword === "required" && error.params?.missingProperty) {
+ const prop = error.params.missingProperty;
+ enhancedMessage = `Required property "${prop}" is missing.`;
+ }
+
if (error.params && Object.keys(error.params).length > 0) {
- return `${dataPath}: ${baseMessage} (${JSON.stringify(error.params)})`;
+ return `${dataPath}: ${enhancedMessage} (${JSON.stringify(error.params)})`;
}
- return `${dataPath}: ${baseMessage}`;
+ return `${dataPath}: ${enhancedMessage}`;
});
}
@@ -318,6 +624,7 @@ export type {
SurfaceColorDescriptor,
SurfaceMotionDescriptor,
SurfaceLayoutDescriptor,
+ PageFrameLayoutDescriptor,
SurfaceReport,
DriftViolation,
ValidationSummary,
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 7a0a1f6..65c36ba 100644
--- a/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json
+++ b/packages/interfacectl-validator/src/schema/surfaces.web.contract.schema.json
@@ -43,6 +43,9 @@
},
"constraints": {
"$ref": "#/$defs/constraints"
+ },
+ "color": {
+ "$ref": "#/$defs/color"
}
},
"$defs": {
@@ -55,7 +58,6 @@
"type",
"requiredSections",
"allowedFonts",
- "allowedColors",
"layout"
],
"properties": {
@@ -99,7 +101,8 @@
"type": "string",
"minLength": 1
},
- "uniqueItems": true
+ "uniqueItems": true,
+ "description": "Deprecated: Use color.sourceOfTruth and color.rawValues instead. This field will be removed in a future version."
},
"layout": {
"type": "object",
@@ -119,6 +122,9 @@
"minLength": 1
},
"uniqueItems": true
+ },
+ "pageFrame": {
+ "$ref": "#/$defs/pageFrame"
}
}
}
@@ -183,6 +189,189 @@
}
}
}
+ },
+ "pageFrame": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "containerSelector",
+ "containerMaxWidthPx",
+ "paddingXpx"
+ ],
+ "properties": {
+ "containerSelector": {
+ "type": "string",
+ "minLength": 1
+ },
+ "containerMaxWidthPx": {
+ "type": "number",
+ "minimum": 0
+ },
+ "paddingXpx": {
+ "type": "number",
+ "minimum": 0
+ },
+ "alignment": {
+ "type": "string",
+ "enum": ["center", "left"],
+ "default": "center"
+ },
+ "enforcement": {
+ "type": "string",
+ "enum": ["strict", "warn"],
+ "default": "strict"
+ }
+ }
+ },
+ "color": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "sourceOfTruth": {
+ "$ref": "#/$defs/colorSourceOfTruth"
+ },
+ "rawValues": {
+ "$ref": "#/$defs/colorRawValues"
+ },
+ "semantics": {
+ "$ref": "#/$defs/colorSemantics"
+ },
+ "consistency": {
+ "$ref": "#/$defs/colorConsistency"
+ }
+ }
+ },
+ "colorSourceOfTruth": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type"],
+ "properties": {
+ "type": {
+ "type": "string",
+ "enum": ["tokens", "none"]
+ },
+ "tokenNamespaces": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "uniqueItems": true
+ }
+ },
+ "if": {
+ "properties": {
+ "type": {
+ "const": "tokens"
+ }
+ }
+ },
+ "then": {
+ "required": ["tokenNamespaces"]
+ }
+ },
+ "colorRawValues": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["policy"],
+ "properties": {
+ "policy": {
+ "type": "string",
+ "enum": ["off", "warn", "strict"]
+ },
+ "allowlist": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "uniqueItems": true
+ },
+ "denylist": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1
+ },
+ "uniqueItems": true
+ }
+ }
+ },
+ "colorSemantics": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "roles": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "accent": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enforcement": {
+ "type": "string",
+ "enum": ["off", "warn", "strict"]
+ }
+ }
+ },
+ "text": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enforcement": {
+ "type": "string",
+ "enum": ["off", "warn", "strict"]
+ }
+ }
+ },
+ "background": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enforcement": {
+ "type": "string",
+ "enum": ["off", "warn", "strict"]
+ }
+ }
+ },
+ "border": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enforcement": {
+ "type": "string",
+ "enum": ["off", "warn", "strict"]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "colorConsistency": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "acrossSurfaces": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enforcement": {
+ "type": "string",
+ "enum": ["off", "warn", "strict"]
+ },
+ "signals": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": ["token-name", "css-var-name", "class-fragment"]
+ },
+ "uniqueItems": true
+ }
+ }
+ }
+ }
}
}
}
diff --git a/packages/interfacectl-validator/src/types.ts b/packages/interfacectl-validator/src/types.ts
index 1746757..339e7f3 100644
--- a/packages/interfacectl-validator/src/types.ts
+++ b/packages/interfacectl-validator/src/types.ts
@@ -1,15 +1,24 @@
export type SurfaceType = "web" | "cli";
+export interface PageFrameLayout {
+ containerSelector: string;
+ containerMaxWidthPx: number;
+ paddingXpx: number;
+ alignment?: "center" | "left";
+ enforcement?: "strict" | "warn";
+}
+
export interface ContractSurface {
id: string;
displayName: string;
type: SurfaceType;
requiredSections: string[];
allowedFonts: string[];
- allowedColors: string[];
+ allowedColors?: string[]; // Deprecated: use color.sourceOfTruth and color.rawValues
layout: {
maxContentWidth: number;
requiredContainers?: string[];
+ pageFrame?: PageFrameLayout;
};
}
@@ -26,6 +35,44 @@ export interface ContractConstraints {
};
}
+export interface ColorSourceOfTruth {
+ type: "tokens" | "none";
+ tokenNamespaces?: string[];
+}
+
+export interface ColorRawValues {
+ policy: "off" | "warn" | "strict";
+ allowlist?: string[];
+ denylist?: string[];
+}
+
+export interface ColorRoleEnforcement {
+ enforcement: "off" | "warn" | "strict";
+}
+
+export interface ColorSemantics {
+ roles?: {
+ accent?: ColorRoleEnforcement;
+ text?: ColorRoleEnforcement;
+ background?: ColorRoleEnforcement;
+ border?: ColorRoleEnforcement;
+ };
+}
+
+export interface ColorConsistency {
+ acrossSurfaces?: {
+ enforcement: "off" | "warn" | "strict";
+ signals?: ("token-name" | "css-var-name" | "class-fragment")[];
+ };
+}
+
+export interface ColorPolicy {
+ sourceOfTruth?: ColorSourceOfTruth;
+ rawValues?: ColorRawValues;
+ semantics?: ColorSemantics;
+ consistency?: ColorConsistency;
+}
+
export interface InterfaceContract {
contractId: string;
version: string;
@@ -33,6 +80,7 @@ export interface InterfaceContract {
surfaces: ContractSurface[];
sections: ContractSection[];
constraints: ContractConstraints;
+ color?: ColorPolicy;
}
export interface SurfaceSectionDescriptor {
@@ -56,11 +104,22 @@ export interface SurfaceMotionDescriptor {
source?: string;
}
+export interface PageFrameLayoutDescriptor {
+ containerSelector: string;
+ maxWidthPx?: number | null;
+ paddingLeftPx?: number | null;
+ paddingRightPx?: number | null;
+ source?: string;
+ maxWidthHasClampCalc?: boolean;
+ paddingHasClampCalc?: boolean;
+}
+
export interface SurfaceLayoutDescriptor {
maxContentWidth?: number | null;
containers?: string[];
containerSources?: string[];
source?: string;
+ pageFrame?: PageFrameLayoutDescriptor;
}
export interface SurfaceDescriptor {
@@ -81,10 +140,18 @@ export type DriftViolationType =
| "layout-width-exceeded"
| "layout-width-undetermined"
| "layout-container-missing"
+ | "layout-pageframe-selector-unsupported"
+ | "layout-pageframe-container-not-found"
+ | "layout-pageframe-maxwidth-mismatch"
+ | "layout-pageframe-padding-mismatch"
+ | "layout-pageframe-non-deterministic-value"
+ | "layout-pageframe-unextractable-value"
| "motion-duration-not-allowed"
| "motion-timing-not-allowed"
| "descriptor-missing"
- | "descriptor-unused";
+ | "descriptor-unused"
+ | "color-raw-value-used"
+ | "color-token-namespace-violation";
export interface DriftViolation {
surfaceId: string;