From c21c62e4d721da9b6fa7486f4e6121c36b81c70e Mon Sep 17 00:00:00 2001 From: Mike Long Date: Wed, 18 Feb 2026 14:45:56 -0800 Subject: [PATCH] Add pageFrame containerMinWidthPx CI validation --- docs/contract-baseline.md | 1 + .../pageframe-minwidth-ci-enforcement.md | 64 ++++++++ .../dist/commands/validate.js | 6 + .../dist/descriptors/static-analysis.d.ts.map | 2 +- .../dist/descriptors/static-analysis.js | 89 +++++++++- .../interfacectl-cli/docs/layout-pageframe.md | 35 ++-- .../interfacectl-cli/src/commands/validate.ts | 6 + .../src/descriptors/static-analysis.ts | 98 ++++++++++- .../pageframe-static/contract.minwidth.json | 53 ++++++ .../contract.unsupported-selector.json | 52 ++++++ .../apps/test-surface/app/globals.css | 20 +++ .../apps/test-surface/app/layout.tsx | 9 ++ .../apps/test-surface/app/page.tsx | 7 + .../apps/test-surface/app/globals.css | 14 ++ .../apps/test-surface/app/layout.tsx | 12 ++ .../apps/test-surface/app/page.tsx | 7 + .../apps/test-surface/app/globals.css | 14 ++ .../apps/test-surface/app/layout.tsx | 12 ++ .../apps/test-surface/app/page.tsx | 7 + .../test/pageframe-static.test.mjs | 68 +++++++- .../test/violation-classifier.test.mjs | 1 + .../dist/index.d.ts.map | 2 +- packages/interfacectl-validator/dist/index.js | 58 ++++++- .../schema/web.surface.contract.schema.json | 4 + .../interfacectl-validator/dist/types.d.ts | 5 +- .../dist/types.d.ts.map | 2 +- packages/interfacectl-validator/src/index.ts | 59 ++++++- .../schema/web.surface.contract.schema.json | 4 + packages/interfacectl-validator/src/types.ts | 4 + .../test/contract-structure.test.mjs | 17 ++ .../test/evaluate-contract.test.mjs | 152 ++++++++++++++++++ 31 files changed, 854 insertions(+), 30 deletions(-) create mode 100644 docs/plans/pageframe-minwidth-ci-enforcement.md create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/contract.minwidth.json create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/contract.unsupported-selector.json create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/globals.css create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-clamp/apps/test-surface/app/page.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/globals.css create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-fail/apps/test-surface/app/page.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/globals.css create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/layout.tsx create mode 100644 packages/interfacectl-cli/test/fixtures/pageframe-static/minwidth-pass/apps/test-surface/app/page.tsx 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); +});