diff --git a/docs/contract-baseline.md b/docs/contract-baseline.md index 880c5e6..4e32005 100644 --- a/docs/contract-baseline.md +++ b/docs/contract-baseline.md @@ -12,6 +12,7 @@ Every contract must include: - `sections` (array, at least one item): section catalog. - `constraints` (object): currently requires `motion` with `allowedDurationsMs` and `allowedTimingFunctions`. - `color` (object): required unified color policy with `policy` and `allowedValues`. +- `tokens` (object, optional): allowlisted UI token categories for typography, layout, and motion. ## Per-surface required fields @@ -47,6 +48,14 @@ Optional per-surface icon policy (web surfaces): - `icons.policy`: one of `"off"`, `"warn"`, `"strict"`. - `icons.allowedSources`: array of allowed icon source libraries (for example `lucide-react`, `@heroicons/react/24/outline`). +Optional top-level UI token policies: + +- `tokens.typography`: allowlisted font and type-scale tokens used in font-family, font-size, line-height, and letter-spacing declarations. +- `tokens.layout`: allowlisted spacing, width, sizing, and radius tokens used in layout declarations. +- `tokens.motion`: allowlisted duration and easing tokens used in transition and animation declarations. +- `tokens..allowedTokens`: canonical token refs used for enforcement. +- `tokens..tokenMetadata` (optional): generated metadata for canonical tokens, including normalized values, observed attributes, and collapsed aliases. + ## Section required fields Each item in `sections` must include: diff --git a/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map b/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map index 71437c3..fffc03a 100644 --- a/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/generate-contract.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"generate-contract.d.ts","sourceRoot":"","sources":["../../src/commands/generate-contract.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,MAAM,CAAC,CA4FjB"} \ No newline at end of file +{"version":3,"file":"generate-contract.d.ts","sourceRoot":"","sources":["../../src/commands/generate-contract.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAID,wBAAsB,0BAA0B,CAC9C,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,MAAM,CAAC,CAsGjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/generate-contract.js b/packages/interfacectl-cli/dist/commands/generate-contract.js index 66ea1bc..834c128 100644 --- a/packages/interfacectl-cli/dist/commands/generate-contract.js +++ b/packages/interfacectl-cli/dist/commands/generate-contract.js @@ -5,6 +5,7 @@ import { getBundledContractSchema, validateContractStructure, } from "@surfaces/ import { seedColorPolicyFromObservedDescriptors } from "../utils/color-policy-seeding.js"; import { seedChromePolicyFromObservedDescriptors } from "../utils/chrome-policy-seeding.js"; import { seedIconPolicyFromObservedDescriptors } from "../utils/icon-policy-seeding.js"; +import { seedObservedUiContract } from "../utils/observed-ui-seeding.js"; const DEFAULT_OUT_DIR = "contracts/generated"; export async function runGenerateContractCommand(options) { const cwd = process.cwd(); @@ -23,12 +24,18 @@ export async function runGenerateContractCommand(options) { appRoot, surfaceId, }); - const seeded = await seedColorPolicyFromObservedDescriptors({ + const uiSeeded = await seedObservedUiContract({ workspaceRoot: cwd, appRoot, surfaceId, contract: extractedContract, }); + const seeded = await seedColorPolicyFromObservedDescriptors({ + workspaceRoot: cwd, + appRoot, + surfaceId, + contract: uiSeeded.contract, + }); const iconSeeded = await seedIconPolicyFromObservedDescriptors({ workspaceRoot: cwd, appRoot, @@ -42,10 +49,12 @@ export async function runGenerateContractCommand(options) { contract: iconSeeded.contract, }); const contract = chromeSeeded.contract; + const filteredReportWarnings = report.warnings.filter((warning) => !uiSeeded.resolvedPlaceholderWarnings.includes(warning.code)); const reportWithSeedWarnings = { ...report, warnings: [ - ...report.warnings, + ...filteredReportWarnings, + ...uiSeeded.warnings, ...seeded.warnings, ...iconSeeded.warnings, ...chromeSeeded.warnings, diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js index 3d00846..c38019e 100644 --- a/packages/interfacectl-cli/dist/commands/validate.js +++ b/packages/interfacectl-cli/dist/commands/validate.js @@ -314,6 +314,7 @@ function mapViolationsToFindings(summary) { "font-not-allowed": "font.disallowed", "color-not-allowed": "color.disallowed", "icon-source-not-allowed": "icon.source-disallowed", + "token-not-allowed": "token.disallowed", "layout-width-exceeded": "layout.width-exceeded", "layout-width-undetermined": "layout.width-undetermined", "layout-container-missing": "layout.container-missing", @@ -329,6 +330,14 @@ function mapViolationsToFindings(summary) { "landing-pattern-section-order": "landing.pattern.section-order", "landing-pattern-section-nested": "landing.pattern.section-nested", "landing-pattern-background-mode": "landing.pattern.background-mode", + "landing-pattern-marketing-layout-missing": "landing.pattern.marketing-layout-missing", + "landing-pattern-hero-container-mode": "landing.pattern.hero-container-mode", + "landing-pattern-hero-visual-placement": "landing.pattern.hero-visual-placement", + "landing-pattern-section-divider-mode": "landing.pattern.section-divider-mode", + "landing-pattern-section-spacing-profile": "landing.pattern.section-spacing-profile", + "marketing-typography-profile-missing": "marketing.typography.profile-missing", + "marketing-typography-role-missing": "marketing.typography.role-missing", + "marketing-typography-role-token": "marketing.typography.role-token", "motion-duration-not-allowed": "motion.duration", "motion-timing-not-allowed": "motion.timing", "descriptor-flows-missing": "descriptor.flows.missing", @@ -391,6 +400,49 @@ function mapViolationsToFindings(summary) { } break; } + case "token-not-allowed": { + finding.expected = Array.isArray(details.allowedTokens) + ? details.allowedTokens + : undefined; + finding.found = { + category: details.tokenCategory, + token: details.token, + canonicalToken: details.canonicalToken, + normalizedValue: details.normalizedValue, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "landing-pattern-marketing-layout-missing": + case "landing-pattern-hero-container-mode": + case "landing-pattern-hero-visual-placement": + case "landing-pattern-section-divider-mode": + case "landing-pattern-section-spacing-profile": + case "marketing-typography-profile-missing": + case "marketing-typography-role-missing": + case "marketing-typography-role-token": { + finding.expected = + details.expectedProfileId ?? + details.expectedHeroContainerMode ?? + details.expectedHeroVisualPlacement ?? + details.expectedSectionDividerMode ?? + details.expectedSectionSpacingProfile ?? + details.allowedTokens; + finding.found = + details.actualProfileId ?? + details.actualHeroContainerMode ?? + details.actualHeroVisualPlacement ?? + details.actualSectionDividerMode ?? + details.actualSectionSpacingProfile ?? + details.token ?? + details.role; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } case "descriptor-flows-missing": { finding.expected = details.requiredFlowIds; finding.found = null; 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 c871883..a6c148d 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,EAIL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EAUvB,MAAM,kCAAkC,CAAC;AAyI1C,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,EAIL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EAevB,MAAM,kCAAkC,CAAC;AA0K1C,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 ff20b2c..7cbb7cd 100644 --- a/packages/interfacectl-cli/dist/descriptors/static-analysis.js +++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.js @@ -1,8 +1,16 @@ import path from "node:path"; import { readFile, stat } from "node:fs/promises"; import { globby } from "globby"; +import { collectTokenDefinitionsFromContent, normalizeObservedTokens, } from "../utils/token-normalization.js"; const SECTION_ATTRIBUTE_REGEX = /data-(?:contract-)?section\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; const CONTAINER_ATTRIBUTE_REGEX = /data-contract-container\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const MARKETING_LAYOUT_PROFILE_ATTRIBUTE_REGEX = /data-contract-marketing-layout-profile\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const HERO_CONTAINER_MODE_ATTRIBUTE_REGEX = /data-contract-hero-container-mode\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const HERO_VISUAL_PLACEMENT_ATTRIBUTE_REGEX = /data-contract-hero-visual-placement\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const SECTION_DIVIDER_MODE_ATTRIBUTE_REGEX = /data-contract-section-divider-mode\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const SECTION_SPACING_PROFILE_ATTRIBUTE_REGEX = /data-contract-section-spacing-profile\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const TYPOGRAPHY_PROFILE_ATTRIBUTE_REGEX = /data-contract-typography-profile\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const COPY_ROLE_ATTRIBUTE_REGEX = /data-contract-copy-role\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; const CONTRACT_CONTAINER_TOKEN = "contract-container"; const PAGE_CONTAINER_ATTRIBUTE_REGEX = /data-contract\s*=\s*(?:"page-container"|'page-container'|{`page-container`}|{\s*["'`]page-container["'`]\s*})/g; const CHROME_IGNORE_ATTRIBUTE_REGEX = /data-contract-chrome-ignore\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*})/; @@ -58,6 +66,13 @@ const MOTION_TIMING_VAR_REGEX = /--contract-motion-timing\s*:\s*([a-z-]+)\s*;/i; const TRANSITION_DECL_REGEX = /transition[^:]*:\s*([^;]+);/gi; const DURATION_DECL_REGEX = /(animation|transition)-duration\s*:\s*([^;]+);/gi; const TIMING_DECL_REGEX = /(animation|transition)-timing-function\s*:\s*([^;]+);/gi; +const GENERIC_VAR_REGEX = /var\((--[a-z0-9-]+)\)/gi; +const TYPOGRAPHY_TOKEN_DECL_REGEX = /(? { + if (left.score !== right.score) { + return right.score - left.score; + } + const leftRank = getLandingPageRank(left.filePath); + const rightRank = getLandingPageRank(right.filePath); + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + return left.filePath.localeCompare(right.filePath); + })[0]; + if (bestCandidate && bestCandidate.score > 0) { + return bestCandidate.filePath; + } const candidates = filePaths.filter((filePath) => filePath.endsWith(`${path.sep}page.tsx`)); if (candidates.length === 0) { return undefined; @@ -1404,6 +1471,144 @@ async function extractPrimitives(sectionFiles, workspaceRoot, fileContentCache) })) .sort((a, b) => a.role.localeCompare(b.role)); } +async function extractTokenUsage(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache) { + const typography = new Map(); + const layout = new Map(); + const motion = new Map(); + const tokenDefinitions = new Map(); + for (const filePath of [...cssFilePaths, ...sectionFiles]) { + const content = await readFileCached(filePath, fileContentCache); + const source = path.relative(workspaceRoot, filePath); + const isCssSource = /\.css$/i.test(filePath); + collectTokenDefinitionsFromContent(content, source, tokenDefinitions); + collectTokenDescriptorsFromContent(content, source, TYPOGRAPHY_TOKEN_DECL_REGEX, typography); + collectTokenDescriptorsFromContent(content, source, LAYOUT_TOKEN_DECL_REGEX, layout); + collectTokenDescriptorsFromContent(content, source, MOTION_TOKEN_DECL_REGEX, motion); + if (!isCssSource) { + collectTokenDescriptorsFromMatches(content, source, JSX_TYPOGRAPHY_TOKEN_DECL_REGEX, typography); + collectTokenDescriptorsFromMatches(content, source, JSX_LAYOUT_TOKEN_DECL_REGEX, layout); + collectTokenDescriptorsFromMatches(content, source, JSX_MOTION_TOKEN_DECL_REGEX, motion); + } + } + const typographyNormalized = normalizeObservedTokens("typography", typography, tokenDefinitions); + const layoutNormalized = normalizeObservedTokens("layout", layout, tokenDefinitions); + const motionNormalized = normalizeObservedTokens("motion", motion, tokenDefinitions); + return { + tokenUsage: { + typography: typographyNormalized.tokens, + layout: layoutNormalized.tokens, + motion: motionNormalized.tokens, + }, + warnings: [ + ...typographyNormalized.warnings, + ...layoutNormalized.warnings, + ...motionNormalized.warnings, + ].map((warning) => ({ + code: warning.code, + message: warning.message, + location: warning.location, + })), + }; +} +function collectTokenDescriptorsFromContent(content, source, declarationRegex, tokenValues) { + collectTokenDescriptorsFromMatches(content, source, declarationRegex, tokenValues); +} +async function extractMarketingTypography(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache) { + const landingPagePath = await selectLandingPageFile(sectionFiles, fileContentCache); + if (!landingPagePath) { + return { typography: undefined, warnings: [] }; + } + const tokenDefinitions = new Map(); + for (const filePath of [...cssFilePaths, ...sectionFiles]) { + const content = await readFileCached(filePath, fileContentCache); + collectTokenDefinitionsFromContent(content, path.relative(workspaceRoot, filePath), tokenDefinitions); + } + const content = await readFileCached(landingPagePath, fileContentCache); + const source = path.relative(workspaceRoot, landingPagePath); + const profileId = extractAttributeValue(content, TYPOGRAPHY_PROFILE_ATTRIBUTE_REGEX); + const roleMaps = new Map(); + const warnings = []; + TAG_REGEX.lastIndex = 0; + let match; + while ((match = TAG_REGEX.exec(content)) !== null) { + const tag = match[0]; + if (tag.startsWith(" ({ + code: warning.code, + message: warning.message, + location: warning.location, + }))); + } + if (!profileId && roles.length === 0) { + return { typography: undefined, warnings }; + } + return { + typography: { + profileId, + roles: roles.sort((left, right) => left.role.localeCompare(right.role)), + source, + }, + warnings, + }; +} +function collectTokenDescriptorsFromMatches(content, source, declarationRegex, tokenValues) { + declarationRegex.lastIndex = 0; + let match; + while ((match = declarationRegex.exec(content)) !== null) { + const attribute = normalizeTokenAttribute(match[1] ?? ""); + const value = match[2] ?? ""; + GENERIC_VAR_REGEX.lastIndex = 0; + let variableMatch; + while ((variableMatch = GENERIC_VAR_REGEX.exec(value)) !== null) { + const token = `var(${variableMatch[1]})`; + if (!tokenValues.has(token)) { + tokenValues.set(token, { + observedValue: token, + source, + attributes: new Set(attribute ? [attribute] : []), + }); + continue; + } + const existing = tokenValues.get(token); + if (existing && attribute) { + existing.attributes.add(attribute); + } + } + } +} +function normalizeTokenAttribute(attribute) { + return attribute + .trim() + .replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`) + .toLowerCase(); +} async function extractIconSources(surfaceRoot, workspaceRoot, fileContentCache, surfaceId) { const warnings = []; const iconSources = new Map(); diff --git a/packages/interfacectl-cli/dist/utils/observed-ui-seeding.d.ts b/packages/interfacectl-cli/dist/utils/observed-ui-seeding.d.ts new file mode 100644 index 0000000..9daba84 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/observed-ui-seeding.d.ts @@ -0,0 +1,17 @@ +import type { InterfaceContract } from "@surfaces/interfacectl-validator"; +export interface ObservedUiSeedingInput { + workspaceRoot: string; + appRoot: string; + surfaceId: string; + contract: InterfaceContract; +} +export interface ObservedUiSeedingResult { + contract: InterfaceContract; + warnings: Array<{ + code: string; + message: string; + }>; + resolvedPlaceholderWarnings: string[]; +} +export declare function seedObservedUiContract(input: ObservedUiSeedingInput): Promise; +//# sourceMappingURL=observed-ui-seeding.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/observed-ui-seeding.d.ts.map b/packages/interfacectl-cli/dist/utils/observed-ui-seeding.d.ts.map new file mode 100644 index 0000000..c4bdb21 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/observed-ui-seeding.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"observed-ui-seeding.d.ts","sourceRoot":"","sources":["../../src/utils/observed-ui-seeding.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EAKlB,MAAM,kCAAkC,CAAC;AAG1C,MAAM,WAAW,sBAAsB;IACrC,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,iBAAiB,CAAC;CAC7B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,2BAA2B,EAAE,MAAM,EAAE,CAAC;CACvC;AAuFD,wBAAsB,sBAAsB,CAC1C,KAAK,EAAE,sBAAsB,GAC5B,OAAO,CAAC,uBAAuB,CAAC,CAkKlC"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/observed-ui-seeding.js b/packages/interfacectl-cli/dist/utils/observed-ui-seeding.js new file mode 100644 index 0000000..b35ec1f --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/observed-ui-seeding.js @@ -0,0 +1,186 @@ +import { collectSurfaceDescriptors } from "../descriptors/static-analysis.js"; +function uniqueSorted(values) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b)); +} +function uniqueSortedNumbers(values) { + return [...new Set(values.filter((value) => Number.isFinite(value)))].sort((a, b) => a - b); +} +function buildTokenPolicy(existingPolicy, tokens, category, warnings) { + if (tokens.length === 0) { + return { + policy: existingPolicy?.policy ?? "warn", + allowedTokens: existingPolicy?.allowedTokens ?? [], + tokenMetadata: existingPolicy?.tokenMetadata, + }; + } + const metadata = new Map(); + let unresolvedCount = 0; + for (const token of tokens) { + const canonicalToken = token.value.trim(); + const observedToken = (token.observedValue ?? token.value).trim(); + if (!canonicalToken) { + continue; + } + if (!token.normalizedValue) { + unresolvedCount += 1; + } + if (!metadata.has(canonicalToken)) { + metadata.set(canonicalToken, { + normalizedValue: token.normalizedValue ?? observedToken, + attributes: new Set(token.attributes ?? []), + aliases: new Set(), + }); + } + const entry = metadata.get(canonicalToken); + if (!entry) + continue; + for (const attribute of token.attributes ?? []) { + entry.attributes.add(attribute); + } + if (observedToken && observedToken !== canonicalToken) { + entry.aliases.add(observedToken); + } + if (!entry.normalizedValue && token.normalizedValue) { + entry.normalizedValue = token.normalizedValue; + } + } + if (unresolvedCount > 0) { + warnings.push({ + code: `ui-seed.${category}.normalization-skipped`, + message: `${category} token normalization was skipped for ${unresolvedCount} observed token` + + `${unresolvedCount === 1 ? "" : "s"} because deterministic token definitions were unavailable.`, + }); + } + const tokenMetadata = [...metadata.entries()] + .map(([token, entry]) => ({ + token, + normalizedValue: entry.normalizedValue, + attributes: [...entry.attributes].sort((a, b) => a.localeCompare(b)), + aliases: [...entry.aliases].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.token.localeCompare(b.token)); + return { + policy: existingPolicy?.policy ?? "warn", + allowedTokens: tokenMetadata.map((entry) => entry.token), + tokenMetadata, + }; +} +export async function seedObservedUiContract(input) { + const warnings = []; + const resolvedPlaceholderWarnings = []; + const surfaceRootMap = new Map([[input.surfaceId, input.appRoot]]); + const descriptorResult = await collectSurfaceDescriptors({ + workspaceRoot: input.workspaceRoot, + contract: input.contract, + surfaceFilters: new Set([input.surfaceId]), + surfaceRootMap, + }); + for (const warning of descriptorResult.warnings) { + warnings.push({ + code: `ui-seed.${warning.code}`, + message: `Descriptor warning during UI seed: ${warning.message}`, + }); + } + for (const error of descriptorResult.errors) { + warnings.push({ + code: `ui-seed.${error.code}`, + message: `Descriptor error during UI seed: ${error.message}`, + }); + } + const descriptor = descriptorResult.descriptors.find((entry) => entry.surfaceId === input.surfaceId); + const currentSurface = input.contract.surfaces.find((surface) => surface.id === input.surfaceId); + if (!descriptor || !currentSurface) { + return { + contract: input.contract, + warnings, + resolvedPlaceholderWarnings, + }; + } + const discoveredFonts = uniqueSorted(descriptor.fonts.map((font) => font.value)); + const discoveredDurations = uniqueSortedNumbers(descriptor.motion.map((motion) => motion.durationMs)); + const discoveredTimingFunctions = uniqueSorted(descriptor.motion.map((motion) => motion.timingFunction)); + const discoveredTypographyTokens = descriptor.tokenUsage?.typography ?? []; + const discoveredLayoutTokens = descriptor.tokenUsage?.layout ?? []; + const discoveredMotionTokens = descriptor.tokenUsage?.motion ?? []; + if (discoveredFonts.length > 0) { + resolvedPlaceholderWarnings.push("allowedFonts.default"); + } + else { + warnings.push({ + code: "ui-seed.fonts.none-detected", + message: "allowedFonts was not seeded from descriptors because no fonts were detected.", + }); + } + if (descriptor.layout.maxContentWidth !== undefined && descriptor.layout.maxContentWidth !== null) { + resolvedPlaceholderWarnings.push("layout.default"); + } + else { + warnings.push({ + code: "ui-seed.layout.none-detected", + message: "layout.maxContentWidth was not seeded from descriptors because no deterministic width was detected.", + }); + } + if (discoveredDurations.length === 0 || discoveredTimingFunctions.length === 0) { + warnings.push({ + code: "ui-seed.motion.none-detected", + message: "Motion constraints were not fully seeded from descriptors because no motion declarations were detected.", + }); + } + const discoveredMarketingLayoutProfile = descriptor.layout.landingPattern?.marketingLayoutProfile; + const discoveredMarketingTypographyProfile = descriptor.marketingTypography?.profileId; + const contract = { + ...input.contract, + surfaces: input.contract.surfaces.map((surface) => { + if (surface.id !== input.surfaceId) { + return surface; + } + const seededLandingPattern = surface.layout.landingPattern ?? discoveredMarketingLayoutProfile + ? { + policy: surface.layout.landingPattern?.policy ?? "warn", + ...surface.layout.landingPattern, + marketingLayoutProfile: discoveredMarketingLayoutProfile ?? + surface.layout.landingPattern?.marketingLayoutProfile, + } + : undefined; + return { + ...surface, + marketingTypographyProfile: discoveredMarketingTypographyProfile ?? + surface.marketingTypographyProfile, + allowedFonts: discoveredFonts.length > 0 ? discoveredFonts : surface.allowedFonts, + layout: { + ...surface.layout, + maxContentWidth: descriptor.layout.maxContentWidth ?? surface.layout.maxContentWidth, + landingPattern: seededLandingPattern, + }, + }; + }), + constraints: { + ...input.contract.constraints, + motion: { + ...input.contract.constraints.motion, + allowedDurationsMs: discoveredDurations.length > 0 + ? discoveredDurations + : input.contract.constraints.motion.allowedDurationsMs, + allowedTimingFunctions: discoveredTimingFunctions.length > 0 + ? discoveredTimingFunctions + : input.contract.constraints.motion.allowedTimingFunctions, + }, + }, + tokens: { + typography: { + ...buildTokenPolicy(input.contract.tokens?.typography, discoveredTypographyTokens, "typography", warnings), + }, + layout: { + ...buildTokenPolicy(input.contract.tokens?.layout, discoveredLayoutTokens, "layout", warnings), + }, + motion: { + ...buildTokenPolicy(input.contract.tokens?.motion, discoveredMotionTokens, "motion", warnings), + }, + }, + }; + return { + contract, + warnings, + resolvedPlaceholderWarnings, + }; +} diff --git a/packages/interfacectl-cli/dist/utils/token-normalization.d.ts b/packages/interfacectl-cli/dist/utils/token-normalization.d.ts new file mode 100644 index 0000000..1191237 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/token-normalization.d.ts @@ -0,0 +1,23 @@ +import { type SurfaceTokenDescriptor, type TokenCategory } from "@surfaces/interfacectl-validator"; +export interface TokenDefinition { + name: string; + rawValue: string; + source: string; +} +export interface RawObservedToken { + observedValue: string; + source: string; + attributes: Set; +} +export interface TokenNormalizationWarning { + code: string; + message: string; + location?: string; +} +export interface TokenNormalizationResult { + tokens: SurfaceTokenDescriptor[]; + warnings: TokenNormalizationWarning[]; +} +export declare function collectTokenDefinitionsFromContent(content: string, source: string, definitions: Map): void; +export declare function normalizeObservedTokens(category: TokenCategory, observedTokens: Map, definitions: Map): TokenNormalizationResult; +//# sourceMappingURL=token-normalization.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/token-normalization.d.ts.map b/packages/interfacectl-cli/dist/utils/token-normalization.d.ts.map new file mode 100644 index 0000000..c3374f7 --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/token-normalization.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"token-normalization.d.ts","sourceRoot":"","sources":["../../src/utils/token-normalization.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,sBAAsB,EAC3B,KAAK,aAAa,EACnB,MAAM,kCAAkC,CAAC;AAE1C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gBAAgB;IAC/B,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACzB;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,sBAAsB,EAAE,CAAC;IACjC,QAAQ,EAAE,yBAAyB,EAAE,CAAC;CACvC;AAsBD,wBAAgB,kCAAkC,CAChD,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,GACxC,IAAI,CAWN;AA8DD,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,aAAa,EACvB,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,EAC7C,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,GACxC,wBAAwB,CAuF1B"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/token-normalization.js b/packages/interfacectl-cli/dist/utils/token-normalization.js new file mode 100644 index 0000000..49be49e --- /dev/null +++ b/packages/interfacectl-cli/dist/utils/token-normalization.js @@ -0,0 +1,142 @@ +import { normalizeTokenLiteralValue, } from "@surfaces/interfacectl-validator"; +const TOKEN_DEFINITION_REGEX = /(^|[;{\s])(--[a-z0-9-]+)\s*:\s*([^;{}]+);/gim; +const TOKEN_REFERENCE_REGEX = /^var\((--[a-z0-9-]+)\)$/i; +function normalizeExpression(input) { + return input.trim().replace(/\s+/g, " "); +} +function parseTokenReference(input) { + const match = normalizeExpression(input).match(TOKEN_REFERENCE_REGEX); + return match?.[1] ?? null; +} +export function collectTokenDefinitionsFromContent(content, source, definitions) { + TOKEN_DEFINITION_REGEX.lastIndex = 0; + let match; + while ((match = TOKEN_DEFINITION_REGEX.exec(content)) !== null) { + const name = match[2]?.trim(); + const rawValue = match[3]?.trim(); + if (!name || !rawValue || definitions.has(name)) { + continue; + } + definitions.set(name, { name, rawValue, source }); + } +} +function resolveTokenDefinition(tokenName, category, definitions, trail = new Set()) { + if (trail.has(tokenName)) { + return { + canonicalToken: `var(${tokenName})`, + aliasChainDepth: trail.size, + unresolvedReason: "cyclic token alias definition", + }; + } + const definition = definitions.get(tokenName); + if (!definition) { + return { + canonicalToken: `var(${tokenName})`, + aliasChainDepth: trail.size, + unresolvedReason: "missing custom property definition", + }; + } + const aliasTarget = parseTokenReference(definition.rawValue); + if (aliasTarget) { + const nextTrail = new Set(trail); + nextTrail.add(tokenName); + const resolved = resolveTokenDefinition(aliasTarget, category, definitions, nextTrail); + return { + canonicalToken: resolved.canonicalToken ?? `var(${aliasTarget})`, + normalizedValue: resolved.normalizedValue, + aliasChainDepth: resolved.aliasChainDepth + 1, + unresolvedReason: resolved.unresolvedReason, + location: resolved.location ?? definition.source, + }; + } + const normalizedValue = normalizeTokenLiteralValue(category, definition.rawValue); + if (!normalizedValue) { + return { + canonicalToken: `var(${tokenName})`, + aliasChainDepth: trail.size, + unresolvedReason: "unsupported token literal for deterministic normalization", + location: definition.source, + }; + } + return { + canonicalToken: `var(${tokenName})`, + normalizedValue, + aliasChainDepth: trail.size, + location: definition.source, + }; +} +export function normalizeObservedTokens(category, observedTokens, definitions) { + const warnings = []; + const resolved = [...observedTokens.values()].map((token) => { + const tokenName = parseTokenReference(token.observedValue); + if (!tokenName) { + return { + ...token, + value: token.observedValue, + normalizedValue: undefined, + aliasChainDepth: Number.MAX_SAFE_INTEGER, + }; + } + const resolution = resolveTokenDefinition(tokenName, category, definitions); + if (!resolution.normalizedValue) { + warnings.push({ + code: "token.normalization-skipped", + message: `Could not normalize ${category} token "${token.observedValue}" because ${resolution.unresolvedReason}.`, + location: resolution.location ?? token.source, + }); + return { + ...token, + value: resolution.canonicalToken ?? token.observedValue, + normalizedValue: undefined, + aliasChainDepth: Number.MAX_SAFE_INTEGER, + }; + } + return { + ...token, + value: resolution.canonicalToken ?? token.observedValue, + normalizedValue: resolution.normalizedValue, + aliasChainDepth: resolution.aliasChainDepth, + }; + }); + const canonicalByGroup = new Map(); + for (const token of resolved) { + if (!token.normalizedValue) { + continue; + } + const current = canonicalByGroup.get(token.normalizedValue); + if (!current) { + canonicalByGroup.set(token.normalizedValue, token.value); + continue; + } + const currentResolved = resolved.find((entry) => entry.value === current); + if (!currentResolved) { + canonicalByGroup.set(token.normalizedValue, token.value); + continue; + } + const candidateWins = token.aliasChainDepth < currentResolved.aliasChainDepth || + (token.aliasChainDepth === currentResolved.aliasChainDepth && + token.value.localeCompare(currentResolved.value) < 0); + if (candidateWins) { + canonicalByGroup.set(token.normalizedValue, token.value); + } + } + const tokens = resolved + .map((token) => { + const canonicalValue = token.normalizedValue + ? canonicalByGroup.get(token.normalizedValue) ?? token.observedValue + : token.observedValue; + return { + value: canonicalValue, + observedValue: token.observedValue, + source: token.source, + attributes: [...token.attributes].sort((a, b) => a.localeCompare(b)), + normalizedValue: token.normalizedValue, + }; + }) + .sort((a, b) => { + const left = a.observedValue ?? a.value; + const right = b.observedValue ?? b.value; + return left.localeCompare(right); + }); + return { tokens, warnings }; +} diff --git a/packages/interfacectl-cli/dist/utils/violation-classifier.d.ts.map b/packages/interfacectl-cli/dist/utils/violation-classifier.d.ts.map index 82194e9..2c8f583 100644 --- a/packages/interfacectl-cli/dist/utils/violation-classifier.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/violation-classifier.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"violation-classifier.d.ts","sourceRoot":"","sources":["../../src/utils/violation-classifier.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAChG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,MAAM,iBAAiB,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE1D;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,kBAAkB,GACvB,IAAI,GAAG,IAAI,CAiBb;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,iBAAiB,EAC3B,OAAO,EAAE,eAAe,GACvB,MAAM,CA4BR;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,SAAS,EAAE,GACnB,QAAQ,GAAG,IAAI,CAwBjB"} \ No newline at end of file +{"version":3,"file":"violation-classifier.d.ts","sourceRoot":"","sources":["../../src/utils/violation-classifier.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAChG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,MAAM,iBAAiB,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE1D;;;;GAIG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,kBAAkB,GACvB,IAAI,GAAG,IAAI,CAkBb;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,iBAAiB,EAC3B,OAAO,EAAE,eAAe,GACvB,MAAM,CA4BR;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,SAAS,EAAE,GACnB,QAAQ,GAAG,IAAI,CAwBjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/violation-classifier.js b/packages/interfacectl-cli/dist/utils/violation-classifier.js index 95a5b7c..c20ce56 100644 --- a/packages/interfacectl-cli/dist/utils/violation-classifier.js +++ b/packages/interfacectl-cli/dist/utils/violation-classifier.js @@ -9,6 +9,7 @@ export function classifyViolationType(type) { "font-not-allowed", "color-not-allowed", "icon-source-not-allowed", + "token-not-allowed", "motion-duration-not-allowed", "motion-timing-not-allowed", ]; diff --git a/packages/interfacectl-cli/src/commands/generate-contract.ts b/packages/interfacectl-cli/src/commands/generate-contract.ts index 2e1e922..a5cb9f7 100644 --- a/packages/interfacectl-cli/src/commands/generate-contract.ts +++ b/packages/interfacectl-cli/src/commands/generate-contract.ts @@ -12,6 +12,7 @@ import { import { seedColorPolicyFromObservedDescriptors } from "../utils/color-policy-seeding.js"; import { seedChromePolicyFromObservedDescriptors } from "../utils/chrome-policy-seeding.js"; import { seedIconPolicyFromObservedDescriptors } from "../utils/icon-policy-seeding.js"; +import { seedObservedUiContract } from "../utils/observed-ui-seeding.js"; export interface GenerateContractOptions { appRoot: string; @@ -47,12 +48,18 @@ export async function runGenerateContractCommand( surfaceId, }); - const seeded = await seedColorPolicyFromObservedDescriptors({ + const uiSeeded = await seedObservedUiContract({ workspaceRoot: cwd, appRoot, surfaceId, contract: extractedContract as unknown as InterfaceContract, }); + const seeded = await seedColorPolicyFromObservedDescriptors({ + workspaceRoot: cwd, + appRoot, + surfaceId, + contract: uiSeeded.contract, + }); const iconSeeded = await seedIconPolicyFromObservedDescriptors({ workspaceRoot: cwd, appRoot, @@ -66,10 +73,14 @@ export async function runGenerateContractCommand( contract: iconSeeded.contract, }); const contract = chromeSeeded.contract; + const filteredReportWarnings = report.warnings.filter( + (warning) => !uiSeeded.resolvedPlaceholderWarnings.includes(warning.code), + ); const reportWithSeedWarnings = { ...report, warnings: [ - ...report.warnings, + ...filteredReportWarnings, + ...uiSeeded.warnings, ...seeded.warnings, ...iconSeeded.warnings, ...chromeSeeded.warnings, diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts index f51d43d..f6223eb 100644 --- a/packages/interfacectl-cli/src/commands/validate.ts +++ b/packages/interfacectl-cli/src/commands/validate.ts @@ -466,6 +466,7 @@ function mapViolationsToFindings( "font-not-allowed": "font.disallowed", "color-not-allowed": "color.disallowed", "icon-source-not-allowed": "icon.source-disallowed", + "token-not-allowed": "token.disallowed", "layout-width-exceeded": "layout.width-exceeded", "layout-width-undetermined": "layout.width-undetermined", "layout-container-missing": "layout.container-missing", @@ -481,6 +482,14 @@ function mapViolationsToFindings( "landing-pattern-section-order": "landing.pattern.section-order", "landing-pattern-section-nested": "landing.pattern.section-nested", "landing-pattern-background-mode": "landing.pattern.background-mode", + "landing-pattern-marketing-layout-missing": "landing.pattern.marketing-layout-missing", + "landing-pattern-hero-container-mode": "landing.pattern.hero-container-mode", + "landing-pattern-hero-visual-placement": "landing.pattern.hero-visual-placement", + "landing-pattern-section-divider-mode": "landing.pattern.section-divider-mode", + "landing-pattern-section-spacing-profile": "landing.pattern.section-spacing-profile", + "marketing-typography-profile-missing": "marketing.typography.profile-missing", + "marketing-typography-role-missing": "marketing.typography.role-missing", + "marketing-typography-role-token": "marketing.typography.role-token", "motion-duration-not-allowed": "motion.duration", "motion-timing-not-allowed": "motion.timing", "descriptor-flows-missing": "descriptor.flows.missing", @@ -547,6 +556,49 @@ function mapViolationsToFindings( } break; } + case "token-not-allowed": { + finding.expected = Array.isArray(details.allowedTokens) + ? details.allowedTokens + : undefined; + finding.found = { + category: details.tokenCategory, + token: details.token, + canonicalToken: details.canonicalToken, + normalizedValue: details.normalizedValue, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "landing-pattern-marketing-layout-missing": + case "landing-pattern-hero-container-mode": + case "landing-pattern-hero-visual-placement": + case "landing-pattern-section-divider-mode": + case "landing-pattern-section-spacing-profile": + case "marketing-typography-profile-missing": + case "marketing-typography-role-missing": + case "marketing-typography-role-token": { + finding.expected = + details.expectedProfileId ?? + details.expectedHeroContainerMode ?? + details.expectedHeroVisualPlacement ?? + details.expectedSectionDividerMode ?? + details.expectedSectionSpacingProfile ?? + details.allowedTokens; + finding.found = + details.actualProfileId ?? + details.actualHeroContainerMode ?? + details.actualHeroVisualPlacement ?? + details.actualSectionDividerMode ?? + details.actualSectionSpacingProfile ?? + details.token ?? + details.role; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } case "descriptor-flows-missing": { finding.expected = details.requiredFlowIds; finding.found = null; diff --git a/packages/interfacectl-cli/src/descriptors/static-analysis.ts b/packages/interfacectl-cli/src/descriptors/static-analysis.ts index 572c085..16f4d45 100644 --- a/packages/interfacectl-cli/src/descriptors/static-analysis.ts +++ b/packages/interfacectl-cli/src/descriptors/static-analysis.ts @@ -16,13 +16,38 @@ import { type PageFrameLayoutDescriptor, type LandingPatternDescriptor, type SurfacePrimitiveDescriptor, + type SurfaceTokenDescriptor, + type SurfaceTokenUsage, + type SurfaceMarketingTypographyDescriptor, + type SurfaceMarketingTypographyRoleDescriptor, + type MarketingTypographyRole, } from "@surfaces/interfacectl-validator"; +import { + collectTokenDefinitionsFromContent, + normalizeObservedTokens, + type RawObservedToken, + type TokenDefinition, +} from "../utils/token-normalization.js"; const SECTION_ATTRIBUTE_REGEX = /data-(?:contract-)?section\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; const CONTAINER_ATTRIBUTE_REGEX = /data-contract-container\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const MARKETING_LAYOUT_PROFILE_ATTRIBUTE_REGEX = + /data-contract-marketing-layout-profile\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const HERO_CONTAINER_MODE_ATTRIBUTE_REGEX = + /data-contract-hero-container-mode\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const HERO_VISUAL_PLACEMENT_ATTRIBUTE_REGEX = + /data-contract-hero-visual-placement\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const SECTION_DIVIDER_MODE_ATTRIBUTE_REGEX = + /data-contract-section-divider-mode\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const SECTION_SPACING_PROFILE_ATTRIBUTE_REGEX = + /data-contract-section-spacing-profile\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const TYPOGRAPHY_PROFILE_ATTRIBUTE_REGEX = + /data-contract-typography-profile\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; +const COPY_ROLE_ATTRIBUTE_REGEX = + /data-contract-copy-role\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/g; const CONTRACT_CONTAINER_TOKEN = "contract-container"; const PAGE_CONTAINER_ATTRIBUTE_REGEX = /data-contract\s*=\s*(?:"page-container"|'page-container'|{`page-container`}|{\s*["'`]page-container["'`]\s*})/g; @@ -92,6 +117,19 @@ const TRANSITION_DECL_REGEX = /transition[^:]*:\s*([^;]+);/gi; const DURATION_DECL_REGEX = /(animation|transition)-duration\s*:\s*([^;]+);/gi; const TIMING_DECL_REGEX = /(animation|transition)-timing-function\s*:\s*([^;]+);/gi; +const GENERIC_VAR_REGEX = /var\((--[a-z0-9-]+)\)/gi; +const TYPOGRAPHY_TOKEN_DECL_REGEX = + /(?, ): Promise { - const landingPagePath = selectLandingPageFile(sectionFiles); + const landingPagePath = await selectLandingPageFile( + sectionFiles, + fileContentCache, + ); if (!landingPagePath) { return undefined; } @@ -1222,16 +1283,86 @@ async function extractLandingPattern( } } + const marketingLayoutProfile = extractAttributeValue( + content, + MARKETING_LAYOUT_PROFILE_ATTRIBUTE_REGEX, + ); + const heroContainerMode = extractAttributeValue( + content, + HERO_CONTAINER_MODE_ATTRIBUTE_REGEX, + ) as LandingPatternDescriptor["heroContainerMode"]; + const heroVisualPlacement = extractAttributeValue( + content, + HERO_VISUAL_PLACEMENT_ATTRIBUTE_REGEX, + ) as LandingPatternDescriptor["heroVisualPlacement"]; + const sectionDividerMode = extractAttributeValue( + content, + SECTION_DIVIDER_MODE_ATTRIBUTE_REGEX, + ) as LandingPatternDescriptor["sectionDividerMode"]; + const sectionSpacingProfile = extractAttributeValue( + content, + SECTION_SPACING_PROFILE_ATTRIBUTE_REGEX, + ) as LandingPatternDescriptor["sectionSpacingProfile"]; + return { sectionOrder, topLevelSections, nestedSections, pageBackgroundMode: extractPageBackgroundMode(content), + marketingLayoutProfile, + heroContainerMode, + heroVisualPlacement, + sectionDividerMode, + sectionSpacingProfile, source: path.relative(workspaceRoot, landingPagePath), }; } -function selectLandingPageFile(filePaths: string[]): string | undefined { +async function selectLandingPageFile( + filePaths: string[], + fileContentCache: Map, +): Promise { + const scoredCandidates: Array<{ filePath: string; score: number }> = []; + + for (const filePath of filePaths) { + const content = await readFileCached(filePath, fileContentCache); + const sectionMatches = [...content.matchAll(SECTION_ATTRIBUTE_REGEX)].length; + const copyRoleMatches = [...content.matchAll(COPY_ROLE_ATTRIBUTE_REGEX)].length; + const marketingLayoutSignals = + [...content.matchAll(MARKETING_LAYOUT_PROFILE_ATTRIBUTE_REGEX)].length + + [...content.matchAll(HERO_CONTAINER_MODE_ATTRIBUTE_REGEX)].length + + [...content.matchAll(HERO_VISUAL_PLACEMENT_ATTRIBUTE_REGEX)].length + + [...content.matchAll(SECTION_DIVIDER_MODE_ATTRIBUTE_REGEX)].length + + [...content.matchAll(SECTION_SPACING_PROFILE_ATTRIBUTE_REGEX)].length; + const typographySignals = + [...content.matchAll(TYPOGRAPHY_PROFILE_ATTRIBUTE_REGEX)].length; + + scoredCandidates.push({ + filePath, + score: + sectionMatches * 10 + + marketingLayoutSignals * 4 + + typographySignals * 4 + + copyRoleMatches, + }); + } + + const bestCandidate = [...scoredCandidates].sort((left, right) => { + if (left.score !== right.score) { + return right.score - left.score; + } + const leftRank = getLandingPageRank(left.filePath); + const rightRank = getLandingPageRank(right.filePath); + if (leftRank !== rightRank) { + return leftRank - rightRank; + } + return left.filePath.localeCompare(right.filePath); + })[0]; + + if (bestCandidate && bestCandidate.score > 0) { + return bestCandidate.filePath; + } + const candidates = filePaths.filter((filePath) => filePath.endsWith(`${path.sep}page.tsx`)); if (candidates.length === 0) { return undefined; @@ -1878,6 +2009,255 @@ async function extractPrimitives( .sort((a, b) => a.role.localeCompare(b.role)); } +async function extractTokenUsage( + cssFilePaths: string[], + sectionFiles: string[], + workspaceRoot: string, + fileContentCache: Map, +): Promise<{ + tokenUsage: SurfaceTokenUsage; + warnings: DescriptorIssue[]; +}> { + const typography = new Map(); + const layout = new Map(); + const motion = new Map(); + const tokenDefinitions = new Map(); + + for (const filePath of [...cssFilePaths, ...sectionFiles]) { + const content = await readFileCached(filePath, fileContentCache); + const source = path.relative(workspaceRoot, filePath); + const isCssSource = /\.css$/i.test(filePath); + collectTokenDefinitionsFromContent(content, source, tokenDefinitions); + collectTokenDescriptorsFromContent( + content, + source, + TYPOGRAPHY_TOKEN_DECL_REGEX, + typography, + ); + collectTokenDescriptorsFromContent( + content, + source, + LAYOUT_TOKEN_DECL_REGEX, + layout, + ); + collectTokenDescriptorsFromContent( + content, + source, + MOTION_TOKEN_DECL_REGEX, + motion, + ); + if (!isCssSource) { + collectTokenDescriptorsFromMatches( + content, + source, + JSX_TYPOGRAPHY_TOKEN_DECL_REGEX, + typography, + ); + collectTokenDescriptorsFromMatches( + content, + source, + JSX_LAYOUT_TOKEN_DECL_REGEX, + layout, + ); + collectTokenDescriptorsFromMatches( + content, + source, + JSX_MOTION_TOKEN_DECL_REGEX, + motion, + ); + } + } + + const typographyNormalized = normalizeObservedTokens( + "typography", + typography, + tokenDefinitions, + ); + const layoutNormalized = normalizeObservedTokens("layout", layout, tokenDefinitions); + const motionNormalized = normalizeObservedTokens("motion", motion, tokenDefinitions); + + return { + tokenUsage: { + typography: typographyNormalized.tokens, + layout: layoutNormalized.tokens, + motion: motionNormalized.tokens, + }, + warnings: [ + ...typographyNormalized.warnings, + ...layoutNormalized.warnings, + ...motionNormalized.warnings, + ].map((warning) => ({ + code: warning.code, + message: warning.message, + location: warning.location, + })), + }; +} + +function collectTokenDescriptorsFromContent( + content: string, + source: string, + declarationRegex: RegExp, + tokenValues: Map, +): void { + collectTokenDescriptorsFromMatches(content, source, declarationRegex, tokenValues); +} + +async function extractMarketingTypography( + cssFilePaths: string[], + sectionFiles: string[], + workspaceRoot: string, + fileContentCache: Map, +): Promise<{ + typography?: SurfaceMarketingTypographyDescriptor; + warnings: DescriptorIssue[]; +}> { + const landingPagePath = await selectLandingPageFile( + sectionFiles, + fileContentCache, + ); + if (!landingPagePath) { + return { typography: undefined, warnings: [] }; + } + + const tokenDefinitions = new Map(); + for (const filePath of [...cssFilePaths, ...sectionFiles]) { + const content = await readFileCached(filePath, fileContentCache); + collectTokenDefinitionsFromContent( + content, + path.relative(workspaceRoot, filePath), + tokenDefinitions, + ); + } + + const content = await readFileCached(landingPagePath, fileContentCache); + const source = path.relative(workspaceRoot, landingPagePath); + const profileId = extractAttributeValue(content, TYPOGRAPHY_PROFILE_ATTRIBUTE_REGEX); + const roleMaps = new Map< + MarketingTypographyRole, + { + tokens: Map; + source: string; + } + >(); + const warnings: DescriptorIssue[] = []; + + TAG_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = TAG_REGEX.exec(content)) !== null) { + const tag = match[0]; + if (tag.startsWith("(), + source, + }); + } + + const entry = roleMaps.get(role); + if (!entry) { + continue; + } + + collectTokenDescriptorsFromContent( + tag, + source, + TYPOGRAPHY_TOKEN_DECL_REGEX, + entry.tokens, + ); + collectTokenDescriptorsFromMatches( + tag, + source, + JSX_TYPOGRAPHY_TOKEN_DECL_REGEX, + entry.tokens, + ); + } + + const roles: SurfaceMarketingTypographyRoleDescriptor[] = []; + for (const [role, entry] of roleMaps.entries()) { + const normalized = normalizeObservedTokens( + "typography", + entry.tokens, + tokenDefinitions, + ); + roles.push({ + role, + tokens: normalized.tokens, + source: entry.source, + }); + warnings.push( + ...normalized.warnings.map((warning) => ({ + code: warning.code, + message: warning.message, + location: warning.location, + })), + ); + } + + if (!profileId && roles.length === 0) { + return { typography: undefined, warnings }; + } + + return { + typography: { + profileId, + roles: roles.sort((left, right) => left.role.localeCompare(right.role)), + source, + }, + warnings, + }; +} + +function collectTokenDescriptorsFromMatches( + content: string, + source: string, + declarationRegex: RegExp, + tokenValues: Map, +): void { + declarationRegex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = declarationRegex.exec(content)) !== null) { + const attribute = normalizeTokenAttribute(match[1] ?? ""); + const value = match[2] ?? ""; + GENERIC_VAR_REGEX.lastIndex = 0; + let variableMatch: RegExpExecArray | null; + while ((variableMatch = GENERIC_VAR_REGEX.exec(value)) !== null) { + const token = `var(${variableMatch[1]})`; + if (!tokenValues.has(token)) { + tokenValues.set(token, { + observedValue: token, + source, + attributes: new Set(attribute ? [attribute] : []), + }); + continue; + } + + const existing = tokenValues.get(token); + if (existing && attribute) { + existing.attributes.add(attribute); + } + } + } +} + +function normalizeTokenAttribute(attribute: string): string { + return attribute + .trim() + .replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`) + .toLowerCase(); +} + async function extractIconSources( surfaceRoot: string, workspaceRoot: string, diff --git a/packages/interfacectl-cli/src/utils/observed-ui-seeding.ts b/packages/interfacectl-cli/src/utils/observed-ui-seeding.ts new file mode 100644 index 0000000..106dd70 --- /dev/null +++ b/packages/interfacectl-cli/src/utils/observed-ui-seeding.ts @@ -0,0 +1,272 @@ +import type { + InterfaceContract, + SurfaceTokenDescriptor, + TokenCategory, + TokenMetadata, + TokenPolicy, +} from "@surfaces/interfacectl-validator"; +import { collectSurfaceDescriptors } from "../descriptors/static-analysis.js"; + +export interface ObservedUiSeedingInput { + workspaceRoot: string; + appRoot: string; + surfaceId: string; + contract: InterfaceContract; +} + +export interface ObservedUiSeedingResult { + contract: InterfaceContract; + warnings: Array<{ code: string; message: string }>; + resolvedPlaceholderWarnings: string[]; +} + +function uniqueSorted(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort( + (a, b) => a.localeCompare(b), + ); +} + +function uniqueSortedNumbers(values: number[]): number[] { + return [...new Set(values.filter((value) => Number.isFinite(value)))].sort( + (a, b) => a - b, + ); +} + +function buildTokenPolicy( + existingPolicy: TokenPolicy | undefined, + tokens: SurfaceTokenDescriptor[], + category: TokenCategory, + warnings: Array<{ code: string; message: string }>, +): TokenPolicy { + if (tokens.length === 0) { + return { + policy: existingPolicy?.policy ?? "warn", + allowedTokens: existingPolicy?.allowedTokens ?? [], + tokenMetadata: existingPolicy?.tokenMetadata, + }; + } + + const metadata = new Map; aliases: Set }>(); + let unresolvedCount = 0; + + for (const token of tokens) { + const canonicalToken = token.value.trim(); + const observedToken = (token.observedValue ?? token.value).trim(); + if (!canonicalToken) { + continue; + } + if (!token.normalizedValue) { + unresolvedCount += 1; + } + + if (!metadata.has(canonicalToken)) { + metadata.set(canonicalToken, { + normalizedValue: token.normalizedValue ?? observedToken, + attributes: new Set(token.attributes ?? []), + aliases: new Set(), + }); + } + + const entry = metadata.get(canonicalToken); + if (!entry) continue; + for (const attribute of token.attributes ?? []) { + entry.attributes.add(attribute); + } + if (observedToken && observedToken !== canonicalToken) { + entry.aliases.add(observedToken); + } + if (!entry.normalizedValue && token.normalizedValue) { + entry.normalizedValue = token.normalizedValue; + } + } + + if (unresolvedCount > 0) { + warnings.push({ + code: `ui-seed.${category}.normalization-skipped`, + message: + `${category} token normalization was skipped for ${unresolvedCount} observed token` + + `${unresolvedCount === 1 ? "" : "s"} because deterministic token definitions were unavailable.`, + }); + } + + const tokenMetadata: TokenMetadata[] = [...metadata.entries()] + .map(([token, entry]) => ({ + token, + normalizedValue: entry.normalizedValue, + attributes: [...entry.attributes].sort((a, b) => a.localeCompare(b)), + aliases: [...entry.aliases].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.token.localeCompare(b.token)); + + return { + policy: existingPolicy?.policy ?? "warn", + allowedTokens: tokenMetadata.map((entry) => entry.token), + tokenMetadata, + }; +} + +export async function seedObservedUiContract( + input: ObservedUiSeedingInput, +): Promise { + const warnings: Array<{ code: string; message: string }> = []; + const resolvedPlaceholderWarnings: string[] = []; + const surfaceRootMap = new Map([[input.surfaceId, input.appRoot]]); + + const descriptorResult = await collectSurfaceDescriptors({ + workspaceRoot: input.workspaceRoot, + contract: input.contract, + surfaceFilters: new Set([input.surfaceId]), + surfaceRootMap, + }); + + for (const warning of descriptorResult.warnings) { + warnings.push({ + code: `ui-seed.${warning.code}`, + message: `Descriptor warning during UI seed: ${warning.message}`, + }); + } + for (const error of descriptorResult.errors) { + warnings.push({ + code: `ui-seed.${error.code}`, + message: `Descriptor error during UI seed: ${error.message}`, + }); + } + + const descriptor = descriptorResult.descriptors.find( + (entry) => entry.surfaceId === input.surfaceId, + ); + const currentSurface = input.contract.surfaces.find( + (surface) => surface.id === input.surfaceId, + ); + + if (!descriptor || !currentSurface) { + return { + contract: input.contract, + warnings, + resolvedPlaceholderWarnings, + }; + } + + const discoveredFonts = uniqueSorted(descriptor.fonts.map((font) => font.value)); + const discoveredDurations = uniqueSortedNumbers( + descriptor.motion.map((motion) => motion.durationMs), + ); + const discoveredTimingFunctions = uniqueSorted( + descriptor.motion.map((motion) => motion.timingFunction), + ); + const discoveredTypographyTokens = descriptor.tokenUsage?.typography ?? []; + const discoveredLayoutTokens = descriptor.tokenUsage?.layout ?? []; + const discoveredMotionTokens = descriptor.tokenUsage?.motion ?? []; + + if (discoveredFonts.length > 0) { + resolvedPlaceholderWarnings.push("allowedFonts.default"); + } else { + warnings.push({ + code: "ui-seed.fonts.none-detected", + message: + "allowedFonts was not seeded from descriptors because no fonts were detected.", + }); + } + + if (descriptor.layout.maxContentWidth !== undefined && descriptor.layout.maxContentWidth !== null) { + resolvedPlaceholderWarnings.push("layout.default"); + } else { + warnings.push({ + code: "ui-seed.layout.none-detected", + message: + "layout.maxContentWidth was not seeded from descriptors because no deterministic width was detected.", + }); + } + + if (discoveredDurations.length === 0 || discoveredTimingFunctions.length === 0) { + warnings.push({ + code: "ui-seed.motion.none-detected", + message: + "Motion constraints were not fully seeded from descriptors because no motion declarations were detected.", + }); + } + + const discoveredMarketingLayoutProfile = + descriptor.layout.landingPattern?.marketingLayoutProfile; + const discoveredMarketingTypographyProfile = + descriptor.marketingTypography?.profileId; + + const contract: InterfaceContract = { + ...input.contract, + surfaces: input.contract.surfaces.map((surface) => { + if (surface.id !== input.surfaceId) { + return surface; + } + const seededLandingPattern = + surface.layout.landingPattern ?? discoveredMarketingLayoutProfile + ? { + policy: surface.layout.landingPattern?.policy ?? "warn", + ...surface.layout.landingPattern, + marketingLayoutProfile: + discoveredMarketingLayoutProfile ?? + surface.layout.landingPattern?.marketingLayoutProfile, + } + : undefined; + return { + ...surface, + marketingTypographyProfile: + discoveredMarketingTypographyProfile ?? + surface.marketingTypographyProfile, + allowedFonts: + discoveredFonts.length > 0 ? discoveredFonts : surface.allowedFonts, + layout: { + ...surface.layout, + maxContentWidth: + descriptor.layout.maxContentWidth ?? surface.layout.maxContentWidth, + landingPattern: seededLandingPattern, + }, + }; + }), + constraints: { + ...input.contract.constraints, + motion: { + ...input.contract.constraints.motion, + allowedDurationsMs: + discoveredDurations.length > 0 + ? discoveredDurations + : input.contract.constraints.motion.allowedDurationsMs, + allowedTimingFunctions: + discoveredTimingFunctions.length > 0 + ? discoveredTimingFunctions + : input.contract.constraints.motion.allowedTimingFunctions, + }, + }, + tokens: { + typography: { + ...buildTokenPolicy( + input.contract.tokens?.typography, + discoveredTypographyTokens, + "typography", + warnings, + ), + }, + layout: { + ...buildTokenPolicy( + input.contract.tokens?.layout, + discoveredLayoutTokens, + "layout", + warnings, + ), + }, + motion: { + ...buildTokenPolicy( + input.contract.tokens?.motion, + discoveredMotionTokens, + "motion", + warnings, + ), + }, + }, + }; + + return { + contract, + warnings, + resolvedPlaceholderWarnings, + }; +} diff --git a/packages/interfacectl-cli/src/utils/token-normalization.ts b/packages/interfacectl-cli/src/utils/token-normalization.ts new file mode 100644 index 0000000..1444394 --- /dev/null +++ b/packages/interfacectl-cli/src/utils/token-normalization.ts @@ -0,0 +1,218 @@ +import { + normalizeTokenLiteralValue, + type SurfaceTokenDescriptor, + type TokenCategory, +} from "@surfaces/interfacectl-validator"; + +export interface TokenDefinition { + name: string; + rawValue: string; + source: string; +} + +export interface RawObservedToken { + observedValue: string; + source: string; + attributes: Set; +} + +export interface TokenNormalizationWarning { + code: string; + message: string; + location?: string; +} + +export interface TokenNormalizationResult { + tokens: SurfaceTokenDescriptor[]; + warnings: TokenNormalizationWarning[]; +} + +const TOKEN_DEFINITION_REGEX = /(^|[;{\s])(--[a-z0-9-]+)\s*:\s*([^;{}]+);/gim; +const TOKEN_REFERENCE_REGEX = /^var\((--[a-z0-9-]+)\)$/i; + +interface TokenResolution { + canonicalToken?: string; + normalizedValue?: string; + aliasChainDepth: number; + unresolvedReason?: string; + location?: string; +} + +function normalizeExpression(input: string): string { + return input.trim().replace(/\s+/g, " "); +} + +function parseTokenReference(input: string): string | null { + const match = normalizeExpression(input).match(TOKEN_REFERENCE_REGEX); + return match?.[1] ?? null; +} + +export function collectTokenDefinitionsFromContent( + content: string, + source: string, + definitions: Map, +): void { + TOKEN_DEFINITION_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = TOKEN_DEFINITION_REGEX.exec(content)) !== null) { + const name = match[2]?.trim(); + const rawValue = match[3]?.trim(); + if (!name || !rawValue || definitions.has(name)) { + continue; + } + definitions.set(name, { name, rawValue, source }); + } +} + +function resolveTokenDefinition( + tokenName: string, + category: TokenCategory, + definitions: Map, + trail: Set = new Set(), +): TokenResolution { + if (trail.has(tokenName)) { + return { + canonicalToken: `var(${tokenName})`, + aliasChainDepth: trail.size, + unresolvedReason: "cyclic token alias definition", + }; + } + + const definition = definitions.get(tokenName); + if (!definition) { + return { + canonicalToken: `var(${tokenName})`, + aliasChainDepth: trail.size, + unresolvedReason: "missing custom property definition", + }; + } + + const aliasTarget = parseTokenReference(definition.rawValue); + if (aliasTarget) { + const nextTrail = new Set(trail); + nextTrail.add(tokenName); + const resolved = resolveTokenDefinition( + aliasTarget, + category, + definitions, + nextTrail, + ); + return { + canonicalToken: resolved.canonicalToken ?? `var(${aliasTarget})`, + normalizedValue: resolved.normalizedValue, + aliasChainDepth: resolved.aliasChainDepth + 1, + unresolvedReason: resolved.unresolvedReason, + location: resolved.location ?? definition.source, + }; + } + + const normalizedValue = normalizeTokenLiteralValue(category, definition.rawValue); + if (!normalizedValue) { + return { + canonicalToken: `var(${tokenName})`, + aliasChainDepth: trail.size, + unresolvedReason: "unsupported token literal for deterministic normalization", + location: definition.source, + }; + } + + return { + canonicalToken: `var(${tokenName})`, + normalizedValue, + aliasChainDepth: trail.size, + location: definition.source, + }; +} + +export function normalizeObservedTokens( + category: TokenCategory, + observedTokens: Map, + definitions: Map, +): TokenNormalizationResult { + const warnings: TokenNormalizationWarning[] = []; + const resolved = [...observedTokens.values()].map((token) => { + const tokenName = parseTokenReference(token.observedValue); + if (!tokenName) { + return { + ...token, + value: token.observedValue, + normalizedValue: undefined, + aliasChainDepth: Number.MAX_SAFE_INTEGER, + }; + } + + const resolution = resolveTokenDefinition(tokenName, category, definitions); + if (!resolution.normalizedValue) { + warnings.push({ + code: "token.normalization-skipped", + message: + `Could not normalize ${category} token "${token.observedValue}" because ${resolution.unresolvedReason}.`, + location: resolution.location ?? token.source, + }); + return { + ...token, + value: resolution.canonicalToken ?? token.observedValue, + normalizedValue: undefined, + aliasChainDepth: Number.MAX_SAFE_INTEGER, + }; + } + + return { + ...token, + value: resolution.canonicalToken ?? token.observedValue, + normalizedValue: resolution.normalizedValue, + aliasChainDepth: resolution.aliasChainDepth, + }; + }); + + const canonicalByGroup = new Map(); + for (const token of resolved) { + if (!token.normalizedValue) { + continue; + } + const current = canonicalByGroup.get(token.normalizedValue); + if (!current) { + canonicalByGroup.set(token.normalizedValue, token.value); + continue; + } + + const currentResolved = resolved.find((entry) => entry.value === current); + if (!currentResolved) { + canonicalByGroup.set(token.normalizedValue, token.value); + continue; + } + + const candidateWins = + token.aliasChainDepth < currentResolved.aliasChainDepth || + ( + token.aliasChainDepth === currentResolved.aliasChainDepth && + token.value.localeCompare(currentResolved.value) < 0 + ); + if (candidateWins) { + canonicalByGroup.set(token.normalizedValue, token.value); + } + } + + const tokens: SurfaceTokenDescriptor[] = resolved + .map((token) => { + const canonicalValue = + token.normalizedValue + ? canonicalByGroup.get(token.normalizedValue) ?? token.observedValue + : token.observedValue; + + return { + value: canonicalValue, + observedValue: token.observedValue, + source: token.source, + attributes: [...token.attributes].sort((a, b) => a.localeCompare(b)), + normalizedValue: token.normalizedValue, + }; + }) + .sort((a, b) => { + const left = a.observedValue ?? a.value; + const right = b.observedValue ?? b.value; + return left.localeCompare(right); + }); + + return { tokens, warnings }; +} diff --git a/packages/interfacectl-cli/src/utils/violation-classifier.ts b/packages/interfacectl-cli/src/utils/violation-classifier.ts index 5579f5d..2b69e19 100644 --- a/packages/interfacectl-cli/src/utils/violation-classifier.ts +++ b/packages/interfacectl-cli/src/utils/violation-classifier.ts @@ -16,6 +16,7 @@ export function classifyViolationType( "font-not-allowed", "color-not-allowed", "icon-source-not-allowed", + "token-not-allowed", "motion-duration-not-allowed", "motion-timing-not-allowed", ]; diff --git a/packages/interfacectl-cli/test/observed-ui-seeding.test.mjs b/packages/interfacectl-cli/test/observed-ui-seeding.test.mjs new file mode 100644 index 0000000..3a3ffd5 --- /dev/null +++ b/packages/interfacectl-cli/test/observed-ui-seeding.test.mjs @@ -0,0 +1,248 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { seedObservedUiContract } from "../dist/utils/observed-ui-seeding.js"; + +function baseContract(surfaceId) { + return { + contractId: "ui-seed-test", + version: "1.0.0", + surfaces: [ + { + id: surfaceId, + displayName: "Seed Test", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["sans-serif"], + layout: { maxContentWidth: 1120 }, + }, + ], + sections: [{ id: "main.hero", intent: "Hero", description: "Hero section" }], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "warn", + allowedValues: [], + }, + }; +} + +test("seedObservedUiContract captures existing fonts, layout width, motion, and UI tokens", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-seed-ui-")); + const surfaceId = "demo-surface"; + const appRoot = path.join(tempRoot, "apps", surfaceId); + + try { + const appDir = path.join(appRoot, "app"); + await mkdir(appDir, { recursive: true }); + + await writeFile( + path.join(appDir, "layout.tsx"), + `import "./globals.css"; +export default function RootLayout({ children }) { + return {children}; +} +`, + "utf8", + ); + + await writeFile( + path.join(appDir, "page.tsx"), + `export default function Page() { + return
Hello
; +} +`, + "utf8", + ); + + await writeFile( + path.join(appDir, "globals.css"), + `:root { + --contract-max-width: 960px; + --contract-motion-duration: 180ms; + --contract-motion-timing: ease-in-out; + --font-sans: "Inter", sans-serif; + --text-body: var(--text-body-size); + --text-body-size: 1rem; + --space-4: 16px; + --space-6: var(--space-4); + --container-xl: 60rem; + --container-content: 960px; + --motion-duration-200: 200ms; + --motion-duration-fast: 0.2s; + --motion-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --motion-ease-emphasized: cubic-bezier(0.4,0,0.2,1); +} + +.hero { + font-family: var(--font-sans); + font-size: var(--text-body); + line-height: var(--text-body); + padding-inline: var(--space-6); + max-width: var(--container-xl); + width: var(--container-content); + transition: opacity var(--motion-duration-fast) var(--motion-ease-standard); + animation-duration: var(--motion-duration-200); + animation-timing-function: var(--motion-ease-emphasized); +} +`, + "utf8", + ); + + const seeded = await seedObservedUiContract({ + workspaceRoot: tempRoot, + appRoot, + surfaceId, + contract: baseContract(surfaceId), + }); + + assert.deepEqual(seeded.contract.surfaces[0].allowedFonts, ["var(--font-sans)"]); + assert.equal(seeded.contract.surfaces[0].layout.maxContentWidth, 960); + assert.deepEqual(seeded.contract.constraints.motion.allowedDurationsMs, [180]); + assert.deepEqual(seeded.contract.constraints.motion.allowedTimingFunctions, ["ease-in-out"]); + assert.deepEqual( + seeded.contract.tokens.typography.allowedTokens, + ["var(--font-sans)", "var(--text-body-size)"], + ); + assert.deepEqual( + seeded.contract.tokens.typography.tokenMetadata, + [ + { + token: "var(--font-sans)", + normalizedValue: "inter, sans-serif", + attributes: ["font-family"], + aliases: [], + }, + { + token: "var(--text-body-size)", + normalizedValue: "1rem", + attributes: ["font-size", "line-height"], + aliases: ["var(--text-body)"], + }, + ], + ); + assert.deepEqual( + seeded.contract.tokens.layout.allowedTokens, + ["var(--container-content)", "var(--space-4)"], + ); + assert.deepEqual( + seeded.contract.tokens.layout.tokenMetadata, + [ + { + token: "var(--container-content)", + normalizedValue: "960px", + attributes: ["max-width", "width"], + aliases: ["var(--container-xl)"], + }, + { + token: "var(--space-4)", + normalizedValue: "16px", + attributes: ["padding-inline"], + aliases: ["var(--space-6)"], + }, + ], + ); + assert.deepEqual( + seeded.contract.tokens.motion.allowedTokens, + ["var(--motion-duration-200)", "var(--motion-ease-emphasized)"], + ); + assert.deepEqual( + seeded.contract.tokens.motion.tokenMetadata, + [ + { + token: "var(--motion-duration-200)", + normalizedValue: "200ms", + attributes: ["animation-duration", "transition"], + aliases: ["var(--motion-duration-fast)"], + }, + { + token: "var(--motion-ease-emphasized)", + normalizedValue: "cubic-bezier(0.4,0,0.2,1)", + attributes: ["animation-timing-function", "transition"], + aliases: ["var(--motion-ease-standard)"], + }, + ], + ); + assert.deepEqual( + seeded.resolvedPlaceholderWarnings.sort(), + ["allowedFonts.default", "layout.default"], + ); + assert.equal( + seeded.warnings.some((warning) => warning.code.includes("normalization-skipped")), + false, + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("seedObservedUiContract warns when token normalization cannot resolve a token definition", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-seed-ui-unresolved-")); + const surfaceId = "demo-surface"; + const appRoot = path.join(tempRoot, "apps", surfaceId); + + try { + const appDir = path.join(appRoot, "app"); + await mkdir(appDir, { recursive: true }); + + await writeFile( + path.join(appDir, "layout.tsx"), + `import "./globals.css"; +export default function RootLayout({ children }) { + return {children}; +} +`, + "utf8", + ); + + await writeFile( + path.join(appDir, "page.tsx"), + `export default function Page() { + return
Hello
; +} +`, + "utf8", + ); + + await writeFile( + path.join(appDir, "globals.css"), + `.hero { + padding-inline: var(--space-missing); +} +`, + "utf8", + ); + + const seeded = await seedObservedUiContract({ + workspaceRoot: tempRoot, + appRoot, + surfaceId, + contract: baseContract(surfaceId), + }); + + assert.deepEqual(seeded.contract.tokens.layout.allowedTokens, ["var(--space-missing)"]); + assert.deepEqual( + seeded.contract.tokens.layout.tokenMetadata, + [ + { + token: "var(--space-missing)", + normalizedValue: "var(--space-missing)", + attributes: ["padding-inline"], + aliases: [], + }, + ], + ); + assert.equal( + seeded.warnings.some((warning) => warning.code === "ui-seed.layout.normalization-skipped"), + true, + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/packages/interfacectl-cli/test/static-analysis.test.mjs b/packages/interfacectl-cli/test/static-analysis.test.mjs index 05c21fc..6f2ea97 100644 --- a/packages/interfacectl-cli/test/static-analysis.test.mjs +++ b/packages/interfacectl-cli/test/static-analysis.test.mjs @@ -62,6 +62,7 @@ test("collectSurfaceDescriptors captures sections, containers, fonts, colors, an --contract-max-width: 960px; --contract-motion-duration: 120ms; --contract-motion-timing: ease-in; + --font-demo: "Demo Sans"; --color-primary: #0066cc; --color-background: #ffffff; } @@ -274,6 +275,203 @@ test("detects shell-owned primitives (navigation/footer/auth-shell) in descripto } }); +test("collectSurfaceDescriptors extracts marketing layout signals and typography-role tokens", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-marketing-")); + const surfaceId = "marketing-surface"; + const surfaceRoot = path.join(tempRoot, "apps", surfaceId); + + try { + await mkdir(path.join(surfaceRoot, "app"), { recursive: true }); + + await writeFile( + path.join(surfaceRoot, "app", "globals.css"), + ` + :root { + --contract-max-width: 1120px; + --contract-motion-duration: 120ms; + --contract-motion-timing: linear; + --font-marketing: "Inter"; + --color-marketing-text: #111827; + --marketing-sans-hero-title-size: 32px; + --marketing-sans-hero-title-line-height: 1.2; + --marketing-sans-body-size: 14px; + --marketing-sans-body-line-height: 1.6; + } + + body { + font-family: var(--font-marketing), Inter, sans-serif; + color: var(--color-marketing-text); + transition: opacity var(--contract-motion-duration) linear; + } + `, + "utf-8", + ); + + await writeFile( + path.join(surfaceRoot, "app", "page.tsx"), + ` + export default function Page() { + return ( +
+
+

+ Marketing Surface +

+

+ Body copy +

+
+
+ ); + } + `, + "utf-8", + ); + + const contract = { + contractId: "marketing.contract", + version: "1.0.0", + sections: [ + { + id: "landing.hero", + intent: "hero", + description: "Hero section", + }, + ], + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + surfaces: [ + { + id: surfaceId, + displayName: "Marketing Surface", + type: "web", + requiredSections: ["landing.hero"], + allowedFonts: ["Inter", "var(--font-marketing)", "sans-serif"], + layout: { + maxContentWidth: 1120, + landingPattern: { + policy: "warn", + marketingLayoutProfile: "marketing-open-flow-split", + marketingLayoutPolicy: "warn", + }, + }, + marketingTypographyProfile: "marketing-sans", + marketingTypographyPolicy: "warn", + }, + ], + marketingProfiles: { + layout: [ + { + id: "marketing-open-flow-split", + heroContainerMode: "open-flow", + heroVisualPlacement: "inline-end", + sectionDividerMode: "border-top", + sectionSpacingProfile: "roomy", + }, + ], + typography: [ + { + id: "marketing-sans", + roles: [ + { + role: "heroTitle", + allowedTokens: [ + "var(--marketing-sans-hero-title-size)", + "var(--marketing-sans-hero-title-line-height)", + ], + }, + { + role: "body", + allowedTokens: [ + "var(--marketing-sans-body-size)", + "var(--marketing-sans-body-line-height)", + ], + }, + ], + }, + ], + }, + color: { + policy: "warn", + allowedValues: ["var(--color-marketing-text)"], + }, + }; + + const result = await collectSurfaceDescriptors({ + workspaceRoot: tempRoot, + contract, + surfaceFilters: new Set(), + surfaceRootMap: new Map(), + }); + + assert.equal(result.errors.length, 0); + assert.equal(result.warnings.length, 0); + + const descriptor = result.descriptors[0]; + assert.equal( + descriptor.layout.landingPattern?.marketingLayoutProfile, + "marketing-open-flow-split", + ); + assert.equal( + descriptor.layout.landingPattern?.heroContainerMode, + "open-flow", + ); + assert.equal( + descriptor.layout.landingPattern?.heroVisualPlacement, + "inline-end", + ); + assert.equal( + descriptor.layout.landingPattern?.sectionDividerMode, + "border-top", + ); + assert.equal( + descriptor.layout.landingPattern?.sectionSpacingProfile, + "roomy", + ); + assert.equal(descriptor.marketingTypography?.profileId, "marketing-sans"); + assert.deepEqual( + descriptor.marketingTypography?.roles.map((role) => role.role), + ["body", "heroTitle"], + ); + assert.deepEqual( + descriptor.marketingTypography?.roles.find((role) => role.role === "heroTitle")?.tokens.map((token) => token.value), + [ + "var(--marketing-sans-hero-title-line-height)", + "var(--marketing-sans-hero-title-size)", + ], + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test("collectSurfaceDescriptors captures deterministic icon sources from surface and shared UI imports", async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-icons-static-")); const surfaceId = "demo-icons"; diff --git a/packages/interfacectl-validator/dist/index.d.ts b/packages/interfacectl-validator/dist/index.d.ts index 5d78ce9..f20d9d8 100644 --- a/packages/interfacectl-validator/dist/index.d.ts +++ b/packages/interfacectl-validator/dist/index.d.ts @@ -8,7 +8,8 @@ export interface ContractStructureValidation { export declare function validateContractStructure(contractData: unknown, schema: object): ContractStructureValidation; export declare function evaluateSurfaceCompliance(contract: InterfaceContract, descriptor: SurfaceDescriptor): SurfaceReport; export declare function evaluateContractCompliance(contract: InterfaceContract, descriptors: SurfaceDescriptor[]): ValidationSummary; -export type { InterfaceContract, ContractSurface, ContractSection, ContractConstraints, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceIconDescriptor, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, PageFrameLayoutDescriptor, ChromeLayoutDescriptor, ChromePolicyTarget, ChromeShadowKind, LandingPatternDescriptor, SurfacePrimitiveDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, ContractRef, RuleRef, DiffOutput, DiffEntry, DiffChangeType, DriftRisk, Severity, SafetyLevel, EnforcementPolicy, EnforcementMode, IconPolicy, FlowPolicy, FlowRequirement, FlowTransitionRequirement, LandingPatternPolicy, SurfaceFlowDescriptor, SurfaceFlowStepDescriptor, SurfaceFlowTransitionDescriptor, AutofixRule, FixSummary, FixEntry, FixError, } from "./types.js"; +export type { InterfaceContract, ContractSurface, ContractSection, ContractConstraints, ContractTokenPolicies, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceIconDescriptor, SurfaceTokenDescriptor, SurfaceTokenUsage, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, PageFrameLayoutDescriptor, ChromeLayoutDescriptor, ChromePolicyTarget, ChromeShadowKind, LandingPatternDescriptor, SurfacePrimitiveDescriptor, SurfaceReport, DriftViolation, ValidationSummary, DriftViolationType, ContractRef, RuleRef, DiffOutput, DiffEntry, DiffChangeType, DriftRisk, Severity, SafetyLevel, EnforcementPolicy, EnforcementMode, IconPolicy, TokenCategory, TokenMetadata, TokenPolicy, ContractMarketingProfiles, MarketingLayoutProfile, MarketingTypographyProfile, MarketingTypographyRoleProfile, MarketingHeroContainerMode, MarketingHeroVisualPlacement, MarketingSectionDividerMode, MarketingSectionSpacingProfile, MarketingTypographyRole, FlowPolicy, FlowRequirement, FlowTransitionRequirement, LandingPatternPolicy, SurfaceMarketingTypographyDescriptor, SurfaceMarketingTypographyRoleDescriptor, SurfaceFlowDescriptor, SurfaceFlowStepDescriptor, SurfaceFlowTransitionDescriptor, AutofixRule, FixSummary, FixEntry, FixError, } from "./types.js"; export { getBundledDiffSchema, getBundledPolicySchema, getBundledFixSummarySchema, validateDiffOutput, validatePolicy, validateFixSummary, type ValidationResult, } from "./schema-validate.js"; export { normalizeColorValue, normalizeColorValues, } from "./color-policy.js"; +export { matchTokenPolicy, normalizeTokenLiteralValue, type TokenPolicyMatch, } from "./token-policy.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/index.d.ts.map b/packages/interfacectl-validator/dist/index.d.ts.map index 2d347ea..1e0f35b 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,EAQlB,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;AA0VD,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CA8af;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,sBAAsB,EACtB,kBAAkB,EAClB,gBAAgB,EAChB,wBAAwB,EACxB,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,UAAU,EACV,eAAe,EACf,yBAAyB,EACzB,oBAAoB,EACpB,qBAAqB,EACrB,yBAAyB,EACzB,+BAA+B,EAC/B,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,EAQlB,MAAM,YAAY,CAAC;AASpB,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,CAmC7B;AAstBD,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,iBAAiB,EAC3B,UAAU,EAAE,iBAAiB,GAC5B,aAAa,CAgbf;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,qBAAqB,EACrB,iBAAiB,EACjB,wBAAwB,EACxB,qBAAqB,EACrB,sBAAsB,EACtB,qBAAqB,EACrB,sBAAsB,EACtB,iBAAiB,EACjB,uBAAuB,EACvB,uBAAuB,EACvB,yBAAyB,EACzB,sBAAsB,EACtB,kBAAkB,EAClB,gBAAgB,EAChB,wBAAwB,EACxB,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,aAAa,EACb,aAAa,EACb,WAAW,EACX,yBAAyB,EACzB,sBAAsB,EACtB,0BAA0B,EAC1B,8BAA8B,EAC9B,0BAA0B,EAC1B,4BAA4B,EAC5B,2BAA2B,EAC3B,8BAA8B,EAC9B,uBAAuB,EACvB,UAAU,EACV,eAAe,EACf,yBAAyB,EACzB,oBAAoB,EACpB,oCAAoC,EACpC,wCAAwC,EACxC,qBAAqB,EACrB,yBAAyB,EACzB,+BAA+B,EAC/B,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;AAE3B,OAAO,EACL,gBAAgB,EAChB,0BAA0B,EAC1B,KAAK,gBAAgB,GACtB,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 01b66d2..c66940a 100644 --- a/packages/interfacectl-validator/dist/index.js +++ b/packages/interfacectl-validator/dist/index.js @@ -4,6 +4,7 @@ import bundledSchema from "./schema/web.surface.contract.schema.json" with { type: "json" }; import { normalizeColorValue } from "./color-policy.js"; +import { matchTokenPolicy } from "./token-policy.js"; const frozenBundledSchema = Object.freeze(bundledSchema); export function getBundledContractSchema() { return frozenBundledSchema; @@ -22,12 +23,35 @@ export function validateContractStructure(contractData, schema) { errors: formatAjvErrors(validate.errors), }; } + const marketingReferenceErrors = validateMarketingProfileReferences(contractData); + if (marketingReferenceErrors.length > 0) { + return { + ok: false, + errors: marketingReferenceErrors, + }; + } return { ok: true, errors: [], contract: contractData, }; } +function validateMarketingProfileReferences(contract) { + const errors = []; + const layoutProfiles = new Set((contract.marketingProfiles?.layout ?? []).map((profile) => profile.id)); + const typographyProfiles = new Set((contract.marketingProfiles?.typography ?? []).map((profile) => profile.id)); + for (const surface of contract.surfaces) { + const layoutProfileRef = surface.layout.landingPattern?.marketingLayoutProfile; + if (layoutProfileRef && !layoutProfiles.has(layoutProfileRef)) { + errors.push(`/surfaces/${surface.id}/layout/landingPattern/marketingLayoutProfile must reference a declared marketingProfiles.layout id`); + } + const typographyProfileRef = surface.marketingTypographyProfile; + if (typographyProfileRef && !typographyProfiles.has(typographyProfileRef)) { + errors.push(`/surfaces/${surface.id}/marketingTypographyProfile must reference a declared marketingProfiles.typography id`); + } + } + return errors; +} function validateColorPolicy(contract, descriptor, violations) { const colorPolicy = contract.color; if (!colorPolicy || colorPolicy.policy === "off") { @@ -83,6 +107,49 @@ function validateIconPolicy(surface, descriptor, violations) { }); } } +function validateTokenPolicyCategory(surfaceId, category, policy, observedTokens, violations) { + if (!policy || policy.policy === "off") { + return; + } + const allowedTokens = new Set(policy.allowedTokens); + const seenTokens = new Set(); + for (const token of observedTokens ?? []) { + const observedToken = (token.observedValue ?? token.value).trim(); + const dedupeKey = `${token.value.trim()}::${observedToken}::${token.normalizedValue ?? ""}`; + if (!observedToken || seenTokens.has(dedupeKey)) { + continue; + } + seenTokens.add(dedupeKey); + const match = matchTokenPolicy(policy, token); + if (match.matched) { + continue; + } + violations.push({ + surfaceId, + type: "token-not-allowed", + message: `${category} token "${observedToken}" is not allowed for surface "${surfaceId}".`, + details: { + token: observedToken, + canonicalToken: match.canonicalToken, + normalizedValue: match.normalizedValue, + tokenCategory: category, + source: token.source, + allowedTokens: [...allowedTokens].sort((a, b) => a.localeCompare(b)), + policy: policy.policy, + jsonPointer: `/tokens/${category}/allowedTokens`, + }, + }); + } +} +function validateTokenPolicies(contract, descriptor, violations) { + const tokenUsage = descriptor.tokenUsage; + if (!contract.tokens || !tokenUsage) { + return; + } + validateTokenPolicyCategory(descriptor.surfaceId, "typography", contract.tokens.typography, tokenUsage.typography, violations); + validateTokenPolicyCategory(descriptor.surfaceId, "layout", contract.tokens.layout, tokenUsage.layout, violations); + validateTokenPolicyCategory(descriptor.surfaceId, "motion", contract.tokens.motion, tokenUsage.motion, violations); +} function validateFlowPolicy(surface, descriptor, violations) { const flowPolicy = surface.flows; if (!flowPolicy || flowPolicy.policy === "off") { @@ -204,7 +271,7 @@ function validateFlowPolicy(surface, descriptor, violations) { } } } -function validateLandingPattern(surface, descriptor, violations) { +function validateLandingPattern(surface, contract, descriptor, violations) { const landingPattern = surface.layout.landingPattern; if (!landingPattern || landingPattern.policy === "off") { return; @@ -300,6 +367,207 @@ function validateLandingPattern(surface, descriptor, violations) { }, }); } + validateMarketingLandingPattern(surface, contract, descriptor, violations); +} +function validateMarketingLandingPattern(surface, contract, descriptor, violations) { + const landingPattern = surface.layout.landingPattern; + const policy = landingPattern?.marketingLayoutPolicy ?? + (landingPattern?.marketingLayoutProfile ? "warn" : "off"); + if (!landingPattern || policy === "off" || !landingPattern.marketingLayoutProfile) { + return; + } + const descriptorPattern = descriptor.layout.landingPattern; + const expectedProfile = contract.marketingProfiles?.layout?.find((profile) => profile.id === landingPattern.marketingLayoutProfile); + if (!expectedProfile) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-marketing-layout-missing", + message: `Marketing layout profile "${landingPattern.marketingLayoutProfile}" is missing from the contract for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId: landingPattern.marketingLayoutProfile, + policy, + }, + }); + return; + } + if (!descriptorPattern) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-marketing-layout-missing", + message: `Marketing layout signals are missing for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId: expectedProfile.id, + policy, + }, + }); + return; + } + if (!descriptorPattern.marketingLayoutProfile || + descriptorPattern.marketingLayoutProfile !== expectedProfile.id) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-marketing-layout-missing", + message: `Surface "${descriptor.surfaceId}" must declare marketing layout profile "${expectedProfile.id}".`, + details: { + expectedProfileId: expectedProfile.id, + actualProfileId: descriptorPattern.marketingLayoutProfile, + policy, + source: descriptorPattern.source, + }, + }); + } + if (descriptorPattern.heroContainerMode && + descriptorPattern.heroContainerMode !== expectedProfile.heroContainerMode) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-hero-container-mode", + message: `Hero container mode for surface "${descriptor.surfaceId}" must be "${expectedProfile.heroContainerMode}".`, + details: { + expectedHeroContainerMode: expectedProfile.heroContainerMode, + actualHeroContainerMode: descriptorPattern.heroContainerMode, + policy, + source: descriptorPattern.source, + }, + }); + } + if (descriptorPattern.heroVisualPlacement && + descriptorPattern.heroVisualPlacement !== expectedProfile.heroVisualPlacement) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-hero-visual-placement", + message: `Hero visual placement for surface "${descriptor.surfaceId}" must be "${expectedProfile.heroVisualPlacement}".`, + details: { + expectedHeroVisualPlacement: expectedProfile.heroVisualPlacement, + actualHeroVisualPlacement: descriptorPattern.heroVisualPlacement, + policy, + source: descriptorPattern.source, + }, + }); + } + if (descriptorPattern.sectionDividerMode && + descriptorPattern.sectionDividerMode !== expectedProfile.sectionDividerMode) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-section-divider-mode", + message: `Section divider mode for surface "${descriptor.surfaceId}" must be "${expectedProfile.sectionDividerMode}".`, + details: { + expectedSectionDividerMode: expectedProfile.sectionDividerMode, + actualSectionDividerMode: descriptorPattern.sectionDividerMode, + policy, + source: descriptorPattern.source, + }, + }); + } + if (descriptorPattern.sectionSpacingProfile && + descriptorPattern.sectionSpacingProfile !== expectedProfile.sectionSpacingProfile) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-section-spacing-profile", + message: `Section spacing profile for surface "${descriptor.surfaceId}" must be "${expectedProfile.sectionSpacingProfile}".`, + details: { + expectedSectionSpacingProfile: expectedProfile.sectionSpacingProfile, + actualSectionSpacingProfile: descriptorPattern.sectionSpacingProfile, + policy, + source: descriptorPattern.source, + }, + }); + } +} +function validateMarketingTypography(surface, contract, descriptor, violations) { + const expectedProfileId = surface.marketingTypographyProfile; + const policy = surface.marketingTypographyPolicy ?? + (expectedProfileId ? "warn" : "off"); + if (!expectedProfileId || policy === "off") { + return; + } + const expectedProfile = contract.marketingProfiles?.typography?.find((profile) => profile.id === expectedProfileId); + if (!expectedProfile) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-profile-missing", + message: `Marketing typography profile "${expectedProfileId}" is missing from the contract for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId, + policy, + }, + }); + return; + } + const observedTypography = descriptor.marketingTypography; + if (!observedTypography) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-profile-missing", + message: `Marketing typography signals are missing for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId, + policy, + }, + }); + return; + } + if (!observedTypography.profileId || + observedTypography.profileId !== expectedProfileId) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-profile-missing", + message: `Surface "${descriptor.surfaceId}" must declare marketing typography profile "${expectedProfileId}".`, + details: { + expectedProfileId, + actualProfileId: observedTypography.profileId, + policy, + source: observedTypography.source, + }, + }); + } + const roleDescriptors = new Map(observedTypography.roles.map((roleDescriptor) => [ + roleDescriptor.role, + roleDescriptor, + ])); + const roleTokenMetadata = contract.tokens?.typography?.tokenMetadata ?? []; + for (const expectedRole of expectedProfile.roles) { + const observedRole = roleDescriptors.get(expectedRole.role); + if (!observedRole || observedRole.tokens.length === 0) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-role-missing", + message: `Marketing typography role "${expectedRole.role}" is missing for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId, + role: expectedRole.role, + policy, + source: observedRole?.source ?? observedTypography.source, + }, + }); + continue; + } + const rolePolicy = { + policy, + allowedTokens: expectedRole.allowedTokens, + tokenMetadata: roleTokenMetadata.filter((entry) => expectedRole.allowedTokens.includes(entry.token)), + }; + for (const token of observedRole.tokens) { + const match = matchTokenPolicy(rolePolicy, token); + if (match.matched) { + continue; + } + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-role-token", + message: `Marketing typography role "${expectedRole.role}" uses a token outside profile "${expectedProfileId}" for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId, + role: expectedRole.role, + token: token.observedValue ?? token.value, + canonicalToken: match.canonicalToken, + normalizedValue: match.normalizedValue, + allowedTokens: expectedRole.allowedTokens, + policy, + source: token.source ?? observedRole.source, + }, + }); + } + } } export function evaluateSurfaceCompliance(contract, descriptor) { const surface = findSurface(contract.surfaces, descriptor.surfaceId); @@ -421,9 +689,11 @@ export function evaluateSurfaceCompliance(contract, descriptor) { } } validateColorPolicy(contract, descriptor, violations); + validateTokenPolicies(contract, descriptor, violations); validateIconPolicy(surface, descriptor, violations); validateFlowPolicy(surface, descriptor, violations); - validateLandingPattern(surface, descriptor, violations); + validateLandingPattern(surface, contract, descriptor, violations); + validateMarketingTypography(surface, contract, descriptor, violations); const reportedWidth = descriptor.layout.maxContentWidth; if (reportedWidth === null || reportedWidth === undefined) { violations.push({ @@ -769,3 +1039,4 @@ function findSurface(surfaces, surfaceId) { } export { getBundledDiffSchema, getBundledPolicySchema, getBundledFixSummarySchema, validateDiffOutput, validatePolicy, validateFixSummary, } from "./schema-validate.js"; export { normalizeColorValue, normalizeColorValues, } from "./color-policy.js"; +export { matchTokenPolicy, normalizeTokenLiteralValue, } from "./token-policy.js"; 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 f710cdf..16167e3 100644 --- a/packages/interfacectl-validator/dist/schema/web.surface.contract.schema.json +++ b/packages/interfacectl-validator/dist/schema/web.surface.contract.schema.json @@ -49,6 +49,12 @@ "color": { "$ref": "#/$defs/color" }, + "tokens": { + "$ref": "#/$defs/contractTokens" + }, + "marketingProfiles": { + "$ref": "#/$defs/marketingProfiles" + }, "x_extracted": { "type": "object", "description": "Phase 0: fields extracted from app code; not used by validator semantics.", @@ -134,6 +140,18 @@ }, "uniqueItems": true }, + "marketingTypographyProfile": { + "type": "string", + "minLength": 1 + }, + "marketingTypographyPolicy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] + }, "layout": { "type": "object", "additionalProperties": false, @@ -395,6 +413,210 @@ } } }, + "tokenPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy", + "allowedTokens" + ], + "properties": { + "policy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] + }, + "allowedTokens": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "tokenMetadata": { + "type": "array", + "items": { + "$ref": "#/$defs/tokenMetadata" + } + } + } + }, + "tokenMetadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "token", + "normalizedValue", + "attributes", + "aliases" + ], + "properties": { + "token": { + "type": "string", + "minLength": 1 + }, + "normalizedValue": { + "type": "string", + "minLength": 1 + }, + "attributes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, + "contractTokens": { + "type": "object", + "additionalProperties": false, + "properties": { + "typography": { + "$ref": "#/$defs/tokenPolicy" + }, + "layout": { + "$ref": "#/$defs/tokenPolicy" + }, + "motion": { + "$ref": "#/$defs/tokenPolicy" + } + } + }, + "marketingProfiles": { + "type": "object", + "additionalProperties": false, + "properties": { + "layout": { + "type": "array", + "items": { + "$ref": "#/$defs/marketingLayoutProfile" + } + }, + "typography": { + "type": "array", + "items": { + "$ref": "#/$defs/marketingTypographyProfile" + } + } + } + }, + "marketingLayoutProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "heroContainerMode", + "heroVisualPlacement", + "sectionDividerMode", + "sectionSpacingProfile" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "heroContainerMode": { + "type": "string", + "enum": [ + "open-flow", + "framed" + ] + }, + "heroVisualPlacement": { + "type": "string", + "enum": [ + "inline-end", + "inline-start", + "stacked", + "none" + ] + }, + "sectionDividerMode": { + "type": "string", + "enum": [ + "none", + "border-top" + ] + }, + "sectionSpacingProfile": { + "type": "string", + "enum": [ + "compact", + "roomy" + ] + } + } + }, + "marketingTypographyProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "roles" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "roles": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/marketingTypographyRoleProfile" + } + } + } + }, + "marketingTypographyRoleProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "role", + "allowedTokens" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "heroEyebrow", + "heroTitle", + "heroBody", + "sectionTitle", + "body" + ] + }, + "allowedTokens": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, "landingPattern": { "type": "object", "additionalProperties": false, @@ -432,6 +654,18 @@ "solid", "custom" ] + }, + "marketingLayoutProfile": { + "type": "string", + "minLength": 1 + }, + "marketingLayoutPolicy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] } } }, diff --git a/packages/interfacectl-validator/dist/token-policy.d.ts b/packages/interfacectl-validator/dist/token-policy.d.ts new file mode 100644 index 0000000..9408f17 --- /dev/null +++ b/packages/interfacectl-validator/dist/token-policy.d.ts @@ -0,0 +1,10 @@ +import type { SurfaceTokenDescriptor, TokenCategory, TokenPolicy } from "./types.js"; +export declare function normalizeTokenLiteralValue(category: TokenCategory, input: string): string | null; +export interface TokenPolicyMatch { + canonicalToken?: string; + normalizedValue?: string; + observedToken: string; + matched: boolean; +} +export declare function matchTokenPolicy(policy: TokenPolicy | undefined, token: SurfaceTokenDescriptor): TokenPolicyMatch; +//# sourceMappingURL=token-policy.d.ts.map \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/token-policy.d.ts.map b/packages/interfacectl-validator/dist/token-policy.d.ts.map new file mode 100644 index 0000000..ef55f40 --- /dev/null +++ b/packages/interfacectl-validator/dist/token-policy.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"token-policy.d.ts","sourceRoot":"","sources":["../src/token-policy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,sBAAsB,EACtB,aAAa,EACb,WAAW,EACZ,MAAM,YAAY,CAAC;AA+DpB,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,aAAa,EACvB,KAAK,EAAE,MAAM,GACZ,MAAM,GAAG,IAAI,CAQf;AAED,MAAM,WAAW,gBAAgB;IAC/B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,GAAG,SAAS,EAC/B,KAAK,EAAE,sBAAsB,GAC5B,gBAAgB,CAqDlB"} \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/token-policy.js b/packages/interfacectl-validator/dist/token-policy.js new file mode 100644 index 0000000..f00b1c4 --- /dev/null +++ b/packages/interfacectl-validator/dist/token-policy.js @@ -0,0 +1,110 @@ +const LENGTH_LITERAL_REGEX = /^(-?(?:\d+|\d*\.\d+))(px|rem|em)$/i; +const DURATION_LITERAL_REGEX = /^(-?(?:\d+|\d*\.\d+))(ms|s)$/i; +const WHITESPACE_REGEX = /\s+/g; +function formatNumber(value) { + if (Number.isInteger(value)) { + return String(value); + } + return value.toFixed(4).replace(/\.?0+$/, ""); +} +function normalizeTypographyValue(input) { + const trimmed = input.trim(); + if (!trimmed) + return null; + return trimmed + .split(",") + .map((part) => part.trim().replace(/^['"]|['"]$/g, "").toLowerCase()) + .filter(Boolean) + .join(", "); +} +function normalizeLayoutValue(input) { + const trimmed = input.trim(); + if (!trimmed) + return null; + if (/^0(?:[a-z%]+)?$/i.test(trimmed)) { + return "0px"; + } + const literalMatch = trimmed.match(LENGTH_LITERAL_REGEX); + if (literalMatch) { + const numericValue = Number.parseFloat(literalMatch[1]); + if (!Number.isFinite(numericValue)) + return null; + const unit = literalMatch[2].toLowerCase(); + const pxValue = unit === "px" ? numericValue : numericValue * 16; + return `${formatNumber(pxValue)}px`; + } + return trimmed.toLowerCase().replace(WHITESPACE_REGEX, " "); +} +function normalizeMotionValue(input) { + const trimmed = input.trim(); + if (!trimmed) + return null; + if (/^0(?:ms|s)?$/i.test(trimmed)) { + return "0ms"; + } + const durationMatch = trimmed.match(DURATION_LITERAL_REGEX); + if (durationMatch) { + const numericValue = Number.parseFloat(durationMatch[1]); + if (!Number.isFinite(numericValue)) + return null; + const unit = durationMatch[2].toLowerCase(); + const msValue = unit === "ms" ? numericValue : numericValue * 1000; + return `${formatNumber(msValue)}ms`; + } + return trimmed.toLowerCase().replace(/\s*,\s*/g, ",").replace(WHITESPACE_REGEX, " "); +} +export function normalizeTokenLiteralValue(category, input) { + if (category === "typography") { + return normalizeTypographyValue(input); + } + if (category === "layout") { + return normalizeLayoutValue(input); + } + return normalizeMotionValue(input); +} +export function matchTokenPolicy(policy, token) { + const observedToken = (token.observedValue ?? token.value).trim(); + const canonicalCandidate = token.value.trim(); + const normalizedValue = token.normalizedValue?.trim() || undefined; + if (!policy || policy.policy === "off") { + return { + observedToken, + canonicalToken: canonicalCandidate || undefined, + normalizedValue, + matched: true, + }; + } + const allowedTokens = new Set(policy.allowedTokens.map((value) => value.trim())); + if ((canonicalCandidate && allowedTokens.has(canonicalCandidate)) || + (observedToken && allowedTokens.has(observedToken))) { + return { + observedToken, + canonicalToken: canonicalCandidate || observedToken || undefined, + normalizedValue, + matched: true, + }; + } + for (const metadata of policy.tokenMetadata ?? []) { + if (!allowedTokens.has(metadata.token)) { + continue; + } + if (metadata.token === canonicalCandidate || + metadata.token === observedToken || + metadata.aliases.includes(observedToken) || + metadata.aliases.includes(canonicalCandidate) || + (normalizedValue !== undefined && metadata.normalizedValue === normalizedValue)) { + return { + observedToken, + canonicalToken: metadata.token, + normalizedValue: normalizedValue ?? metadata.normalizedValue, + matched: true, + }; + } + } + return { + observedToken, + canonicalToken: canonicalCandidate || undefined, + normalizedValue, + matched: false, + }; +} diff --git a/packages/interfacectl-validator/dist/types.d.ts b/packages/interfacectl-validator/dist/types.d.ts index 26b4b8d..43c2b3c 100644 --- a/packages/interfacectl-validator/dist/types.d.ts +++ b/packages/interfacectl-validator/dist/types.d.ts @@ -21,6 +21,8 @@ export interface LandingPatternPolicy { requireTopLevelSections?: string[]; sectionOrder?: string[]; pageBackgroundMode?: "solid" | "custom"; + marketingLayoutProfile?: string; + marketingLayoutPolicy?: "off" | "warn" | "strict"; } export interface FlowTransitionRequirement { from: string; @@ -43,6 +45,8 @@ export interface ContractSurface { type: SurfaceType; requiredSections: string[]; allowedFonts: string[]; + marketingTypographyProfile?: string; + marketingTypographyPolicy?: "off" | "warn" | "strict"; layout: { maxContentWidth: number; requiredContainers?: string[]; @@ -78,6 +82,49 @@ export interface IconPolicy { policy: "off" | "warn" | "strict"; allowedSources: string[]; } +export type TokenCategory = "typography" | "layout" | "motion"; +export interface TokenMetadata { + token: string; + normalizedValue: string; + attributes: string[]; + aliases: string[]; +} +export interface TokenPolicy { + policy: "off" | "warn" | "strict"; + allowedTokens: string[]; + tokenMetadata?: TokenMetadata[]; +} +export interface ContractTokenPolicies { + typography?: TokenPolicy; + layout?: TokenPolicy; + motion?: TokenPolicy; +} +export type MarketingHeroContainerMode = "open-flow" | "framed"; +export type MarketingHeroVisualPlacement = "inline-end" | "inline-start" | "stacked" | "none"; +export type MarketingSectionDividerMode = "none" | "border-top"; +export type MarketingSectionSpacingProfile = "compact" | "roomy"; +export type MarketingTypographyRole = "heroEyebrow" | "heroTitle" | "heroBody" | "sectionTitle" | "body"; +export interface MarketingLayoutProfile { + id: string; + description?: string; + heroContainerMode: MarketingHeroContainerMode; + heroVisualPlacement: MarketingHeroVisualPlacement; + sectionDividerMode: MarketingSectionDividerMode; + sectionSpacingProfile: MarketingSectionSpacingProfile; +} +export interface MarketingTypographyRoleProfile { + role: MarketingTypographyRole; + allowedTokens: string[]; +} +export interface MarketingTypographyProfile { + id: string; + description?: string; + roles: MarketingTypographyRoleProfile[]; +} +export interface ContractMarketingProfiles { + layout?: MarketingLayoutProfile[]; + typography?: MarketingTypographyProfile[]; +} export interface InterfaceContract { contractId: string; version: string; @@ -86,6 +133,8 @@ export interface InterfaceContract { sections: ContractSection[]; constraints: ContractConstraints; color: ColorPolicy; + tokens?: ContractTokenPolicies; + marketingProfiles?: ContractMarketingProfiles; x_extracted?: { routes?: string[]; hasShell?: boolean; @@ -117,6 +166,18 @@ export interface SurfaceMotionDescriptor { timingFunction: string; source?: string; } +export interface SurfaceTokenDescriptor { + value: string; + observedValue?: string; + source?: string; + attributes?: string[]; + normalizedValue?: string; +} +export interface SurfaceTokenUsage { + typography: SurfaceTokenDescriptor[]; + layout: SurfaceTokenDescriptor[]; + motion: SurfaceTokenDescriptor[]; +} export interface SurfacePrimitiveDescriptor { role: string; count: number; @@ -151,6 +212,21 @@ export interface LandingPatternDescriptor { topLevelSections: string[]; nestedSections: string[]; pageBackgroundMode?: "solid" | "custom" | "unknown"; + marketingLayoutProfile?: string; + heroContainerMode?: MarketingHeroContainerMode; + heroVisualPlacement?: MarketingHeroVisualPlacement; + sectionDividerMode?: MarketingSectionDividerMode; + sectionSpacingProfile?: MarketingSectionSpacingProfile; + source?: string; +} +export interface SurfaceMarketingTypographyRoleDescriptor { + role: MarketingTypographyRole; + tokens: SurfaceTokenDescriptor[]; + source?: string; +} +export interface SurfaceMarketingTypographyDescriptor { + profileId?: string; + roles: SurfaceMarketingTypographyRoleDescriptor[]; source?: string; } export interface ChromeLayoutDescriptor { @@ -175,13 +251,15 @@ export interface SurfaceDescriptor { fonts: SurfaceFontDescriptor[]; colors: SurfaceColorDescriptor[]; icons?: SurfaceIconDescriptor[]; + tokenUsage?: SurfaceTokenUsage; + marketingTypography?: SurfaceMarketingTypographyDescriptor; flows?: SurfaceFlowDescriptor[]; flowDescriptorPath?: string; layout: SurfaceLayoutDescriptor; 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-minwidth-mismatch" | "layout-pageframe-padding-mismatch" | "layout-pageframe-non-deterministic-value" | "layout-pageframe-unextractable-value" | "landing-pattern-signal-missing" | "landing-pattern-top-level-missing" | "landing-pattern-section-order" | "landing-pattern-section-nested" | "landing-pattern-background-mode" | "motion-duration-not-allowed" | "motion-timing-not-allowed" | "descriptor-flows-missing" | "flow-required-missing" | "flow-steps-min" | "flow-steps-required" | "flow-transition-required" | "flow-terminal-invalid" | "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" | "token-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" | "landing-pattern-signal-missing" | "landing-pattern-top-level-missing" | "landing-pattern-section-order" | "landing-pattern-section-nested" | "landing-pattern-background-mode" | "landing-pattern-marketing-layout-missing" | "landing-pattern-hero-container-mode" | "landing-pattern-hero-visual-placement" | "landing-pattern-section-divider-mode" | "landing-pattern-section-spacing-profile" | "marketing-typography-profile-missing" | "marketing-typography-role-missing" | "marketing-typography-role-token" | "motion-duration-not-allowed" | "motion-timing-not-allowed" | "descriptor-flows-missing" | "flow-required-missing" | "flow-steps-min" | "flow-steps-required" | "flow-transition-required" | "flow-terminal-invalid" | "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 caa9c99..87e3798 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 f7e878c..c3439e4 100644 --- a/packages/interfacectl-validator/src/index.ts +++ b/packages/interfacectl-validator/src/index.ts @@ -9,14 +9,15 @@ import { ContractSection, ContractSurface, PageFrameLayoutDescriptor, - ChromeLayoutDescriptor, - ChromePolicyTarget, - ChromeShadowKind, + TokenCategory, + TokenMetadata, + TokenPolicy, } from "./types.js"; import bundledSchema from "./schema/web.surface.contract.schema.json" with { type: "json", }; import { normalizeColorValue } from "./color-policy.js"; +import { matchTokenPolicy, normalizeTokenLiteralValue } from "./token-policy.js"; const frozenBundledSchema = Object.freeze(bundledSchema) as object; @@ -53,6 +54,16 @@ export function validateContractStructure( }; } + const marketingReferenceErrors = validateMarketingProfileReferences( + contractData as InterfaceContract, + ); + if (marketingReferenceErrors.length > 0) { + return { + ok: false, + errors: marketingReferenceErrors, + }; + } + return { ok: true, errors: [], @@ -60,6 +71,36 @@ export function validateContractStructure( }; } +function validateMarketingProfileReferences( + contract: InterfaceContract, +): string[] { + const errors: string[] = []; + const layoutProfiles = new Set( + (contract.marketingProfiles?.layout ?? []).map((profile) => profile.id), + ); + const typographyProfiles = new Set( + (contract.marketingProfiles?.typography ?? []).map((profile) => profile.id), + ); + + for (const surface of contract.surfaces) { + const layoutProfileRef = surface.layout.landingPattern?.marketingLayoutProfile; + if (layoutProfileRef && !layoutProfiles.has(layoutProfileRef)) { + errors.push( + `/surfaces/${surface.id}/layout/landingPattern/marketingLayoutProfile must reference a declared marketingProfiles.layout id`, + ); + } + + const typographyProfileRef = surface.marketingTypographyProfile; + if (typographyProfileRef && !typographyProfiles.has(typographyProfileRef)) { + errors.push( + `/surfaces/${surface.id}/marketingTypographyProfile must reference a declared marketingProfiles.typography id`, + ); + } + } + + return errors; +} + function validateColorPolicy( contract: InterfaceContract, descriptor: SurfaceDescriptor, @@ -134,6 +175,88 @@ function validateIconPolicy( } } +function validateTokenPolicyCategory( + surfaceId: string, + category: "typography" | "layout" | "motion", + policy: TokenPolicy | undefined, + observedTokens: SurfaceDescriptor["tokenUsage"] extends infer T + ? T extends { [K in typeof category]: infer U } + ? U + : never + : never, + violations: DriftViolation[], +): void { + if (!policy || policy.policy === "off") { + return; + } + + const allowedTokens = new Set(policy.allowedTokens); + const seenTokens = new Set(); + + for (const token of observedTokens ?? []) { + const observedToken = (token.observedValue ?? token.value).trim(); + const dedupeKey = `${token.value.trim()}::${observedToken}::${token.normalizedValue ?? ""}`; + if (!observedToken || seenTokens.has(dedupeKey)) { + continue; + } + seenTokens.add(dedupeKey); + + const match = matchTokenPolicy(policy, token); + if (match.matched) { + continue; + } + + violations.push({ + surfaceId, + type: "token-not-allowed", + message: `${category} token "${observedToken}" is not allowed for surface "${surfaceId}".`, + details: { + token: observedToken, + canonicalToken: match.canonicalToken, + normalizedValue: match.normalizedValue, + tokenCategory: category, + source: token.source, + allowedTokens: [...allowedTokens].sort((a, b) => a.localeCompare(b)), + policy: policy.policy, + jsonPointer: `/tokens/${category}/allowedTokens`, + }, + }); + } +} + +function validateTokenPolicies( + contract: InterfaceContract, + descriptor: SurfaceDescriptor, + violations: DriftViolation[], +): void { + const tokenUsage = descriptor.tokenUsage; + if (!contract.tokens || !tokenUsage) { + return; + } + + validateTokenPolicyCategory( + descriptor.surfaceId, + "typography", + contract.tokens.typography, + tokenUsage.typography, + violations, + ); + validateTokenPolicyCategory( + descriptor.surfaceId, + "layout", + contract.tokens.layout, + tokenUsage.layout, + violations, + ); + validateTokenPolicyCategory( + descriptor.surfaceId, + "motion", + contract.tokens.motion, + tokenUsage.motion, + violations, + ); +} + function validateFlowPolicy( surface: ContractSurface, descriptor: SurfaceDescriptor, @@ -291,6 +414,7 @@ function validateFlowPolicy( function validateLandingPattern( surface: ContractSurface, + contract: InterfaceContract, descriptor: SurfaceDescriptor, violations: DriftViolation[], ): void { @@ -400,10 +524,277 @@ function validateLandingPattern( actualBackgroundMode: descriptorPattern.pageBackgroundMode, source: descriptorPattern.source, }, + }); + } + + validateMarketingLandingPattern(surface, contract, descriptor, violations); +} + +function validateMarketingLandingPattern( + surface: ContractSurface, + contract: InterfaceContract, + descriptor: SurfaceDescriptor, + violations: DriftViolation[], +): void { + const landingPattern = surface.layout.landingPattern; + const policy = + landingPattern?.marketingLayoutPolicy ?? + (landingPattern?.marketingLayoutProfile ? "warn" : "off"); + + if (!landingPattern || policy === "off" || !landingPattern.marketingLayoutProfile) { + return; + } + + const descriptorPattern = descriptor.layout.landingPattern; + const expectedProfile = contract.marketingProfiles?.layout?.find( + (profile) => profile.id === landingPattern.marketingLayoutProfile, + ); + + if (!expectedProfile) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-marketing-layout-missing", + message: + `Marketing layout profile "${landingPattern.marketingLayoutProfile}" is missing from the contract for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId: landingPattern.marketingLayoutProfile, + policy, + }, + }); + return; + } + + if (!descriptorPattern) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-marketing-layout-missing", + message: + `Marketing layout signals are missing for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId: expectedProfile.id, + policy, + }, + }); + return; + } + + if ( + !descriptorPattern.marketingLayoutProfile || + descriptorPattern.marketingLayoutProfile !== expectedProfile.id + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-marketing-layout-missing", + message: + `Surface "${descriptor.surfaceId}" must declare marketing layout profile "${expectedProfile.id}".`, + details: { + expectedProfileId: expectedProfile.id, + actualProfileId: descriptorPattern.marketingLayoutProfile, + policy, + source: descriptorPattern.source, + }, + }); + } + + if ( + descriptorPattern.heroContainerMode && + descriptorPattern.heroContainerMode !== expectedProfile.heroContainerMode + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-hero-container-mode", + message: + `Hero container mode for surface "${descriptor.surfaceId}" must be "${expectedProfile.heroContainerMode}".`, + details: { + expectedHeroContainerMode: expectedProfile.heroContainerMode, + actualHeroContainerMode: descriptorPattern.heroContainerMode, + policy, + source: descriptorPattern.source, + }, + }); + } + + if ( + descriptorPattern.heroVisualPlacement && + descriptorPattern.heroVisualPlacement !== expectedProfile.heroVisualPlacement + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-hero-visual-placement", + message: + `Hero visual placement for surface "${descriptor.surfaceId}" must be "${expectedProfile.heroVisualPlacement}".`, + details: { + expectedHeroVisualPlacement: expectedProfile.heroVisualPlacement, + actualHeroVisualPlacement: descriptorPattern.heroVisualPlacement, + policy, + source: descriptorPattern.source, + }, + }); + } + + if ( + descriptorPattern.sectionDividerMode && + descriptorPattern.sectionDividerMode !== expectedProfile.sectionDividerMode + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-section-divider-mode", + message: + `Section divider mode for surface "${descriptor.surfaceId}" must be "${expectedProfile.sectionDividerMode}".`, + details: { + expectedSectionDividerMode: expectedProfile.sectionDividerMode, + actualSectionDividerMode: descriptorPattern.sectionDividerMode, + policy, + source: descriptorPattern.source, + }, + }); + } + + if ( + descriptorPattern.sectionSpacingProfile && + descriptorPattern.sectionSpacingProfile !== expectedProfile.sectionSpacingProfile + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "landing-pattern-section-spacing-profile", + message: + `Section spacing profile for surface "${descriptor.surfaceId}" must be "${expectedProfile.sectionSpacingProfile}".`, + details: { + expectedSectionSpacingProfile: expectedProfile.sectionSpacingProfile, + actualSectionSpacingProfile: descriptorPattern.sectionSpacingProfile, + policy, + source: descriptorPattern.source, + }, }); } } +function validateMarketingTypography( + surface: ContractSurface, + contract: InterfaceContract, + descriptor: SurfaceDescriptor, + violations: DriftViolation[], +): void { + const expectedProfileId = surface.marketingTypographyProfile; + const policy = + surface.marketingTypographyPolicy ?? + (expectedProfileId ? "warn" : "off"); + + if (!expectedProfileId || policy === "off") { + return; + } + + const expectedProfile = contract.marketingProfiles?.typography?.find( + (profile) => profile.id === expectedProfileId, + ); + if (!expectedProfile) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-profile-missing", + message: + `Marketing typography profile "${expectedProfileId}" is missing from the contract for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId, + policy, + }, + }); + return; + } + + const observedTypography = descriptor.marketingTypography; + if (!observedTypography) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-profile-missing", + message: + `Marketing typography signals are missing for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId, + policy, + }, + }); + return; + } + + if ( + !observedTypography.profileId || + observedTypography.profileId !== expectedProfileId + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-profile-missing", + message: + `Surface "${descriptor.surfaceId}" must declare marketing typography profile "${expectedProfileId}".`, + details: { + expectedProfileId, + actualProfileId: observedTypography.profileId, + policy, + source: observedTypography.source, + }, + }); + } + + const roleDescriptors = new Map( + observedTypography.roles.map((roleDescriptor) => [ + roleDescriptor.role, + roleDescriptor, + ]), + ); + + const roleTokenMetadata = contract.tokens?.typography?.tokenMetadata ?? []; + + for (const expectedRole of expectedProfile.roles) { + const observedRole = roleDescriptors.get(expectedRole.role); + if (!observedRole || observedRole.tokens.length === 0) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-role-missing", + message: + `Marketing typography role "${expectedRole.role}" is missing for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId, + role: expectedRole.role, + policy, + source: observedRole?.source ?? observedTypography.source, + }, + }); + continue; + } + + const rolePolicy: TokenPolicy = { + policy, + allowedTokens: expectedRole.allowedTokens, + tokenMetadata: roleTokenMetadata.filter((entry) => + expectedRole.allowedTokens.includes(entry.token), + ), + }; + + for (const token of observedRole.tokens) { + const match = matchTokenPolicy(rolePolicy, token); + if (match.matched) { + continue; + } + + violations.push({ + surfaceId: descriptor.surfaceId, + type: "marketing-typography-role-token", + message: + `Marketing typography role "${expectedRole.role}" uses a token outside profile "${expectedProfileId}" for surface "${descriptor.surfaceId}".`, + details: { + expectedProfileId, + role: expectedRole.role, + token: token.observedValue ?? token.value, + canonicalToken: match.canonicalToken, + normalizedValue: match.normalizedValue, + allowedTokens: expectedRole.allowedTokens, + policy, + source: token.source ?? observedRole.source, + }, + }); + } + } +} + export function evaluateSurfaceCompliance( contract: InterfaceContract, descriptor: SurfaceDescriptor, @@ -539,9 +930,11 @@ export function evaluateSurfaceCompliance( } validateColorPolicy(contract, descriptor, violations); + validateTokenPolicies(contract, descriptor, violations); validateIconPolicy(surface, descriptor, violations); validateFlowPolicy(surface, descriptor, violations); - validateLandingPattern(surface, descriptor, violations); + validateLandingPattern(surface, contract, descriptor, violations); + validateMarketingTypography(surface, contract, descriptor, violations); const reportedWidth = descriptor.layout.maxContentWidth; if (reportedWidth === null || reportedWidth === undefined) { @@ -933,11 +1326,14 @@ export type { ContractSurface, ContractSection, ContractConstraints, + ContractTokenPolicies, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceIconDescriptor, + SurfaceTokenDescriptor, + SurfaceTokenUsage, SurfaceMotionDescriptor, SurfaceLayoutDescriptor, PageFrameLayoutDescriptor, @@ -961,10 +1357,24 @@ export type { EnforcementPolicy, EnforcementMode, IconPolicy, + TokenCategory, + TokenMetadata, + TokenPolicy, + ContractMarketingProfiles, + MarketingLayoutProfile, + MarketingTypographyProfile, + MarketingTypographyRoleProfile, + MarketingHeroContainerMode, + MarketingHeroVisualPlacement, + MarketingSectionDividerMode, + MarketingSectionSpacingProfile, + MarketingTypographyRole, FlowPolicy, FlowRequirement, FlowTransitionRequirement, LandingPatternPolicy, + SurfaceMarketingTypographyDescriptor, + SurfaceMarketingTypographyRoleDescriptor, SurfaceFlowDescriptor, SurfaceFlowStepDescriptor, SurfaceFlowTransitionDescriptor, @@ -988,3 +1398,9 @@ export { normalizeColorValue, normalizeColorValues, } from "./color-policy.js"; + +export { + matchTokenPolicy, + normalizeTokenLiteralValue, + type TokenPolicyMatch, +} from "./token-policy.js"; 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 f710cdf..16167e3 100644 --- a/packages/interfacectl-validator/src/schema/web.surface.contract.schema.json +++ b/packages/interfacectl-validator/src/schema/web.surface.contract.schema.json @@ -49,6 +49,12 @@ "color": { "$ref": "#/$defs/color" }, + "tokens": { + "$ref": "#/$defs/contractTokens" + }, + "marketingProfiles": { + "$ref": "#/$defs/marketingProfiles" + }, "x_extracted": { "type": "object", "description": "Phase 0: fields extracted from app code; not used by validator semantics.", @@ -134,6 +140,18 @@ }, "uniqueItems": true }, + "marketingTypographyProfile": { + "type": "string", + "minLength": 1 + }, + "marketingTypographyPolicy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] + }, "layout": { "type": "object", "additionalProperties": false, @@ -395,6 +413,210 @@ } } }, + "tokenPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy", + "allowedTokens" + ], + "properties": { + "policy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] + }, + "allowedTokens": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "tokenMetadata": { + "type": "array", + "items": { + "$ref": "#/$defs/tokenMetadata" + } + } + } + }, + "tokenMetadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "token", + "normalizedValue", + "attributes", + "aliases" + ], + "properties": { + "token": { + "type": "string", + "minLength": 1 + }, + "normalizedValue": { + "type": "string", + "minLength": 1 + }, + "attributes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + }, + "aliases": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, + "contractTokens": { + "type": "object", + "additionalProperties": false, + "properties": { + "typography": { + "$ref": "#/$defs/tokenPolicy" + }, + "layout": { + "$ref": "#/$defs/tokenPolicy" + }, + "motion": { + "$ref": "#/$defs/tokenPolicy" + } + } + }, + "marketingProfiles": { + "type": "object", + "additionalProperties": false, + "properties": { + "layout": { + "type": "array", + "items": { + "$ref": "#/$defs/marketingLayoutProfile" + } + }, + "typography": { + "type": "array", + "items": { + "$ref": "#/$defs/marketingTypographyProfile" + } + } + } + }, + "marketingLayoutProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "heroContainerMode", + "heroVisualPlacement", + "sectionDividerMode", + "sectionSpacingProfile" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "heroContainerMode": { + "type": "string", + "enum": [ + "open-flow", + "framed" + ] + }, + "heroVisualPlacement": { + "type": "string", + "enum": [ + "inline-end", + "inline-start", + "stacked", + "none" + ] + }, + "sectionDividerMode": { + "type": "string", + "enum": [ + "none", + "border-top" + ] + }, + "sectionSpacingProfile": { + "type": "string", + "enum": [ + "compact", + "roomy" + ] + } + } + }, + "marketingTypographyProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "roles" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "roles": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/marketingTypographyRoleProfile" + } + } + } + }, + "marketingTypographyRoleProfile": { + "type": "object", + "additionalProperties": false, + "required": [ + "role", + "allowedTokens" + ], + "properties": { + "role": { + "type": "string", + "enum": [ + "heroEyebrow", + "heroTitle", + "heroBody", + "sectionTitle", + "body" + ] + }, + "allowedTokens": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + }, + "uniqueItems": true + } + } + }, "landingPattern": { "type": "object", "additionalProperties": false, @@ -432,6 +654,18 @@ "solid", "custom" ] + }, + "marketingLayoutProfile": { + "type": "string", + "minLength": 1 + }, + "marketingLayoutPolicy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] } } }, diff --git a/packages/interfacectl-validator/src/token-policy.ts b/packages/interfacectl-validator/src/token-policy.ts new file mode 100644 index 0000000..850fe82 --- /dev/null +++ b/packages/interfacectl-validator/src/token-policy.ts @@ -0,0 +1,144 @@ +import type { + SurfaceTokenDescriptor, + TokenCategory, + TokenPolicy, +} from "./types.js"; + +const LENGTH_LITERAL_REGEX = /^(-?(?:\d+|\d*\.\d+))(px|rem|em)$/i; +const DURATION_LITERAL_REGEX = /^(-?(?:\d+|\d*\.\d+))(ms|s)$/i; +const WHITESPACE_REGEX = /\s+/g; + +function formatNumber(value: number): string { + if (Number.isInteger(value)) { + return String(value); + } + return value.toFixed(4).replace(/\.?0+$/, ""); +} + +function normalizeTypographyValue(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + + return trimmed + .split(",") + .map((part) => part.trim().replace(/^['"]|['"]$/g, "").toLowerCase()) + .filter(Boolean) + .join(", "); +} + +function normalizeLayoutValue(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if (/^0(?:[a-z%]+)?$/i.test(trimmed)) { + return "0px"; + } + + const literalMatch = trimmed.match(LENGTH_LITERAL_REGEX); + if (literalMatch) { + const numericValue = Number.parseFloat(literalMatch[1]); + if (!Number.isFinite(numericValue)) return null; + const unit = literalMatch[2].toLowerCase(); + const pxValue = + unit === "px" ? numericValue : numericValue * 16; + return `${formatNumber(pxValue)}px`; + } + + return trimmed.toLowerCase().replace(WHITESPACE_REGEX, " "); +} + +function normalizeMotionValue(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if (/^0(?:ms|s)?$/i.test(trimmed)) { + return "0ms"; + } + + const durationMatch = trimmed.match(DURATION_LITERAL_REGEX); + if (durationMatch) { + const numericValue = Number.parseFloat(durationMatch[1]); + if (!Number.isFinite(numericValue)) return null; + const unit = durationMatch[2].toLowerCase(); + const msValue = unit === "ms" ? numericValue : numericValue * 1000; + return `${formatNumber(msValue)}ms`; + } + + return trimmed.toLowerCase().replace(/\s*,\s*/g, ",").replace(WHITESPACE_REGEX, " "); +} + +export function normalizeTokenLiteralValue( + category: TokenCategory, + input: string, +): string | null { + if (category === "typography") { + return normalizeTypographyValue(input); + } + if (category === "layout") { + return normalizeLayoutValue(input); + } + return normalizeMotionValue(input); +} + +export interface TokenPolicyMatch { + canonicalToken?: string; + normalizedValue?: string; + observedToken: string; + matched: boolean; +} + +export function matchTokenPolicy( + policy: TokenPolicy | undefined, + token: SurfaceTokenDescriptor, +): TokenPolicyMatch { + const observedToken = (token.observedValue ?? token.value).trim(); + const canonicalCandidate = token.value.trim(); + const normalizedValue = token.normalizedValue?.trim() || undefined; + + if (!policy || policy.policy === "off") { + return { + observedToken, + canonicalToken: canonicalCandidate || undefined, + normalizedValue, + matched: true, + }; + } + + const allowedTokens = new Set(policy.allowedTokens.map((value) => value.trim())); + if ( + (canonicalCandidate && allowedTokens.has(canonicalCandidate)) || + (observedToken && allowedTokens.has(observedToken)) + ) { + return { + observedToken, + canonicalToken: canonicalCandidate || observedToken || undefined, + normalizedValue, + matched: true, + }; + } + + for (const metadata of policy.tokenMetadata ?? []) { + if (!allowedTokens.has(metadata.token)) { + continue; + } + if ( + metadata.token === canonicalCandidate || + metadata.token === observedToken || + metadata.aliases.includes(observedToken) || + metadata.aliases.includes(canonicalCandidate) || + (normalizedValue !== undefined && metadata.normalizedValue === normalizedValue) + ) { + return { + observedToken, + canonicalToken: metadata.token, + normalizedValue: normalizedValue ?? metadata.normalizedValue, + matched: true, + }; + } + } + + return { + observedToken, + canonicalToken: canonicalCandidate || undefined, + normalizedValue, + matched: false, + }; +} diff --git a/packages/interfacectl-validator/src/types.ts b/packages/interfacectl-validator/src/types.ts index 6c85c45..e5c5ded 100644 --- a/packages/interfacectl-validator/src/types.ts +++ b/packages/interfacectl-validator/src/types.ts @@ -29,6 +29,8 @@ export interface LandingPatternPolicy { requireTopLevelSections?: string[]; sectionOrder?: string[]; pageBackgroundMode?: "solid" | "custom"; + marketingLayoutProfile?: string; + marketingLayoutPolicy?: "off" | "warn" | "strict"; } export interface FlowTransitionRequirement { @@ -55,6 +57,8 @@ export interface ContractSurface { type: SurfaceType; requiredSections: string[]; allowedFonts: string[]; + marketingTypographyProfile?: string; + marketingTypographyPolicy?: "off" | "warn" | "strict"; layout: { maxContentWidth: number; requiredContainers?: string[]; @@ -96,6 +100,67 @@ export interface IconPolicy { allowedSources: string[]; } +export type TokenCategory = "typography" | "layout" | "motion"; + +export interface TokenMetadata { + token: string; + normalizedValue: string; + attributes: string[]; + aliases: string[]; +} + +export interface TokenPolicy { + policy: "off" | "warn" | "strict"; + allowedTokens: string[]; + tokenMetadata?: TokenMetadata[]; +} + +export interface ContractTokenPolicies { + typography?: TokenPolicy; + layout?: TokenPolicy; + motion?: TokenPolicy; +} + +export type MarketingHeroContainerMode = "open-flow" | "framed"; +export type MarketingHeroVisualPlacement = + | "inline-end" + | "inline-start" + | "stacked" + | "none"; +export type MarketingSectionDividerMode = "none" | "border-top"; +export type MarketingSectionSpacingProfile = "compact" | "roomy"; +export type MarketingTypographyRole = + | "heroEyebrow" + | "heroTitle" + | "heroBody" + | "sectionTitle" + | "body"; + +export interface MarketingLayoutProfile { + id: string; + description?: string; + heroContainerMode: MarketingHeroContainerMode; + heroVisualPlacement: MarketingHeroVisualPlacement; + sectionDividerMode: MarketingSectionDividerMode; + sectionSpacingProfile: MarketingSectionSpacingProfile; +} + +export interface MarketingTypographyRoleProfile { + role: MarketingTypographyRole; + allowedTokens: string[]; +} + +export interface MarketingTypographyProfile { + id: string; + description?: string; + roles: MarketingTypographyRoleProfile[]; +} + +export interface ContractMarketingProfiles { + layout?: MarketingLayoutProfile[]; + typography?: MarketingTypographyProfile[]; +} + export interface InterfaceContract { contractId: string; version: string; @@ -104,6 +169,8 @@ export interface InterfaceContract { sections: ContractSection[]; constraints: ContractConstraints; color: ColorPolicy; + tokens?: ContractTokenPolicies; + marketingProfiles?: ContractMarketingProfiles; x_extracted?: { routes?: string[]; hasShell?: boolean; @@ -141,6 +208,20 @@ export interface SurfaceMotionDescriptor { source?: string; } +export interface SurfaceTokenDescriptor { + value: string; + observedValue?: string; + source?: string; + attributes?: string[]; + normalizedValue?: string; +} + +export interface SurfaceTokenUsage { + typography: SurfaceTokenDescriptor[]; + layout: SurfaceTokenDescriptor[]; + motion: SurfaceTokenDescriptor[]; +} + export interface SurfacePrimitiveDescriptor { role: string; count: number; @@ -180,6 +261,23 @@ export interface LandingPatternDescriptor { topLevelSections: string[]; nestedSections: string[]; pageBackgroundMode?: "solid" | "custom" | "unknown"; + marketingLayoutProfile?: string; + heroContainerMode?: MarketingHeroContainerMode; + heroVisualPlacement?: MarketingHeroVisualPlacement; + sectionDividerMode?: MarketingSectionDividerMode; + sectionSpacingProfile?: MarketingSectionSpacingProfile; + source?: string; +} + +export interface SurfaceMarketingTypographyRoleDescriptor { + role: MarketingTypographyRole; + tokens: SurfaceTokenDescriptor[]; + source?: string; +} + +export interface SurfaceMarketingTypographyDescriptor { + profileId?: string; + roles: SurfaceMarketingTypographyRoleDescriptor[]; source?: string; } @@ -207,6 +305,8 @@ export interface SurfaceDescriptor { fonts: SurfaceFontDescriptor[]; colors: SurfaceColorDescriptor[]; icons?: SurfaceIconDescriptor[]; + tokenUsage?: SurfaceTokenUsage; + marketingTypography?: SurfaceMarketingTypographyDescriptor; flows?: SurfaceFlowDescriptor[]; flowDescriptorPath?: string; layout: SurfaceLayoutDescriptor; @@ -221,6 +321,7 @@ export type DriftViolationType = | "font-not-allowed" | "color-not-allowed" | "icon-source-not-allowed" + | "token-not-allowed" | "layout-width-exceeded" | "layout-width-undetermined" | "layout-container-missing" @@ -236,6 +337,14 @@ export type DriftViolationType = | "landing-pattern-section-order" | "landing-pattern-section-nested" | "landing-pattern-background-mode" + | "landing-pattern-marketing-layout-missing" + | "landing-pattern-hero-container-mode" + | "landing-pattern-hero-visual-placement" + | "landing-pattern-section-divider-mode" + | "landing-pattern-section-spacing-profile" + | "marketing-typography-profile-missing" + | "marketing-typography-role-missing" + | "marketing-typography-role-token" | "motion-duration-not-allowed" | "motion-timing-not-allowed" | "descriptor-flows-missing" diff --git a/packages/interfacectl-validator/test/contract-structure.test.mjs b/packages/interfacectl-validator/test/contract-structure.test.mjs index 045a0b1..73b156f 100644 --- a/packages/interfacectl-validator/test/contract-structure.test.mjs +++ b/packages/interfacectl-validator/test/contract-structure.test.mjs @@ -148,6 +148,124 @@ test("validateContractStructure accepts optional surface.layout.chromePolicy", ( assert.equal(result.ok, true); }); +test("validateContractStructure accepts optional top-level token policies", () => { + const schema = getBundledContractSchema(); + const contract = buildContract({}, { + tokens: { + typography: { + policy: "warn", + allowedTokens: ["var(--text-body)", "var(--font-sans)"], + tokenMetadata: [ + { + token: "var(--font-sans)", + normalizedValue: "inter, sans-serif", + attributes: ["font-family", "font-size"], + aliases: ["var(--text-body)"], + }, + ], + }, + layout: { + policy: "warn", + allowedTokens: ["var(--space-6)", "var(--container-xl)"], + }, + motion: { + policy: "warn", + allowedTokens: ["var(--motion-duration-fast)", "var(--motion-ease-standard)"], + }, + }, + }); + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, true); +}); + +test("validateContractStructure accepts optional marketing layout and typography profiles", () => { + const schema = getBundledContractSchema(); + const contract = buildContract({ + marketingTypographyProfile: "marketing-sans", + marketingTypographyPolicy: "warn", + layout: { + maxContentWidth: 1200, + landingPattern: { + policy: "strict", + marketingLayoutProfile: "marketing-open-flow-split", + marketingLayoutPolicy: "warn", + }, + }, + }, { + marketingProfiles: { + layout: [ + { + id: "marketing-open-flow-split", + heroContainerMode: "open-flow", + heroVisualPlacement: "inline-end", + sectionDividerMode: "border-top", + sectionSpacingProfile: "roomy", + }, + ], + typography: [ + { + id: "marketing-sans", + roles: [ + { + role: "heroTitle", + allowedTokens: ["var(--marketing-sans-hero-title-size)"], + }, + ], + }, + ], + }, + }); + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, true); +}); + +test("validateContractStructure rejects invalid marketing profile references", () => { + const schema = getBundledContractSchema(); + const contract = buildContract({ + marketingTypographyProfile: "missing-typography-profile", + layout: { + maxContentWidth: 1200, + landingPattern: { + policy: "strict", + marketingLayoutProfile: "missing-layout-profile", + }, + }, + }, { + marketingProfiles: { + layout: [ + { + id: "marketing-open-flow-split", + heroContainerMode: "open-flow", + heroVisualPlacement: "inline-end", + sectionDividerMode: "border-top", + sectionSpacingProfile: "roomy", + }, + ], + typography: [ + { + id: "marketing-sans", + roles: [ + { + role: "heroTitle", + allowedTokens: ["var(--marketing-sans-hero-title-size)"], + }, + ], + }, + ], + }, + }); + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("marketingLayoutProfile")), + `expected marketingLayoutProfile error, got ${JSON.stringify(result.errors)}`, + ); + assert.ok( + result.errors.some((error) => error.includes("marketingTypographyProfile")), + `expected marketingTypographyProfile error, got ${JSON.stringify(result.errors)}`, + ); +}); + test("validateContractStructure rejects invalid surface.layout.chromePolicy target", () => { const schema = getBundledContractSchema(); const contract = buildContract({ diff --git a/packages/interfacectl-validator/test/evaluate-contract.test.mjs b/packages/interfacectl-validator/test/evaluate-contract.test.mjs index 8a4ed56..b22aeb0 100644 --- a/packages/interfacectl-validator/test/evaluate-contract.test.mjs +++ b/packages/interfacectl-validator/test/evaluate-contract.test.mjs @@ -339,6 +339,312 @@ test("icon policy warn emits policy metadata for warn-level handling", () => { assert.equal(iconViolation.details?.policy, "warn"); }); +test("marketing layout and typography drift emit warn-mode violations", () => { + const contract = { + ...baseContract, + tokens: { + typography: { + policy: "warn", + allowedTokens: [ + "var(--marketing-sans-hero-title-size)", + "var(--marketing-sans-body-size)", + ], + }, + }, + marketingProfiles: { + layout: [ + { + id: "marketing-open-flow-split", + heroContainerMode: "open-flow", + heroVisualPlacement: "inline-end", + sectionDividerMode: "border-top", + sectionSpacingProfile: "roomy", + }, + ], + typography: [ + { + id: "marketing-sans", + roles: [ + { + role: "heroTitle", + allowedTokens: ["var(--marketing-sans-hero-title-size)"], + }, + { + role: "body", + allowedTokens: ["var(--marketing-sans-body-size)"], + }, + ], + }, + ], + }, + surfaces: [ + { + id: "surface-marketing", + displayName: "Surface Marketing", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-marketing)"], + marketingTypographyProfile: "marketing-sans", + marketingTypographyPolicy: "warn", + layout: { + maxContentWidth: 1200, + requiredContainers: [], + landingPattern: { + policy: "strict", + marketingLayoutProfile: "marketing-open-flow-split", + marketingLayoutPolicy: "warn", + }, + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-marketing", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-marketing)" }], + colors: [{ value: "var(--color-primary)" }], + tokenUsage: { + typography: [{ value: "var(--marketing-sans-hero-title-size)" }], + layout: [], + motion: [], + }, + marketingTypography: { + profileId: "marketing-sans", + roles: [ + { + role: "heroTitle", + tokens: [{ value: "var(--marketing-sans-body-size)" }], + }, + ], + }, + layout: { + maxContentWidth: 1000, + containers: [], + landingPattern: { + sectionOrder: ["main.hero"], + topLevelSections: ["main.hero"], + nestedSections: [], + marketingLayoutProfile: "marketing-open-flow-split", + heroContainerMode: "framed", + heroVisualPlacement: "inline-end", + sectionDividerMode: "border-top", + sectionSpacingProfile: "roomy", + }, + }, + motion: [], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const marketingViolations = report.violations.filter((violation) => + violation.type.startsWith("landing-pattern-") || + violation.type.startsWith("marketing-typography-"), + ); + + assert.ok( + marketingViolations.some( + (violation) => violation.type === "landing-pattern-hero-container-mode", + ), + ); + assert.ok( + marketingViolations.some( + (violation) => violation.type === "marketing-typography-role-token", + ), + ); + assert.ok( + marketingViolations.every((violation) => violation.details?.policy === "warn"), + ); +}); + +test("token policies report disallowed typography, layout, and motion tokens", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-token-policy", + displayName: "Surface Token Policy", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-sans)"], + layout: { + maxContentWidth: 1200, + requiredContainers: [], + }, + }, + ], + tokens: { + typography: { + policy: "warn", + allowedTokens: ["var(--text-body)", "var(--font-sans)"], + }, + layout: { + policy: "strict", + allowedTokens: ["var(--space-6)"], + }, + motion: { + policy: "warn", + allowedTokens: ["var(--motion-duration-fast)"], + }, + }, + }; + + const descriptor = { + surfaceId: "surface-token-policy", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-sans)" }], + colors: [{ value: "var(--color-primary)" }], + tokenUsage: { + typography: [{ value: "var(--text-display)", source: "app/globals.css" }], + layout: [{ value: "var(--space-12)", source: "app/globals.css" }], + motion: [{ value: "var(--motion-duration-slow)", source: "app/globals.css" }], + }, + layout: { + maxContentWidth: 1000, + containers: [], + }, + motion: [], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const tokenViolations = report.violations.filter((item) => item.type === "token-not-allowed"); + assert.equal(tokenViolations.length, 3); + assert.deepEqual( + tokenViolations.map((item) => item.details?.tokenCategory).sort(), + ["layout", "motion", "typography"], + ); + assert.equal( + tokenViolations.find((item) => item.details?.tokenCategory === "layout")?.details?.policy, + "strict", + ); +}); + +test("token policy accepts observed aliases that normalize to an allowlisted canonical token", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-token-alias", + displayName: "Surface Token Alias", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-sans)"], + layout: { + maxContentWidth: 1200, + requiredContainers: [], + }, + }, + ], + tokens: { + layout: { + policy: "strict", + allowedTokens: ["var(--space-4)"], + tokenMetadata: [ + { + token: "var(--space-4)", + normalizedValue: "16px", + attributes: ["padding-inline"], + aliases: ["var(--space-md)"], + }, + ], + }, + }, + }; + + const descriptor = { + surfaceId: "surface-token-alias", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-sans)" }], + colors: [{ value: "var(--color-primary)" }], + tokenUsage: { + typography: [], + layout: [ + { + value: "var(--space-4)", + observedValue: "var(--space-md)", + normalizedValue: "16px", + attributes: ["padding-inline"], + source: "app/globals.css", + }, + ], + motion: [], + }, + layout: { + maxContentWidth: 1000, + containers: [], + }, + motion: [], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const tokenViolations = report.violations.filter((item) => item.type === "token-not-allowed"); + assert.equal(tokenViolations.length, 0); +}); + +test("token policy violation exposes normalized details for unmatched token groups", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-token-details", + displayName: "Surface Token Details", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-sans)"], + layout: { + maxContentWidth: 1200, + requiredContainers: [], + }, + }, + ], + tokens: { + motion: { + policy: "warn", + allowedTokens: ["var(--motion-duration-fast)"], + tokenMetadata: [ + { + token: "var(--motion-duration-fast)", + normalizedValue: "200ms", + attributes: ["transition-duration"], + aliases: [], + }, + ], + }, + }, + }; + + const descriptor = { + surfaceId: "surface-token-details", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-sans)" }], + colors: [{ value: "var(--color-primary)" }], + tokenUsage: { + typography: [], + layout: [], + motion: [ + { + value: "var(--motion-duration-slow)", + observedValue: "var(--motion-duration-slow)", + normalizedValue: "300ms", + attributes: ["transition-duration"], + source: "app/globals.css", + }, + ], + }, + layout: { + maxContentWidth: 1000, + containers: [], + }, + motion: [], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violation = report.violations.find((item) => item.type === "token-not-allowed"); + assert.ok(violation); + assert.equal(violation.details?.token, "var(--motion-duration-slow)"); + assert.equal(violation.details?.canonicalToken, "var(--motion-duration-slow)"); + assert.equal(violation.details?.normalizedValue, "300ms"); +}); + test("mixed allowed values pass exact matching through one path", () => { const contract = { ...baseContract,