diff --git a/docs/contract-baseline.md b/docs/contract-baseline.md
index 022a9fc..26a8054 100644
--- a/docs/contract-baseline.md
+++ b/docs/contract-baseline.md
@@ -28,6 +28,7 @@ Optional surface layout fields:
- `layout.requiredContainers` (array of strings)
- `layout.pageFrame` (when present, enforced by validator)
+ - `layout.pageFrame.containerMinWidthPx` (optional): exact deterministic min-width check in px for `[data-contract="page-container"]`.
Optional per-surface icon policy (web surfaces):
diff --git a/docs/plans/pageframe-minwidth-ci-enforcement.md b/docs/plans/pageframe-minwidth-ci-enforcement.md
new file mode 100644
index 0000000..6fcf77b
--- /dev/null
+++ b/docs/plans/pageframe-minwidth-ci-enforcement.md
@@ -0,0 +1,64 @@
+# Feature Plan
+
+## Summary
+Add optional `containerMinWidthPx` support to `surfaces[*].layout.pageFrame` in the web surface contract. The validator and static descriptor extraction paths now enforce this value at CI/CD time when present, using deterministic exact px matching. Selector scope remains fixed to `[data-contract="page-container"]`, and existing contracts that omit the new field keep current behavior.
+
+## Strategy alignment
+This work strengthens the decision filter by adding one more explicit, contract-defined layout invariant that can block drift before ship in CI/CD pipelines. It keeps enforcement deterministic and contract-first, avoiding runtime-only or observability-only behavior.
+
+Decision filter sentence
+"When an agent proposes a change to the user experience, Surfaces determines whether that change is allowed, under what conditions it may proceed, and blocks or corrects it if it violates intent."
+
+## Contract impact
+1. Contract definition
+Adds optional `layout.pageFrame.containerMinWidthPx` (`number`, minimum `0`) with exact px semantics.
+
+2. Enforcement timing
+CI/CD time only. Enforced via existing `interfacectl validate` static descriptor extraction + contract compliance evaluation.
+
+3. Violation handling
+In strict mode, violations fail validation. New deterministic mismatch violation: `layout-pageframe-minwidth-mismatch` (`layout.pageframe.minwidth-mismatch`). Non-deterministic and unextractable min-width values reuse existing pageFrame violation types with `property: "min-width"` details.
+
+## Surface impact
+Which surface this affects and why.
+- surfaces.systems: indirect, when it consumes updated interfacectl validation behavior.
+- surfaceops.ai: indirect, same contract/validation pipeline inputs.
+- interfacectl: direct, schema/types/extractor/validator/CLI/tests/docs.
+
+## Non-goals
+List what this explicitly does not do to avoid scope drift.
+- No selector-scope expansion beyond `[data-contract="page-container"]`.
+- No runtime (edge) or generation-time enforcement changes.
+- No lower-bound inequality semantics (`>=`); exact px matching only.
+
+## Acceptance criteria
+Write 5 to 10 testable criteria.
+1. Schema accepts contracts that include `containerMinWidthPx`.
+2. Existing contracts without `containerMinWidthPx` continue to validate as before.
+3. Matching min-width values produce no pageFrame violations.
+4. Mismatched min-width values emit `layout.pageframe.minwidth-mismatch`.
+5. `min-width: clamp(...)` emits `layout.pageframe.non-deterministic-value` with min-width context.
+6. Unsupported selectors (for example `.shell`) emit `layout.pageframe.selector-unsupported`.
+7. Existing pageFrame max-width/padding tests remain green.
+8. `pnpm --filter @surfaces/interfacectl-validator run test` passes.
+9. `pnpm --filter @surfaces/interfacectl-cli run test` passes.
+
+## Risks and drift checks
+List the top 3 ways this could drift into observability-only, design tooling, prompt tooling, or orchestration.
+Add one mitigation per risk.
+1. Risk: parser/extractor ambiguity from broader selector behavior.
+Mitigation: keep selector support fixed to `[data-contract="page-container"]`.
+2. Risk: non-deterministic style expressions reduce enforceability.
+Mitigation: keep deterministic px extraction requirements and explicit non-deterministic findings.
+3. Risk: scope drift into runtime enforcement behavior.
+Mitigation: confine changes to validator + static descriptor extraction at CI/CD time.
+
+## Implementation outline
+List the smallest steps that can ship value.
+1. Extend pageFrame schema/types with optional `containerMinWidthPx`.
+2. Extend static extraction for `min-width` (inline styles, CSS selector rules, Tailwind bracket classes, CSS variable fallback).
+3. Enforce optional min-width in validator with exact px matching.
+4. Add dedicated min-width mismatch violation type and CLI finding mapping.
+5. Add min-width fixtures/tests and selector-unsupported regression test.
+6. Update pageFrame and baseline docs.
+7. Rebuild dist artifacts and run validator/CLI test suites.
diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js
index 1bb83d6..a0600ae 100644
--- a/packages/interfacectl-cli/dist/commands/validate.js
+++ b/packages/interfacectl-cli/dist/commands/validate.js
@@ -286,6 +286,7 @@ function mapViolationsToFindings(summary) {
"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-minwidth-mismatch": "layout.pageframe.minwidth-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",
@@ -378,6 +379,11 @@ function mapViolationsToFindings(summary) {
finding.found = details.actual;
break;
}
+ case "layout-pageframe-minwidth-mismatch": {
+ finding.expected = details.expected;
+ finding.found = details.actual;
+ break;
+ }
case "layout-pageframe-padding-mismatch": {
finding.expected = details.expected;
finding.found = {
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 d45b04d..edcce8b 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,EASvB,MAAM,kCAAkC,CAAC;AA6G1C,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
+{"version":3,"file":"static-analysis.d.ts","sourceRoot":"","sources":["../../src/descriptors/static-analysis.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EASvB,MAAM,kCAAkC,CAAC;AAkH1C,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 f644b29..ea52c7d 100644
--- a/packages/interfacectl-cli/dist/descriptors/static-analysis.js
+++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.js
@@ -8,17 +8,20 @@ const PAGE_CONTAINER_ATTRIBUTE_REGEX = /data-contract\s*=\s*(?:"page-container"|
// 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_MIN_WIDTH_REGEX = /min-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_MIN_WIDTH_REGEX = /min-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_MIN_WIDTH_REGEX = /min-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;
@@ -27,6 +30,7 @@ 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_MIN_WIDTH_VAR_REGEX = /--contract-page-frame-min-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/**",
@@ -330,18 +334,22 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
return {
containerSelector,
maxWidthPx: null,
+ minWidthPx: null,
paddingLeftPx: null,
paddingRightPx: null,
source: undefined,
maxWidthHasClampCalc: undefined,
+ minWidthHasClampCalc: undefined,
paddingHasClampCalc: undefined,
};
}
let maxWidthPx = null;
+ let minWidthPx = null;
let paddingLeftPx = null;
let paddingRightPx = null;
let extractionSource;
let maxWidthHasClampCalc = false;
+ let minWidthHasClampCalc = false;
let paddingHasClampCalc = false;
// Strategy A: Extract from inline styles on the marked element
if (containerFileContent) {
@@ -368,6 +376,27 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
}
}
}
+ // Extract min-width
+ if (minWidthPx === null) {
+ const minWidthDeclMatch = /min-width\s*:\s*([^;]+)/i.exec(styleContent);
+ if (minWidthDeclMatch) {
+ const minWidthValue = minWidthDeclMatch[1].trim();
+ if (CLAMP_REGEX.test(minWidthValue) || CALC_REGEX.test(minWidthValue)) {
+ minWidthHasClampCalc = true;
+ }
+ else {
+ INLINE_MIN_WIDTH_REGEX.lastIndex = 0;
+ const minWidthMatch = INLINE_MIN_WIDTH_REGEX.exec(styleContent);
+ if (minWidthMatch) {
+ const value = Number.parseFloat(minWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ minWidthPx = value;
+ extractionSource = containerSource;
+ }
+ }
+ }
+ }
+ }
// Extract padding
if (paddingLeftPx === null || paddingRightPx === null) {
INLINE_PADDING_INLINE_REGEX.lastIndex = 0;
@@ -417,7 +446,10 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
}
}
// Strategy B: Extract from CSS rules targeting [data-contract="page-container"]
- if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ if (maxWidthPx === null ||
+ minWidthPx === null ||
+ paddingLeftPx === null ||
+ paddingRightPx === null) {
for (const cssPath of cssFilePaths) {
const cssContent = await readFileCached(cssPath, fileContentCache);
CSS_SELECTOR_PAGE_CONTAINER_REGEX.lastIndex = 0;
@@ -448,6 +480,27 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
}
}
}
+ // Extract min-width (check for clamp/calc in min-width declaration)
+ if (minWidthPx === null) {
+ const minWidthDeclMatch = /min-width\s*:\s*([^;]+)/i.exec(ruleContent);
+ if (minWidthDeclMatch) {
+ const minWidthValue = minWidthDeclMatch[1].trim();
+ if (CLAMP_REGEX.test(minWidthValue) || CALC_REGEX.test(minWidthValue)) {
+ minWidthHasClampCalc = true;
+ }
+ else {
+ CSS_MIN_WIDTH_REGEX.lastIndex = 0;
+ const minWidthMatch = CSS_MIN_WIDTH_REGEX.exec(ruleContent);
+ if (minWidthMatch) {
+ const value = Number.parseFloat(minWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ minWidthPx = 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)
@@ -503,7 +556,10 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
}
}
// Strategy C: Extract from Tailwind bracket classes (best-effort, v1)
- if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ if (maxWidthPx === null ||
+ minWidthPx === null ||
+ paddingLeftPx === null ||
+ paddingRightPx === null) {
for (const filePath of sectionFiles) {
const content = await readFileCached(filePath, fileContentCache);
// Extract max-width from max-w-[NNNpx]
@@ -518,6 +574,18 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
}
}
}
+ // Extract min-width from min-w-[NNNpx]
+ if (minWidthPx === null) {
+ TAILWIND_MIN_WIDTH_REGEX.lastIndex = 0;
+ const minWidthMatch = TAILWIND_MIN_WIDTH_REGEX.exec(content);
+ if (minWidthMatch) {
+ const value = Number.parseFloat(minWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ minWidthPx = 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;
@@ -549,7 +617,10 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
}
}
// Optional: CSS custom properties (fallback, not required)
- if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ if (maxWidthPx === null ||
+ minWidthPx === null ||
+ paddingLeftPx === null ||
+ paddingRightPx === null) {
for (const cssPath of cssFilePaths) {
const cssContent = await readFileCached(cssPath, fileContentCache);
if (maxWidthPx === null) {
@@ -562,6 +633,16 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
}
}
}
+ if (minWidthPx === null) {
+ const minWidthMatch = cssContent.match(PAGE_FRAME_MIN_WIDTH_VAR_REGEX);
+ if (minWidthMatch) {
+ const value = Number.parseFloat(minWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ minWidthPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
if (paddingLeftPx === null || paddingRightPx === null) {
const paddingXMatch = cssContent.match(PAGE_FRAME_PADDING_VAR_REGEX);
if (paddingXMatch) {
@@ -578,10 +659,12 @@ async function extractPageFrameLayout(cssFilePaths, sectionFiles, workspaceRoot,
return {
containerSelector,
maxWidthPx,
+ minWidthPx,
paddingLeftPx,
paddingRightPx,
source: extractionSource ?? containerSource,
maxWidthHasClampCalc: maxWidthHasClampCalc || undefined,
+ minWidthHasClampCalc: minWidthHasClampCalc || undefined,
paddingHasClampCalc: paddingHasClampCalc || undefined,
};
}
diff --git a/packages/interfacectl-cli/docs/layout-pageframe.md b/packages/interfacectl-cli/docs/layout-pageframe.md
index 253999b..ff9f668 100644
--- a/packages/interfacectl-cli/docs/layout-pageframe.md
+++ b/packages/interfacectl-cli/docs/layout-pageframe.md
@@ -8,6 +8,7 @@ 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`
+- **Min-width** (optional): Compares extracted `min-width` CSS property against `containerMinWidthPx` when present
- **Horizontal padding**: Compares extracted `padding-left` and `padding-right` (or `padding-inline`) against `paddingXpx`
## What It Does Not Validate
@@ -28,20 +29,20 @@ The validator uses static analysis to extract deterministic pixel values from so
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`
+ - Parses `style` attribute for `max-width`, `min-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: ...; }`
+ - Detects rules like `[data-contract="page-container"] { max-width: ...; min-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]`
+ - Supports `max-w-[NNNpx]`, `min-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`
+ - `--contract-page-frame-max-width`, `--contract-page-frame-min-width`, and `--contract-page-frame-padding-x`
- Not required, but supported if present
### Limitations
@@ -69,6 +70,7 @@ Add `pageFrame` as an optional property within a surface's `layout` section:
"pageFrame": {
"containerSelector": "[data-contract=\"page-container\"]",
"containerMaxWidthPx": 1200,
+ "containerMinWidthPx": 1024,
"paddingXpx": 24,
"alignment": "center",
"enforcement": "strict"
@@ -83,6 +85,7 @@ Add `pageFrame` as an optional property within a surface's `layout` section:
- `containerSelector` (required): CSS selector identifying the primary page container. v1 only supports `[data-contract="page-container"]`
- `containerMaxWidthPx` (required): Expected max-width in pixels
+- `containerMinWidthPx` (optional): Expected min-width in pixels (validated only when present)
- `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"`
@@ -94,13 +97,14 @@ Add `pageFrame` as an optional property within a surface's `layout` section:
### Inline Styles (Recommended)
```tsx
-
{children}
@@ -111,6 +115,7 @@ Add `pageFrame` as an optional property within a surface's `layout` section:
```css
[data-contract="page-container"] {
max-width: 1200px;
+ min-width: 1024px;
padding-left: 24px;
padding-right: 24px;
}
@@ -127,7 +132,7 @@ Add `pageFrame` as an optional property within a surface's `layout` section:
```tsx
{children}
@@ -181,6 +186,12 @@ Extracted max-width does not match the expected value.
**Resolution**: Update the max-width to match the contract value.
+### `layout.pageFrame.minwidth-mismatch`
+
+Extracted min-width does not match the expected value.
+
+**Resolution**: Update the min-width to match the contract value.
+
### `layout.pageFrame.padding-mismatch`
Extracted padding does not match the expected value.
diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts
index 8addbad..2b49f16 100644
--- a/packages/interfacectl-cli/src/commands/validate.ts
+++ b/packages/interfacectl-cli/src/commands/validate.ts
@@ -426,6 +426,7 @@ function mapViolationsToFindings(
"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-minwidth-mismatch": "layout.pageframe.minwidth-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",
@@ -522,6 +523,11 @@ function mapViolationsToFindings(
finding.found = details.actual;
break;
}
+ case "layout-pageframe-minwidth-mismatch": {
+ finding.expected = details.expected;
+ finding.found = details.actual;
+ break;
+ }
case "layout-pageframe-padding-mismatch": {
finding.expected = details.expected;
finding.found = {
diff --git a/packages/interfacectl-cli/src/descriptors/static-analysis.ts b/packages/interfacectl-cli/src/descriptors/static-analysis.ts
index 6c4b252..fa88951 100644
--- a/packages/interfacectl-cli/src/descriptors/static-analysis.ts
+++ b/packages/interfacectl-cli/src/descriptors/static-analysis.ts
@@ -25,17 +25,20 @@ const PAGE_CONTAINER_ATTRIBUTE_REGEX =
// 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_MIN_WIDTH_REGEX = /min-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_MIN_WIDTH_REGEX = /min-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_MIN_WIDTH_REGEX = /min-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;
@@ -45,6 +48,8 @@ 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_MIN_WIDTH_VAR_REGEX =
+ /--contract-page-frame-min-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 = [
@@ -545,19 +550,23 @@ async function extractPageFrameLayout(
return {
containerSelector,
maxWidthPx: null,
+ minWidthPx: null,
paddingLeftPx: null,
paddingRightPx: null,
source: undefined,
maxWidthHasClampCalc: undefined,
+ minWidthHasClampCalc: undefined,
paddingHasClampCalc: undefined,
};
}
let maxWidthPx: number | null = null;
+ let minWidthPx: number | null = null;
let paddingLeftPx: number | null = null;
let paddingRightPx: number | null = null;
let extractionSource: string | undefined;
let maxWidthHasClampCalc = false;
+ let minWidthHasClampCalc = false;
let paddingHasClampCalc = false;
// Strategy A: Extract from inline styles on the marked element
@@ -587,6 +596,27 @@ async function extractPageFrameLayout(
}
}
+ // Extract min-width
+ if (minWidthPx === null) {
+ const minWidthDeclMatch = /min-width\s*:\s*([^;]+)/i.exec(styleContent);
+ if (minWidthDeclMatch) {
+ const minWidthValue = minWidthDeclMatch[1].trim();
+ if (CLAMP_REGEX.test(minWidthValue) || CALC_REGEX.test(minWidthValue)) {
+ minWidthHasClampCalc = true;
+ } else {
+ INLINE_MIN_WIDTH_REGEX.lastIndex = 0;
+ const minWidthMatch = INLINE_MIN_WIDTH_REGEX.exec(styleContent);
+ if (minWidthMatch) {
+ const value = Number.parseFloat(minWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ minWidthPx = value;
+ extractionSource = containerSource;
+ }
+ }
+ }
+ }
+ }
+
// Extract padding
if (paddingLeftPx === null || paddingRightPx === null) {
INLINE_PADDING_INLINE_REGEX.lastIndex = 0;
@@ -636,7 +666,12 @@ async function extractPageFrameLayout(
}
// Strategy B: Extract from CSS rules targeting [data-contract="page-container"]
- if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ if (
+ maxWidthPx === null ||
+ minWidthPx === null ||
+ paddingLeftPx === null ||
+ paddingRightPx === null
+ ) {
for (const cssPath of cssFilePaths) {
const cssContent = await readFileCached(cssPath, fileContentCache);
@@ -669,6 +704,27 @@ async function extractPageFrameLayout(
}
}
+ // Extract min-width (check for clamp/calc in min-width declaration)
+ if (minWidthPx === null) {
+ const minWidthDeclMatch = /min-width\s*:\s*([^;]+)/i.exec(ruleContent);
+ if (minWidthDeclMatch) {
+ const minWidthValue = minWidthDeclMatch[1].trim();
+ if (CLAMP_REGEX.test(minWidthValue) || CALC_REGEX.test(minWidthValue)) {
+ minWidthHasClampCalc = true;
+ } else {
+ CSS_MIN_WIDTH_REGEX.lastIndex = 0;
+ const minWidthMatch = CSS_MIN_WIDTH_REGEX.exec(ruleContent);
+ if (minWidthMatch) {
+ const value = Number.parseFloat(minWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ minWidthPx = 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)
@@ -724,7 +780,12 @@ async function extractPageFrameLayout(
}
// Strategy C: Extract from Tailwind bracket classes (best-effort, v1)
- if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ if (
+ maxWidthPx === null ||
+ minWidthPx === null ||
+ paddingLeftPx === null ||
+ paddingRightPx === null
+ ) {
for (const filePath of sectionFiles) {
const content = await readFileCached(filePath, fileContentCache);
@@ -741,6 +802,19 @@ async function extractPageFrameLayout(
}
}
+ // Extract min-width from min-w-[NNNpx]
+ if (minWidthPx === null) {
+ TAILWIND_MIN_WIDTH_REGEX.lastIndex = 0;
+ const minWidthMatch = TAILWIND_MIN_WIDTH_REGEX.exec(content);
+ if (minWidthMatch) {
+ const value = Number.parseFloat(minWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ minWidthPx = 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;
@@ -772,7 +846,12 @@ async function extractPageFrameLayout(
}
// Optional: CSS custom properties (fallback, not required)
- if (maxWidthPx === null || paddingLeftPx === null || paddingRightPx === null) {
+ if (
+ maxWidthPx === null ||
+ minWidthPx === null ||
+ paddingLeftPx === null ||
+ paddingRightPx === null
+ ) {
for (const cssPath of cssFilePaths) {
const cssContent = await readFileCached(cssPath, fileContentCache);
@@ -787,6 +866,17 @@ async function extractPageFrameLayout(
}
}
+ if (minWidthPx === null) {
+ const minWidthMatch = cssContent.match(PAGE_FRAME_MIN_WIDTH_VAR_REGEX);
+ if (minWidthMatch) {
+ const value = Number.parseFloat(minWidthMatch[1]);
+ if (Number.isFinite(value)) {
+ minWidthPx = value;
+ extractionSource = path.relative(workspaceRoot, cssPath);
+ }
+ }
+ }
+
if (paddingLeftPx === null || paddingRightPx === null) {
const paddingXMatch = cssContent.match(PAGE_FRAME_PADDING_VAR_REGEX);
if (paddingXMatch) {
@@ -804,10 +894,12 @@ async function extractPageFrameLayout(
return {
containerSelector,
maxWidthPx,
+ minWidthPx,
paddingLeftPx,
paddingRightPx,
source: extractionSource ?? containerSource,
maxWidthHasClampCalc: maxWidthHasClampCalc || undefined,
+ minWidthHasClampCalc: minWidthHasClampCalc || undefined,
paddingHasClampCalc: paddingHasClampCalc || undefined,
};
}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.minwidth.json b/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.minwidth.json
new file mode 100644
index 0000000..c43ce70
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.minwidth.json
@@ -0,0 +1,53 @@
+{
+ "contractId": "test-pageframe-static-minwidth",
+ "version": "1.0.0",
+ "surfaces": [
+ {
+ "id": "test-surface",
+ "displayName": "Test Surface",
+ "type": "web",
+ "requiredSections": [
+ "header"
+ ],
+ "allowedFonts": [
+ "Inter"
+ ],
+ "layout": {
+ "maxContentWidth": 1200,
+ "pageFrame": {
+ "containerSelector": "[data-contract=\"page-container\"]",
+ "containerMaxWidthPx": 1200,
+ "containerMinWidthPx": 1024,
+ "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"
+ ]
+ }
+ },
+ "color": {
+ "policy": "off",
+ "allowedValues": [
+ "#000000"
+ ]
+ }
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.unsupported-selector.json b/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.unsupported-selector.json
new file mode 100644
index 0000000..e6d6c4e
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/contract.unsupported-selector.json
@@ -0,0 +1,52 @@
+{
+ "contractId": "test-pageframe-static-unsupported-selector",
+ "version": "1.0.0",
+ "surfaces": [
+ {
+ "id": "test-surface",
+ "displayName": "Test Surface",
+ "type": "web",
+ "requiredSections": [
+ "header"
+ ],
+ "allowedFonts": [
+ "Inter"
+ ],
+ "layout": {
+ "maxContentWidth": 1200,
+ "pageFrame": {
+ "containerSelector": ".shell",
+ "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"
+ ]
+ }
+ },
+ "color": {
+ "policy": "off",
+ "allowedValues": [
+ "#000000"
+ ]
+ }
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/globals.css b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/globals.css
new file mode 100644
index 0000000..720e556
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/globals.css
@@ -0,0 +1,20 @@
+:root {
+ --contract-max-width: 1200px;
+ --contract-motion-duration: 200ms;
+ --contract-motion-timing: ease;
+}
+
+body {
+ font-family: "Inter";
+}
+
+.contract-container {
+ max-width: var(--contract-max-width);
+ transition: opacity var(--contract-motion-duration) var(--contract-motion-timing);
+}
+
+[data-contract="page-container"] {
+ max-width: 1200px;
+ min-width: clamp(768px, 80vw, 1024px);
+ padding-inline: 24px;
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/layout.tsx
new file mode 100644
index 0000000..3fccca2
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/layout.tsx
@@ -0,0 +1,9 @@
+import "./globals.css";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/page.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/page.tsx
new file mode 100644
index 0000000..2550a82
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/page.tsx
@@ -0,0 +1,7 @@
+export default function Page() {
+ return (
+
+ Test
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/globals.css b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/globals.css
new file mode 100644
index 0000000..1890dba
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/globals.css
@@ -0,0 +1,14 @@
+:root {
+ --contract-max-width: 1200px;
+ --contract-motion-duration: 200ms;
+ --contract-motion-timing: ease;
+}
+
+body {
+ font-family: "Inter";
+}
+
+.contract-container {
+ max-width: var(--contract-max-width);
+ transition: opacity var(--contract-motion-duration) var(--contract-motion-timing);
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/layout.tsx
new file mode 100644
index 0000000..ea3c8e1
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/layout.tsx
@@ -0,0 +1,12 @@
+import "./globals.css";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/page.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/page.tsx
new file mode 100644
index 0000000..2550a82
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/page.tsx
@@ -0,0 +1,7 @@
+export default function Page() {
+ return (
+
+ Test
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/globals.css b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/globals.css
new file mode 100644
index 0000000..1890dba
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/globals.css
@@ -0,0 +1,14 @@
+:root {
+ --contract-max-width: 1200px;
+ --contract-motion-duration: 200ms;
+ --contract-motion-timing: ease;
+}
+
+body {
+ font-family: "Inter";
+}
+
+.contract-container {
+ max-width: var(--contract-max-width);
+ transition: opacity var(--contract-motion-duration) var(--contract-motion-timing);
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/layout.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/layout.tsx
new file mode 100644
index 0000000..562a901
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/layout.tsx
@@ -0,0 +1,12 @@
+import "./globals.css";
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/page.tsx b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/page.tsx
new file mode 100644
index 0000000..2550a82
--- /dev/null
+++ b/packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/page.tsx
@@ -0,0 +1,7 @@
+export default function Page() {
+ return (
+
+ Test
+
+ );
+}
diff --git a/packages/interfacectl-cli/test/pageframe-static.test.mjs b/packages/interfacectl-cli/test/pageframe-static.test.mjs
index b1e239a..4fcd579 100644
--- a/packages/interfacectl-cli/test/pageframe-static.test.mjs
+++ b/packages/interfacectl-cli/test/pageframe-static.test.mjs
@@ -1,16 +1,19 @@
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");
+const minWidthContractPath = path.join(fixturesDir, "contract.minwidth.json");
+const unsupportedSelectorContractPath = path.join(
+ fixturesDir,
+ "contract.unsupported-selector.json",
+);
-async function runValidate(fixtureName) {
+async function runValidate(fixtureName, selectedContractPath = contractPath) {
const fixtureRoot = path.join(fixturesDir, fixtureName);
return new Promise((resolve, reject) => {
const proc = spawn(
@@ -19,7 +22,7 @@ async function runValidate(fixtureName) {
path.join(__dirname, "..", "dist", "index.js"),
"validate",
"--contract",
- contractPath,
+ selectedContractPath,
"--root",
fixtureRoot,
"--format",
@@ -134,3 +137,60 @@ test("pageFrame validation - max-width mismatch (failing)", async () => {
assert.strictEqual(maxWidthViolation.found, 1400);
assert.strictEqual(result.code, 1, "Should exit with code 1");
});
+
+test("pageFrame validation - min-width exact match (passing)", async () => {
+ const result = await runValidate("minwidth-pass", minWidthContractPath);
+ 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 - min-width mismatch (failing)", async () => {
+ const result = await runValidate("minwidth-fail", minWidthContractPath);
+ const output = JSON.parse(result.stdout);
+
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+
+ const minWidthViolation = pageFrameViolations.find(
+ (v) => v.code === "layout.pageframe.minwidth-mismatch",
+ );
+ assert.ok(minWidthViolation, "Should have minwidth-mismatch violation");
+ assert.strictEqual(minWidthViolation.expected, 1024);
+ assert.strictEqual(minWidthViolation.found, 960);
+ assert.strictEqual(result.code, 1, "Should exit with code 1");
+});
+
+test("pageFrame validation - clamp() min-width (non-deterministic)", async () => {
+ const result = await runValidate("minwidth-clamp", minWidthContractPath);
+ const output = JSON.parse(result.stdout);
+
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+
+ const minWidthViolation = pageFrameViolations.find(
+ (v) => v.code === "layout.pageframe.non-deterministic-value",
+ );
+ assert.ok(minWidthViolation, "Should have non-deterministic-value violation");
+ assert.strictEqual(result.code, 1, "Should exit with code 1");
+});
+
+test("pageFrame validation - unsupported selector reports violation", async () => {
+ const result = await runValidate("inline-styles", unsupportedSelectorContractPath);
+ const output = JSON.parse(result.stdout);
+
+ const pageFrameViolations = output.findings.filter((f) =>
+ f.code.startsWith("layout.pageframe."),
+ );
+ const selectorViolation = pageFrameViolations.find(
+ (v) => v.code === "layout.pageframe.selector-unsupported",
+ );
+ assert.ok(selectorViolation, "Should have selector-unsupported violation");
+ assert.strictEqual(result.code, 1, "Should exit with code 1");
+});
diff --git a/packages/interfacectl-cli/test/violation-classifier.test.mjs b/packages/interfacectl-cli/test/violation-classifier.test.mjs
index c57e23a..71eba0f 100644
--- a/packages/interfacectl-cli/test/violation-classifier.test.mjs
+++ b/packages/interfacectl-cli/test/violation-classifier.test.mjs
@@ -18,6 +18,7 @@ test("classifyViolationType: E2 violations", () => {
assert.equal(classifyViolationType("layout-width-exceeded"), "E2");
assert.equal(classifyViolationType("layout-width-undetermined"), "E2");
assert.equal(classifyViolationType("layout-container-missing"), "E2");
+ assert.equal(classifyViolationType("layout-pageframe-minwidth-mismatch"), "E2");
assert.equal(classifyViolationType("missing-section"), "E2");
assert.equal(classifyViolationType("unknown-section"), "E2");
assert.equal(classifyViolationType("unknown-surface"), "E2");
diff --git a/packages/interfacectl-validator/dist/index.d.ts.map b/packages/interfacectl-validator/dist/index.d.ts.map
index efd0aa5..0731bf6 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,EAKlB,MAAM,YAAY,CAAC;AAQpB,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;AA4ED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAqXf;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,qBAAqB,EACrB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,0BAA0B,EAC1B,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,WAAW,EACX,OAAO,EACP,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,UAAU,EACV,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;AAE9B,OAAO,EACL,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,mBAAmB,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;AAQpB,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;AA4ED,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CA4af;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,qBAAqB,EACrB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,0BAA0B,EAC1B,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,WAAW,EACX,OAAO,EACP,UAAU,EACV,SAAS,EACT,cAAc,EACd,SAAS,EACT,QAAQ,EACR,WAAW,EACX,iBAAiB,EACjB,eAAe,EACf,UAAU,EACV,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;AAE9B,OAAO,EACL,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,mBAAmB,CAAC"}
\ No newline at end of file
diff --git a/packages/interfacectl-validator/dist/index.js b/packages/interfacectl-validator/dist/index.js
index 7f0f830..358fbe2 100644
--- a/packages/interfacectl-validator/dist/index.js
+++ b/packages/interfacectl-validator/dist/index.js
@@ -279,9 +279,15 @@ export function evaluateSurfaceCompliance(contract, descriptor) {
}
}
// Validate pageFrame layout if contract defines it
- if (surface.layout.pageFrame && descriptor.layout.pageFrame) {
+ if (surface.layout.pageFrame) {
const pageFrameContract = surface.layout.pageFrame;
- const pageFrameDescriptor = descriptor.layout.pageFrame;
+ const pageFrameDescriptor = descriptor.layout.pageFrame ?? {
+ containerSelector: pageFrameContract.containerSelector,
+ maxWidthPx: null,
+ minWidthPx: null,
+ paddingLeftPx: null,
+ paddingRightPx: null,
+ };
const enforcement = pageFrameContract.enforcement ?? "strict";
// Check if selector is supported
const containerSelector = pageFrameContract.containerSelector;
@@ -363,6 +369,54 @@ export function evaluateSurfaceCompliance(contract, descriptor) {
},
});
}
+ // Validate min-width (optional)
+ const expectedMinWidth = pageFrameContract.containerMinWidthPx;
+ if (expectedMinWidth !== undefined) {
+ const actualMinWidth = pageFrameDescriptor.minWidthPx ?? null;
+ if (actualMinWidth === null) {
+ if (pageFrameDescriptor.minWidthHasClampCalc) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-non-deterministic-value",
+ message: `Page frame min-width uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedMinWidth}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`,
+ details: {
+ property: "min-width",
+ expected: expectedMinWidth,
+ actual: null,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ else {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-unextractable-value",
+ message: `Page frame min-width could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedMinWidth}px. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (min-w-[${expectedMinWidth}px]).`,
+ details: {
+ property: "min-width",
+ expected: expectedMinWidth,
+ actual: null,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ }
+ else if (actualMinWidth !== expectedMinWidth) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-minwidth-mismatch",
+ message: `Page frame min-width mismatch for surface "${descriptor.surfaceId}": expected ${expectedMinWidth}px, found ${actualMinWidth}px.`,
+ details: {
+ expected: expectedMinWidth,
+ actual: actualMinWidth,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ }
// Validate padding
const expectedPadding = pageFrameContract.paddingXpx;
const actualPaddingLeft = pageFrameDescriptor.paddingLeftPx;
diff --git a/packages/interfacectl-validator/dist/schema/web.surface.contract.schema.json b/packages/interfacectl-validator/dist/schema/web.surface.contract.schema.json
index 7564e7c..88a2d63 100644
--- a/packages/interfacectl-validator/dist/schema/web.surface.contract.schema.json
+++ b/packages/interfacectl-validator/dist/schema/web.surface.contract.schema.json
@@ -290,6 +290,10 @@
"type": "number",
"minimum": 0
},
+ "containerMinWidthPx": {
+ "type": "number",
+ "minimum": 0
+ },
"paddingXpx": {
"type": "number",
"minimum": 0
diff --git a/packages/interfacectl-validator/dist/types.d.ts b/packages/interfacectl-validator/dist/types.d.ts
index 8940282..d2bc248 100644
--- a/packages/interfacectl-validator/dist/types.d.ts
+++ b/packages/interfacectl-validator/dist/types.d.ts
@@ -2,6 +2,7 @@ export type SurfaceType = "web" | "cli";
export interface PageFrameLayout {
containerSelector: string;
containerMaxWidthPx: number;
+ containerMinWidthPx?: number;
paddingXpx: number;
alignment?: "center" | "left";
enforcement?: "strict" | "warn";
@@ -91,10 +92,12 @@ export interface SurfacePrimitiveDescriptor {
export interface PageFrameLayoutDescriptor {
containerSelector: string;
maxWidthPx?: number | null;
+ minWidthPx?: number | null;
paddingLeftPx?: number | null;
paddingRightPx?: number | null;
source?: string;
maxWidthHasClampCalc?: boolean;
+ minWidthHasClampCalc?: boolean;
paddingHasClampCalc?: boolean;
}
export interface SurfaceLayoutDescriptor {
@@ -114,7 +117,7 @@ export interface SurfaceDescriptor {
motion: SurfaceMotionDescriptor[];
primitives?: SurfacePrimitiveDescriptor[];
}
-export type DriftViolationType = "unknown-surface" | "missing-section" | "unknown-section" | "font-not-allowed" | "color-not-allowed" | "icon-source-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" | "shell-owned-primitive-emitted";
+export type DriftViolationType = "unknown-surface" | "missing-section" | "unknown-section" | "font-not-allowed" | "color-not-allowed" | "icon-source-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-minwidth-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" | "shell-owned-primitive-emitted";
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 2f6fbd3..d65f9db 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 16a2a9e..bb38e11 100644
--- a/packages/interfacectl-validator/src/index.ts
+++ b/packages/interfacectl-validator/src/index.ts
@@ -356,9 +356,16 @@ export function evaluateSurfaceCompliance(
}
// Validate pageFrame layout if contract defines it
- if (surface.layout.pageFrame && descriptor.layout.pageFrame) {
+ if (surface.layout.pageFrame) {
const pageFrameContract = surface.layout.pageFrame;
- const pageFrameDescriptor = descriptor.layout.pageFrame;
+ const pageFrameDescriptor: PageFrameLayoutDescriptor =
+ descriptor.layout.pageFrame ?? {
+ containerSelector: pageFrameContract.containerSelector,
+ maxWidthPx: null,
+ minWidthPx: null,
+ paddingLeftPx: null,
+ paddingRightPx: null,
+ };
const enforcement = pageFrameContract.enforcement ?? "strict";
// Check if selector is supported
@@ -443,6 +450,54 @@ export function evaluateSurfaceCompliance(
});
}
+ // Validate min-width (optional)
+ const expectedMinWidth = pageFrameContract.containerMinWidthPx;
+ if (expectedMinWidth !== undefined) {
+ const actualMinWidth = pageFrameDescriptor.minWidthPx ?? null;
+
+ if (actualMinWidth === null) {
+ if (pageFrameDescriptor.minWidthHasClampCalc) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-non-deterministic-value",
+ message: `Page frame min-width uses non-deterministic expression (clamp/calc) for surface "${descriptor.surfaceId}". Expected ${expectedMinWidth}px. Static analysis requires deterministic px values. Use fixed px values in inline styles or CSS rules targeting [data-contract="page-container"].`,
+ details: {
+ property: "min-width",
+ expected: expectedMinWidth,
+ actual: null,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ } else {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-unextractable-value",
+ message: `Page frame min-width could not be extracted for surface "${descriptor.surfaceId}". Expected ${expectedMinWidth}px. Use inline styles, CSS rules targeting [data-contract="page-container"], or Tailwind bracket classes (min-w-[${expectedMinWidth}px]).`,
+ details: {
+ property: "min-width",
+ expected: expectedMinWidth,
+ actual: null,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ } else if (actualMinWidth !== expectedMinWidth) {
+ violations.push({
+ surfaceId: descriptor.surfaceId,
+ type: "layout-pageframe-minwidth-mismatch",
+ message: `Page frame min-width mismatch for surface "${descriptor.surfaceId}": expected ${expectedMinWidth}px, found ${actualMinWidth}px.`,
+ details: {
+ expected: expectedMinWidth,
+ actual: actualMinWidth,
+ selector: containerSelector,
+ source: pageFrameDescriptor.source,
+ },
+ });
+ }
+ }
+
// Validate padding
const expectedPadding = pageFrameContract.paddingXpx;
const actualPaddingLeft = pageFrameDescriptor.paddingLeftPx;
diff --git a/packages/interfacectl-validator/src/schema/web.surface.contract.schema.json b/packages/interfacectl-validator/src/schema/web.surface.contract.schema.json
index 7564e7c..88a2d63 100644
--- a/packages/interfacectl-validator/src/schema/web.surface.contract.schema.json
+++ b/packages/interfacectl-validator/src/schema/web.surface.contract.schema.json
@@ -290,6 +290,10 @@
"type": "number",
"minimum": 0
},
+ "containerMinWidthPx": {
+ "type": "number",
+ "minimum": 0
+ },
"paddingXpx": {
"type": "number",
"minimum": 0
diff --git a/packages/interfacectl-validator/src/types.ts b/packages/interfacectl-validator/src/types.ts
index 4ba344d..2b47f8c 100644
--- a/packages/interfacectl-validator/src/types.ts
+++ b/packages/interfacectl-validator/src/types.ts
@@ -3,6 +3,7 @@ export type SurfaceType = "web" | "cli";
export interface PageFrameLayout {
containerSelector: string;
containerMaxWidthPx: number;
+ containerMinWidthPx?: number;
paddingXpx: number;
alignment?: "center" | "left";
enforcement?: "strict" | "warn";
@@ -106,10 +107,12 @@ export interface SurfacePrimitiveDescriptor {
export interface PageFrameLayoutDescriptor {
containerSelector: string;
maxWidthPx?: number | null;
+ minWidthPx?: number | null;
paddingLeftPx?: number | null;
paddingRightPx?: number | null;
source?: string;
maxWidthHasClampCalc?: boolean;
+ minWidthHasClampCalc?: boolean;
paddingHasClampCalc?: boolean;
}
@@ -145,6 +148,7 @@ export type DriftViolationType =
| "layout-pageframe-selector-unsupported"
| "layout-pageframe-container-not-found"
| "layout-pageframe-maxwidth-mismatch"
+ | "layout-pageframe-minwidth-mismatch"
| "layout-pageframe-padding-mismatch"
| "layout-pageframe-non-deterministic-value"
| "layout-pageframe-unextractable-value"
diff --git a/packages/interfacectl-validator/test/contract-structure.test.mjs b/packages/interfacectl-validator/test/contract-structure.test.mjs
index f88ffee..bd1f983 100644
--- a/packages/interfacectl-validator/test/contract-structure.test.mjs
+++ b/packages/interfacectl-validator/test/contract-structure.test.mjs
@@ -92,3 +92,20 @@ test("validateContractStructure accepts x_extracted.iconSources", () => {
const result = validateContractStructure(contract, schema);
assert.equal(result.ok, true);
});
+
+test("validateContractStructure accepts optional pageFrame.containerMinWidthPx", () => {
+ const schema = getBundledContractSchema();
+ const contract = buildContract({
+ layout: {
+ maxContentWidth: 1200,
+ pageFrame: {
+ containerSelector: '[data-contract=\"page-container\"]',
+ containerMaxWidthPx: 1200,
+ containerMinWidthPx: 1024,
+ paddingXpx: 24,
+ },
+ },
+ });
+ const result = validateContractStructure(contract, schema);
+ assert.equal(result.ok, true);
+});
diff --git a/packages/interfacectl-validator/test/evaluate-contract.test.mjs b/packages/interfacectl-validator/test/evaluate-contract.test.mjs
index eee74ef..0e2a2f6 100644
--- a/packages/interfacectl-validator/test/evaluate-contract.test.mjs
+++ b/packages/interfacectl-validator/test/evaluate-contract.test.mjs
@@ -389,3 +389,155 @@ test("mixed allowed values pass exact matching through one path", () => {
const colorViolations = report.violations.filter((v) => v.type === "color-not-allowed");
assert.equal(colorViolations.length, 0);
});
+
+test("reports pageFrame min-width mismatch when containerMinWidthPx is set", () => {
+ const contract = {
+ ...baseContract,
+ surfaces: [
+ {
+ id: "surface-pageframe-minwidth",
+ displayName: "Surface PageFrame Min Width",
+ type: "web",
+ requiredSections: ["main.hero"],
+ allowedFonts: ["var(--font-minwidth)"],
+ layout: {
+ maxContentWidth: 1200,
+ requiredContainers: [],
+ pageFrame: {
+ containerSelector: '[data-contract=\"page-container\"]',
+ containerMaxWidthPx: 1200,
+ containerMinWidthPx: 1024,
+ paddingXpx: 24,
+ enforcement: "strict",
+ },
+ },
+ },
+ ],
+ };
+
+ const descriptor = {
+ surfaceId: "surface-pageframe-minwidth",
+ sections: [{ id: "main.hero" }],
+ fonts: [{ value: "var(--font-minwidth)" }],
+ colors: [{ value: "var(--color-primary)" }],
+ layout: {
+ maxContentWidth: 1000,
+ containers: [],
+ pageFrame: {
+ containerSelector: '[data-contract=\"page-container\"]',
+ maxWidthPx: 1200,
+ minWidthPx: 960,
+ paddingLeftPx: 24,
+ paddingRightPx: 24,
+ source: "apps/surface/app/layout.tsx",
+ },
+ },
+ motion: [],
+ };
+
+ const report = evaluateSurfaceCompliance(contract, descriptor);
+ const violation = report.violations.find(
+ (item) => item.type === "layout-pageframe-minwidth-mismatch",
+ );
+ assert.ok(violation);
+ assert.equal(violation.details?.expected, 1024);
+ assert.equal(violation.details?.actual, 960);
+});
+
+test("reports non-deterministic min-width when clamp/calc is detected", () => {
+ const contract = {
+ ...baseContract,
+ surfaces: [
+ {
+ id: "surface-pageframe-minwidth-clamp",
+ displayName: "Surface PageFrame Min Width Clamp",
+ type: "web",
+ requiredSections: ["main.hero"],
+ allowedFonts: ["var(--font-minwidth-clamp)"],
+ layout: {
+ maxContentWidth: 1200,
+ requiredContainers: [],
+ pageFrame: {
+ containerSelector: '[data-contract=\"page-container\"]',
+ containerMaxWidthPx: 1200,
+ containerMinWidthPx: 1024,
+ paddingXpx: 24,
+ enforcement: "strict",
+ },
+ },
+ },
+ ],
+ };
+
+ const descriptor = {
+ surfaceId: "surface-pageframe-minwidth-clamp",
+ sections: [{ id: "main.hero" }],
+ fonts: [{ value: "var(--font-minwidth-clamp)" }],
+ colors: [{ value: "var(--color-primary)" }],
+ layout: {
+ maxContentWidth: 1000,
+ containers: [],
+ pageFrame: {
+ containerSelector: '[data-contract=\"page-container\"]',
+ maxWidthPx: 1200,
+ minWidthPx: null,
+ minWidthHasClampCalc: true,
+ paddingLeftPx: 24,
+ paddingRightPx: 24,
+ source: "apps/surface/app/globals.css",
+ },
+ },
+ motion: [],
+ };
+
+ const report = evaluateSurfaceCompliance(contract, descriptor);
+ const violation = report.violations.find(
+ (item) => item.type === "layout-pageframe-non-deterministic-value",
+ );
+ assert.ok(violation);
+ assert.equal(violation.details?.property, "min-width");
+ assert.equal(violation.details?.expected, 1024);
+});
+
+test("reports selector unsupported even when pageFrame descriptor is unavailable", () => {
+ const contract = {
+ ...baseContract,
+ surfaces: [
+ {
+ id: "surface-pageframe-selector",
+ displayName: "Surface PageFrame Selector",
+ type: "web",
+ requiredSections: ["main.hero"],
+ allowedFonts: ["var(--font-selector)"],
+ layout: {
+ maxContentWidth: 1200,
+ requiredContainers: [],
+ pageFrame: {
+ containerSelector: ".shell",
+ containerMaxWidthPx: 1200,
+ paddingXpx: 24,
+ enforcement: "strict",
+ },
+ },
+ },
+ ],
+ };
+
+ const descriptor = {
+ surfaceId: "surface-pageframe-selector",
+ sections: [{ id: "main.hero" }],
+ fonts: [{ value: "var(--font-selector)" }],
+ colors: [{ value: "var(--color-primary)" }],
+ layout: {
+ maxContentWidth: 1000,
+ containers: [],
+ },
+ motion: [],
+ };
+
+ const report = evaluateSurfaceCompliance(contract, descriptor);
+ const violation = report.violations.find(
+ (item) => item.type === "layout-pageframe-selector-unsupported",
+ );
+ assert.ok(violation);
+});