Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/contract-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
64 changes: 64 additions & 0 deletions docs/plans/pageframe-minwidth-ci-enforcement.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions packages/interfacectl-cli/dist/commands/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

89 changes: 86 additions & 3 deletions packages/interfacectl-cli/dist/descriptors/static-analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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/**",
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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,
};
}
Expand Down
Loading