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
11 changes: 11 additions & 0 deletions docs/contract-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ The top-level **color** object is optional. When present it can include:
- **semantics**: Optional **roles** (accent, text, background, border) each with **enforcement** (`"off"`, `"warn"`, `"strict"`).
- **consistency**: Optional **acrossSurfaces** object with **enforcement** level and **signals** used for reporting.

## Optional: shell ownership boundary (new)

Add these when a shell provides global layout so generators cannot recreate it inside surfaces:

- **shell** (object):
- **owns** (array of strings): Shell-owned primitives (e.g. `nav`, `header`, `sidebar`, `authWrapper`, `appChrome`). Generation should not emit these inside surfaces.
- **contentSlot** (string): Identifier for the shell’s content mount point (frame name or DOM id).
- **surface.mustNotEmit** (array of strings, per-surface): Optional explicit ban list. If omitted, generators should default this to **shell.owns**.

Backwards compatibility: `shell` is optional; existing contracts remain valid. Generators and validators should default an undefined **mustNotEmit** to **shell.owns** when present.

## Deprecated fields

- **allowedColors** (per-surface): Deprecated. The schema still accepts it for compatibility. Prefer the top-level **color** policy with **sourceOfTruth** and **rawValues**. Migration: move per-surface color allowlists into **color.sourceOfTruth** (e.g. token namespaces) or **color.rawValues** (allowlist/denylist). The CLI emits a deprecation warning when allowedColors is present. Deprecated fields are accepted for compatibility but may be removed in a future major version.
Expand Down
44 changes: 43 additions & 1 deletion packages/interfacectl-cli/src/descriptors/static-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type SurfaceMotionDescriptor,
type SurfaceSectionDescriptor,
type PageFrameLayoutDescriptor,
type SurfacePrimitiveDescriptor,
} from "@surfaces/interfacectl-validator";

const SECTION_ATTRIBUTE_REGEX =
Expand Down Expand Up @@ -70,6 +71,15 @@ const DURATION_DECL_REGEX = /(animation|transition)-duration\s*:\s*([^;]+);/gi;
const TIMING_DECL_REGEX =
/(animation|transition)-timing-function\s*:\s*([^;]+);/gi;

const NAV_REGEX = /<nav\b/gi;
const HEADER_REGEX = /<header\b/gi;
const ASIDE_REGEX = /<aside\b/gi;
const PRIMITIVE_PATTERNS: Array<{ role: string; regex: RegExp }> = [
{ role: "nav", regex: NAV_REGEX },
{ role: "header", regex: HEADER_REGEX },
{ role: "sidebar", regex: ASIDE_REGEX },
];

export interface DescriptorIssue {
surfaceId?: string;
code: string;
Expand Down Expand Up @@ -243,13 +253,16 @@ async function extractSurfaceDescriptor(
});
}

const primitives = await extractPrimitives(sectionFiles, workspaceRoot, fileContentCache);

const structuralSurfaceDescriptor: SurfaceDescriptor = {
surfaceId,
sections,
fonts,
colors,
layout,
motion,
primitives,
};

return { descriptor: structuralSurfaceDescriptor, warnings, errors };
Expand Down Expand Up @@ -916,6 +929,36 @@ function collectColorsFromContent(
}
}

async function extractPrimitives(
sectionFiles: string[],
workspaceRoot: string,
fileContentCache: Map<string, string>,
): Promise<SurfacePrimitiveDescriptor[]> {
const counts = new Map<string, { count: number; sources: Set<string> }>();

for (const filePath of sectionFiles) {
const content = await readFileCached(filePath, fileContentCache);
for (const { role, regex } of PRIMITIVE_PATTERNS) {
regex.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const entry = counts.get(role) ?? { count: 0, sources: new Set<string>() };
entry.count += 1;
entry.sources.add(path.relative(workspaceRoot, filePath));
counts.set(role, entry);
}
}
}

return [...counts.entries()]
.map(([role, { count, sources }]) => ({
role,
count,
sources: [...sources],
}))
.sort((a, b) => a.role.localeCompare(b.role));
}

function parseColorValue(value: string): string[] {
const colors: string[] = [];
const trimmed = value.trim();
Expand Down Expand Up @@ -1166,4 +1209,3 @@ function isTimingFunction(token: string): boolean {
function toMotionKey(durationMs: number, timing: string): string {
return `${durationMs}:${timing}`;
}

6 changes: 6 additions & 0 deletions packages/interfacectl-extractor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ export interface ExtractedContractFields {
authAware: boolean;
}

export interface ExtractedPrimitives {
role: string;
count: number;
sources?: string[];
}

export interface ExtractionWarning {
code: string;
message: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function GET() {
return new Response("OK");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return <html lang="en"><body>{children}</body></html>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function Page() {
return (
<div>
<nav data-test="primary-nav">Navigation</nav>
<main data-contract-section="main.hero">Hello</main>
</div>
);
}
20 changes: 20 additions & 0 deletions packages/interfacectl-extractor/test/primitives.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { extractContractFromNextApp } from "../dist/index.js";
import path from "node:path";

const fixturesRoot = path.join(process.cwd(), "test", "fixtures");
const fixture = path.join(fixturesRoot, "simple-app");

test("extracts nav/header primitives from section files", async () => {
const { report } = await extractContractFromNextApp({
appRoot: fixture,
surfaceId: "runs",
});

const primitives = report.extracted.primitives;
assert.ok(Array.isArray(primitives));
const nav = primitives.find((p) => p.role === "nav");
assert.ok(nav);
assert.equal(nav.count, 1);
});
2 changes: 1 addition & 1 deletion packages/interfacectl-validator/dist/index.d.ts.map

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

22 changes: 22 additions & 0 deletions packages/interfacectl-validator/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,28 @@ export function evaluateSurfaceCompliance(contract, descriptor) {
}
}
}
// Shell-owned primitives check
const banList = new Set((surface.mustNotEmit && surface.mustNotEmit.length > 0
? surface.mustNotEmit
: contract.shell?.owns ?? []));
if (banList.size > 0 && descriptor.primitives) {
for (const primitive of descriptor.primitives) {
if (banList.has(primitive.role) && primitive.count > 0) {
violations.push({
surfaceId: descriptor.surfaceId,
type: "shell-owned-primitive-emitted",
message: `Primitive "${primitive.role}" is shell-owned and must not be emitted. Count: ${primitive.count}.`,
details: {
role: primitive.role,
count: primitive.count,
sources: primitive.sources,
banList: [...banList],
jsonPointer: "/shell/owns",
},
});
}
}
}
// New color policy validation
if (contract.color) {
validateColorPolicy(contract, descriptor, violations);
Expand Down
Loading
Loading